From b60636f05c65784b2a3d152403e0dde4411f7fcb Mon Sep 17 00:00:00 2001 From: Jeff Haynie Date: Sun, 29 Mar 2026 20:01:17 -0700 Subject: [PATCH 1/5] feat: add upstream proxy signature verification and fix Director deprecation - Replace deprecated proxy.Director with Rewrite (httputil.ReverseProxy) - Add verifyUpstreamSignature() to validate incoming requests were signed by the Agentuity ion proxy using go-common/crypto HTTP signature headers - Signature is read from HTTP trailer; body is buffered and restored for downstream proxying - Verification currently logs warnings only (non-enforcing) pending the ion-side change to send its signing public key via provider.Configuration --- cmd/start.go | 2 +- go.mod | 2 +- go.sum | 4 +- internal/stack/stack.go | 112 ++++++++++++++++++++++++++++++---------- 4 files changed, 90 insertions(+), 30 deletions(-) diff --git a/cmd/start.go b/cmd/start.go index 5c78be1..f1ca834 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -147,7 +147,7 @@ var rootCmd = &cobra.Command{ if err != nil { return fmt.Errorf("failed to generate certificate: %w", err) } - newServer, err := stack.StartServer(ctx, logger, tlsConfig, urls) + newServer, err := stack.StartServer(ctx, logger, tlsConfig, urls, agent) if err != nil { return fmt.Errorf("failed to start server: %w", err) } diff --git a/go.mod b/go.mod index 11396c5..ddcbd39 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/agentuity/gravity go 1.26.1 require ( - github.com/agentuity/go-common v1.0.165 + github.com/agentuity/go-common v1.0.180 github.com/spf13/cobra v1.10.1 gvisor.dev/gvisor v0.0.0-20240423190808-9d7a357edefe ) diff --git a/go.sum b/go.sum index 50479d5..2fe0d1c 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -github.com/agentuity/go-common v1.0.165 h1:hxaGFRg05/ir4OqoIpCQHhgGhs9VKeR564EsLhEN8Gc= -github.com/agentuity/go-common v1.0.165/go.mod h1:uW1IsiE9ydoK6HRwr8jgEE8wVXSXoFzhm/AJ8Q4xlos= +github.com/agentuity/go-common v1.0.180 h1:fHmSqYAAAj3NH3Ej3wi49nSV3TJAwgN+dmF5r0yZy4c= +github.com/agentuity/go-common v1.0.180/go.mod h1:YuiBVsz9WZ5K1vW2cjHvjUnuA65t7YZTHj4Nq11b0UQ= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cockroachdb/errors v1.12.0 h1:d7oCs6vuIMUQRVbi6jWWWEJZahLCfJpnJSVobd1/sUo= diff --git a/internal/stack/stack.go b/internal/stack/stack.go index 6413add..6e15670 100644 --- a/internal/stack/stack.go +++ b/internal/stack/stack.go @@ -1,6 +1,7 @@ package stack import ( + "bytes" "context" "crypto/ecdsa" "crypto/tls" @@ -17,6 +18,7 @@ import ( "sync" "time" + agcrypto "github.com/agentuity/go-common/crypto" "github.com/agentuity/go-common/gravity" "github.com/agentuity/go-common/gravity/proto" "github.com/agentuity/go-common/gravity/provider" @@ -131,7 +133,7 @@ func GenerateCertificate(_ context.Context, logger _logger.Logger, bundle string return tlsConfig, nil } -func StartServer(ctx context.Context, logger _logger.Logger, tlsConfig *tls.Config, urls UrlsMetadata) (*http.Server, error) { +func StartServer(ctx context.Context, logger _logger.Logger, tlsConfig *tls.Config, urls UrlsMetadata, agent AgentMetadata) (*http.Server, error) { // Set up reverse proxy to the agent server agentURL := fmt.Sprintf("http://127.0.0.1:%d", urls.LocalPort) @@ -140,31 +142,31 @@ func StartServer(ctx context.Context, logger _logger.Logger, tlsConfig *tls.Conf return nil, fmt.Errorf("failed to parse agent URL: %w", err) } - proxy := httputil.NewSingleHostReverseProxy(upstreamURL) - // Override the Director to restore the original public hostname from - // X-Forwarded-Host. Without this, the Host header may contain internal - // routing names (e.g., "*.agentuity-us.live.internal") that leak through - // to the local dev server. Vite and other dev servers check the Host - // header against their allowedHosts list and reject unrecognized hostnames. - defaultDirector := proxy.Director - proxy.Director = func(req *http.Request) { - defaultDirector(req) - if fwdHost := req.Header.Get("X-Forwarded-Host"); fwdHost != "" { - req.Host = fwdHost - } - } - proxy.FlushInterval = -1 - proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) { - // Suppress expected context cancellation errors (client disconnect, WebSocket close) - if errors.Is(ctx.Err(), context.Canceled) || errors.Is(r.Context().Err(), context.Canceled) { - return - } - logger.Error("proxy error: %v", err) - http.Error(w, http.StatusText(http.StatusBadGateway), http.StatusBadGateway) - } - proxy.ModifyResponse = func(resp *http.Response) error { - logger.Trace("response %s: %d", resp.Request.URL.Path, resp.StatusCode) - return nil + proxy := &httputil.ReverseProxy{ + // Use Rewrite instead of the deprecated Director. Restore the original + // public hostname from X-Forwarded-Host so that the Host header sent + // to the local dev server matches the public URL. Vite and other dev + // servers check the Host header against their allowedHosts list and + // reject unrecognized hostnames. + Rewrite: func(r *httputil.ProxyRequest) { + r.SetURL(upstreamURL) + if fwdHost := r.In.Header.Get("X-Forwarded-Host"); fwdHost != "" { + r.Out.Host = fwdHost + } + }, + FlushInterval: -1, + ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) { + // Suppress expected context cancellation errors (client disconnect, WebSocket close) + if errors.Is(ctx.Err(), context.Canceled) || errors.Is(r.Context().Err(), context.Canceled) { + return + } + logger.Error("proxy error: %v", err) + http.Error(w, http.StatusText(http.StatusBadGateway), http.StatusBadGateway) + }, + ModifyResponse: func(resp *http.Response) error { + logger.Trace("response %s: %d", resp.Request.URL.Path, resp.StatusCode) + return nil + }, } server := &http.Server{ @@ -186,6 +188,15 @@ func StartServer(ctx context.Context, logger _logger.Logger, tlsConfig *tls.Conf return default: } + + // Verify the request was signed by the upstream Agentuity ion proxy. + if agent.PrivateKey != nil { + if err := verifyUpstreamSignature(logger, &agent.PrivateKey.PublicKey, r); err != nil { + logger.Warn("upstream proxy verification: %v", err) + // TODO: once verified working, reject unsigned requests here. + } + } + proxy.ServeHTTP(w, r) }), } @@ -218,6 +229,55 @@ func StartServer(ctx context.Context, logger _logger.Logger, tlsConfig *tls.Conf return server, serverErr } +// verifyUpstreamSignature checks that the incoming request was signed by the +// Agentuity ion proxy. It reads the body so that the HTTP trailer (where the +// proxy sends the Signature value) becomes available, verifies the signature, +// and restores the body for downstream proxying. +func verifyUpstreamSignature(logger _logger.Logger, publicKey *ecdsa.PublicKey, r *http.Request) error { + alg := r.Header.Get(agcrypto.HeaderSignatureAlg) + keyID := r.Header.Get(agcrypto.HeaderSignatureKeyID) + timestamp := r.Header.Get(agcrypto.HeaderSignatureTimestamp) + nonce := r.Header.Get(agcrypto.HeaderSignatureNonce) + + logger.Debug("upstream signature: alg=%s keyid=%s timestamp=%s nonce=%s via=%s", + alg, keyID, timestamp, nonce, r.Header.Get("Via")) + + if alg == "" { + return fmt.Errorf("no signature headers present") + } + + // Read the full body so HTTP trailers become available. + body, err := io.ReadAll(r.Body) + if err != nil { + return fmt.Errorf("read body: %w", err) + } + r.Body.Close() + + // The ion proxy sends the Signature as an HTTP trailer. + sig := r.Trailer.Get(agcrypto.HeaderSignature) + source := "trailer" + if sig == "" { + sig = r.Header.Get(agcrypto.HeaderSignature) + source = "header" + if sig == "" { + source = "missing" + } + } + logger.Debug("upstream signature value: source=%s present=%v", source, sig != "") + + // Verify the cryptographic signature against the agent's public key. + verifyErr := agcrypto.VerifyHTTPRequest(publicKey, r, body, nil) + if verifyErr != nil { + logger.Debug("upstream signature verification failed: %v", verifyErr) + } else { + logger.Debug("upstream signature verification succeeded") + } + + // Always restore the body so the reverse proxy can forward it. + r.Body = io.NopCloser(bytes.NewReader(body)) + return verifyErr +} + func CreateNetworkStack(logger _logger.Logger, urls UrlsMetadata) (*stack.Stack, *channel.Endpoint, error) { s := stack.New(stack.Options{ From 0d230c25d2b637243cf9ba9e7852593c88f17e54 Mon Sep 17 00:00:00 2001 From: Jeff Haynie Date: Sun, 29 Mar 2026 23:59:21 -0700 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20buffer=20request=20body=20for=20HTT?= =?UTF-8?q?P/2=E2=86=92HTTP/1.1=20proxy=20bridging=20and=20wire=20signing?= =?UTF-8?q?=20key?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ion signing transport removes Content-Length to enable streaming signature trailers over HTTP/2. When the gravity dev-mode proxy forwards to the local agent over HTTP/1.1, the missing Content-Length causes chunked transfer encoding which many dev servers cannot parse (400). Buffer the body and restore Content-Length before forwarding. Also wire the signing public key from ion through the Configuration so signature verification is ready to enable once all ion instances consistently derive the same HKDF signing key. Changes: - stack.go: buffer body when Content-Length is -1, restore Content-Length - stack.go: refactor verifyUpstreamSignature to accept pre-buffered body bytes without touching r.Body (verification disabled via TODO) - cmd/start.go: parse SigningPublicKey PEM from Configuration, pass to StartServer - go.mod: bump go-common to v1.0.181 --- cmd/start.go | 17 ++++++++++++- go.mod | 2 +- go.sum | 4 +-- internal/stack/stack.go | 56 +++++++++++++++++++++++++---------------- 4 files changed, 54 insertions(+), 25 deletions(-) diff --git a/cmd/start.go b/cmd/start.go index f1ca834..72e963b 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -147,7 +147,22 @@ var rootCmd = &cobra.Command{ if err != nil { return fmt.Errorf("failed to generate certificate: %w", err) } - newServer, err := stack.StartServer(ctx, logger, tlsConfig, urls, agent) + var signingKey *ecdsa.PublicKey + if len(c.SigningPublicKey) > 0 { + block, _ := pem.Decode(c.SigningPublicKey) + if block != nil { + pub, err := x509.ParsePKIXPublicKey(block.Bytes) + if err == nil { + signingKey, _ = pub.(*ecdsa.PublicKey) + } + } + if signingKey != nil { + logger.Info("using upstream signing key for proxy verification") + } else { + logger.Warn("received signing public key but failed to parse it") + } + } + newServer, err := stack.StartServer(ctx, logger, tlsConfig, urls, agent, signingKey) if err != nil { return fmt.Errorf("failed to start server: %w", err) } diff --git a/go.mod b/go.mod index ddcbd39..839ebbd 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/agentuity/gravity go 1.26.1 require ( - github.com/agentuity/go-common v1.0.180 + github.com/agentuity/go-common v1.0.181 github.com/spf13/cobra v1.10.1 gvisor.dev/gvisor v0.0.0-20240423190808-9d7a357edefe ) diff --git a/go.sum b/go.sum index 2fe0d1c..87d5d85 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -github.com/agentuity/go-common v1.0.180 h1:fHmSqYAAAj3NH3Ej3wi49nSV3TJAwgN+dmF5r0yZy4c= -github.com/agentuity/go-common v1.0.180/go.mod h1:YuiBVsz9WZ5K1vW2cjHvjUnuA65t7YZTHj4Nq11b0UQ= +github.com/agentuity/go-common v1.0.181 h1:+mJSQhZdPj++ZxSyIwM3BtG7GcCmLAahWlfRcfaI2Lc= +github.com/agentuity/go-common v1.0.181/go.mod h1:YuiBVsz9WZ5K1vW2cjHvjUnuA65t7YZTHj4Nq11b0UQ= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cockroachdb/errors v1.12.0 h1:d7oCs6vuIMUQRVbi6jWWWEJZahLCfJpnJSVobd1/sUo= diff --git a/internal/stack/stack.go b/internal/stack/stack.go index 6e15670..7a87c55 100644 --- a/internal/stack/stack.go +++ b/internal/stack/stack.go @@ -133,7 +133,7 @@ func GenerateCertificate(_ context.Context, logger _logger.Logger, bundle string return tlsConfig, nil } -func StartServer(ctx context.Context, logger _logger.Logger, tlsConfig *tls.Config, urls UrlsMetadata, agent AgentMetadata) (*http.Server, error) { +func StartServer(ctx context.Context, logger _logger.Logger, tlsConfig *tls.Config, urls UrlsMetadata, agent AgentMetadata, signingKey *ecdsa.PublicKey) (*http.Server, error) { // Set up reverse proxy to the agent server agentURL := fmt.Sprintf("http://127.0.0.1:%d", urls.LocalPort) @@ -169,6 +169,11 @@ func StartServer(ctx context.Context, logger _logger.Logger, tlsConfig *tls.Conf }, } + // Log once if signing key is missing (avoid flooding logs on every request). + if signingKey == nil && agent.PrivateKey != nil { + logger.Warn("no upstream signing key available, skipping signature verification") + } + server := &http.Server{ Addr: fmt.Sprintf(":%d", urls.ProxyPort), TLSConfig: tlsConfig, @@ -189,14 +194,31 @@ func StartServer(ctx context.Context, logger _logger.Logger, tlsConfig *tls.Conf default: } - // Verify the request was signed by the upstream Agentuity ion proxy. - if agent.PrivateKey != nil { - if err := verifyUpstreamSignature(logger, &agent.PrivateKey.PublicKey, r); err != nil { - logger.Warn("upstream proxy verification: %v", err) - // TODO: once verified working, reject unsigned requests here. + // The ion signing transport removes Content-Length to enable + // streaming signature trailers over HTTP/2. When the reverse proxy + // forwards to the local agent over HTTP/1.1, the missing + // Content-Length causes chunked transfer encoding which many dev + // servers cannot parse. Buffer the body and restore Content-Length. + var bodyBytes []byte + if r.ContentLength < 0 && r.Body != nil && r.Body != http.NoBody { + var err error + bodyBytes, err = io.ReadAll(r.Body) + if err != nil { + logger.Error("failed to read request body: %v", err) + http.Error(w, "Bad Gateway", http.StatusBadGateway) + return } + r.Body = io.NopCloser(bytes.NewReader(bodyBytes)) + r.ContentLength = int64(len(bodyBytes)) } + // TODO: Enable upstream signature verification once all ion + // instances consistently derive the same HKDF signing key. + // Currently disabled because multiple ion instances may have + // different keys during rolling deployments, and POST body + // hash verification needs further investigation. + // See: verifyUpstreamSignature() + proxy.ServeHTTP(w, r) }), } @@ -230,10 +252,10 @@ func StartServer(ctx context.Context, logger _logger.Logger, tlsConfig *tls.Conf } // verifyUpstreamSignature checks that the incoming request was signed by the -// Agentuity ion proxy. It reads the body so that the HTTP trailer (where the -// proxy sends the Signature value) becomes available, verifies the signature, -// and restores the body for downstream proxying. -func verifyUpstreamSignature(logger _logger.Logger, publicKey *ecdsa.PublicKey, r *http.Request) error { +// Agentuity ion proxy. The caller passes the already-buffered body bytes so +// this function never reads from or replaces r.Body — keeping the reverse +// proxy's body reader untouched. +func verifyUpstreamSignature(logger _logger.Logger, publicKey *ecdsa.PublicKey, r *http.Request, body []byte) error { alg := r.Header.Get(agcrypto.HeaderSignatureAlg) keyID := r.Header.Get(agcrypto.HeaderSignatureKeyID) timestamp := r.Header.Get(agcrypto.HeaderSignatureTimestamp) @@ -246,14 +268,8 @@ func verifyUpstreamSignature(logger _logger.Logger, publicKey *ecdsa.PublicKey, return fmt.Errorf("no signature headers present") } - // Read the full body so HTTP trailers become available. - body, err := io.ReadAll(r.Body) - if err != nil { - return fmt.Errorf("read body: %w", err) - } - r.Body.Close() - - // The ion proxy sends the Signature as an HTTP trailer. + // The ion proxy sends the Signature as an HTTP trailer (for streaming + // requests) or as a header (for WebSocket requests). sig := r.Trailer.Get(agcrypto.HeaderSignature) source := "trailer" if sig == "" { @@ -265,7 +281,7 @@ func verifyUpstreamSignature(logger _logger.Logger, publicKey *ecdsa.PublicKey, } logger.Debug("upstream signature value: source=%s present=%v", source, sig != "") - // Verify the cryptographic signature against the agent's public key. + // Verify the cryptographic signature against the upstream signing key. verifyErr := agcrypto.VerifyHTTPRequest(publicKey, r, body, nil) if verifyErr != nil { logger.Debug("upstream signature verification failed: %v", verifyErr) @@ -273,8 +289,6 @@ func verifyUpstreamSignature(logger _logger.Logger, publicKey *ecdsa.PublicKey, logger.Debug("upstream signature verification succeeded") } - // Always restore the body so the reverse proxy can forward it. - r.Body = io.NopCloser(bytes.NewReader(body)) return verifyErr } From 0989fbb1935932e605c6f39ff363d88064552c4a Mon Sep 17 00:00:00 2001 From: Jeff Haynie Date: Mon, 30 Mar 2026 00:08:08 -0700 Subject: [PATCH 3/5] fix: differentiate PEM decode, parse, and type errors in signing key handling --- cmd/start.go | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/cmd/start.go b/cmd/start.go index 72e963b..aad3a7b 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -150,16 +150,15 @@ var rootCmd = &cobra.Command{ var signingKey *ecdsa.PublicKey if len(c.SigningPublicKey) > 0 { block, _ := pem.Decode(c.SigningPublicKey) - if block != nil { - pub, err := x509.ParsePKIXPublicKey(block.Bytes) - if err == nil { - signingKey, _ = pub.(*ecdsa.PublicKey) - } - } - if signingKey != nil { - logger.Info("using upstream signing key for proxy verification") + if block == nil { + logger.Warn("received signing public key but PEM decode failed") + } else if pub, err := x509.ParsePKIXPublicKey(block.Bytes); err != nil { + logger.Warn("received signing public key but PKIX parse failed: %v", err) + } else if ecKey, ok := pub.(*ecdsa.PublicKey); !ok { + logger.Warn("received signing public key but unexpected type: %T", pub) } else { - logger.Warn("received signing public key but failed to parse it") + signingKey = ecKey + logger.Info("using upstream signing key for proxy verification") } } newServer, err := stack.StartServer(ctx, logger, tlsConfig, urls, agent, signingKey) From bdbbcb9d835dfa7c2b85533c006f528310248d86 Mon Sep 17 00:00:00 2001 From: Jeff Haynie Date: Mon, 30 Mar 2026 00:27:58 -0700 Subject: [PATCH 4/5] fix: remove body buffering now that ion preserves Content-Length Ion PR #380 preserves Content-Length through the signing transport, so gravity no longer needs to buffer the request body in RAM to restore it. Requests pass straight through to the reverse proxy. --- internal/stack/stack.go | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/internal/stack/stack.go b/internal/stack/stack.go index 7a87c55..b9adb87 100644 --- a/internal/stack/stack.go +++ b/internal/stack/stack.go @@ -1,7 +1,6 @@ package stack import ( - "bytes" "context" "crypto/ecdsa" "crypto/tls" @@ -194,24 +193,6 @@ func StartServer(ctx context.Context, logger _logger.Logger, tlsConfig *tls.Conf default: } - // The ion signing transport removes Content-Length to enable - // streaming signature trailers over HTTP/2. When the reverse proxy - // forwards to the local agent over HTTP/1.1, the missing - // Content-Length causes chunked transfer encoding which many dev - // servers cannot parse. Buffer the body and restore Content-Length. - var bodyBytes []byte - if r.ContentLength < 0 && r.Body != nil && r.Body != http.NoBody { - var err error - bodyBytes, err = io.ReadAll(r.Body) - if err != nil { - logger.Error("failed to read request body: %v", err) - http.Error(w, "Bad Gateway", http.StatusBadGateway) - return - } - r.Body = io.NopCloser(bytes.NewReader(bodyBytes)) - r.ContentLength = int64(len(bodyBytes)) - } - // TODO: Enable upstream signature verification once all ion // instances consistently derive the same HKDF signing key. // Currently disabled because multiple ion instances may have From a9e526c32fd9b51daae948b95c70a7e387824731 Mon Sep 17 00:00:00 2001 From: Jeff Haynie Date: Mon, 30 Mar 2026 00:35:03 -0700 Subject: [PATCH 5/5] chore: disable signature verification for now MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plumbing is in place (signing key parsing, verifyUpstreamSignature function, StartServer parameter) but verification is disabled pending investigation of keyid inconsistencies across ion instances. The Content-Length fix (ion PR #380) is the ship-blocking change — it resolves the 400 Bad Request on POST endpoints. --- internal/stack/stack.go | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/internal/stack/stack.go b/internal/stack/stack.go index b9adb87..e268052 100644 --- a/internal/stack/stack.go +++ b/internal/stack/stack.go @@ -1,6 +1,7 @@ package stack import ( + "bytes" "context" "crypto/ecdsa" "crypto/tls" @@ -193,13 +194,6 @@ func StartServer(ctx context.Context, logger _logger.Logger, tlsConfig *tls.Conf default: } - // TODO: Enable upstream signature verification once all ion - // instances consistently derive the same HKDF signing key. - // Currently disabled because multiple ion instances may have - // different keys during rolling deployments, and POST body - // hash verification needs further investigation. - // See: verifyUpstreamSignature() - proxy.ServeHTTP(w, r) }), } @@ -233,10 +227,10 @@ func StartServer(ctx context.Context, logger _logger.Logger, tlsConfig *tls.Conf } // verifyUpstreamSignature checks that the incoming request was signed by the -// Agentuity ion proxy. The caller passes the already-buffered body bytes so -// this function never reads from or replaces r.Body — keeping the reverse -// proxy's body reader untouched. -func verifyUpstreamSignature(logger _logger.Logger, publicKey *ecdsa.PublicKey, r *http.Request, body []byte) error { +// Agentuity ion proxy. It reads the body so that HTTP trailers become +// available, verifies the signature, and restores the body and Content-Length +// for downstream proxying. +func verifyUpstreamSignature(logger _logger.Logger, publicKey *ecdsa.PublicKey, r *http.Request) error { alg := r.Header.Get(agcrypto.HeaderSignatureAlg) keyID := r.Header.Get(agcrypto.HeaderSignatureKeyID) timestamp := r.Header.Get(agcrypto.HeaderSignatureTimestamp) @@ -249,6 +243,15 @@ func verifyUpstreamSignature(logger _logger.Logger, publicKey *ecdsa.PublicKey, return fmt.Errorf("no signature headers present") } + // Read the full body so HTTP trailers become available. Save the + // original Content-Length so we can restore it after verification — + // the reverse proxy needs it to avoid chunked encoding to localhost. + origContentLength := r.ContentLength + body, err := io.ReadAll(r.Body) + if err != nil { + return fmt.Errorf("read body: %w", err) + } + // The ion proxy sends the Signature as an HTTP trailer (for streaming // requests) or as a header (for WebSocket requests). sig := r.Trailer.Get(agcrypto.HeaderSignature) @@ -270,6 +273,10 @@ func verifyUpstreamSignature(logger _logger.Logger, publicKey *ecdsa.PublicKey, logger.Debug("upstream signature verification succeeded") } + // Restore the body and Content-Length so the reverse proxy can forward it. + r.Body = io.NopCloser(bytes.NewReader(body)) + r.ContentLength = origContentLength + return verifyErr }