Skip to content

Commit a767e41

Browse files
authored
fix(cache-proxy): forward inbound body framing transparently (#527)
This is the actual root cause of the upstream-S3 501 NotImplemented errors on parquet writes that we've been chasing through three previous PRs. forwardUncached built its outbound request via http.NewRequestWithContext with the inbound r.Body as the body. Per Go's docs: When body is of type *bytes.Buffer, *bytes.Reader, or *strings.Reader, the returned request's ContentLength is set [...]. For other types, the default is left as 0; the body is then sent using chunked transfer encoding. r.Body is a generic io.ReadCloser, so the outbound req had ContentLength=0 and Go's Transport fell back to Transfer-Encoding: chunked. AWS S3 returns 501 NotImplemented for chunked PUT/POST regardless of whether the client intended chunked. So even though DuckDB sent a perfectly clean Content-Length-bearing PUT, the proxy was rewriting it as chunked and S3 rejected it. The chain of prior PRs (#516 deadlock fix, #518 / #519 logging, #524 / #525 / #526 visibility) gave us the breadcrumbs to actually see this; the fix itself is one line: mirror ContentLength + TransferEncoding + Trailer from the inbound request so the wire shape is preserved. Verified the chunked / 501 chain is impossible to recreate now: AWS Sigv4 signs `host;x-amz-content-sha256;x-amz-date` (per duckdb-httpfs/src/s3fs.cpp:84), so neither Content-Length nor Transfer-Encoding is in the signed headers list — meaning we're free to set Content-Length without invalidating the signature. Tests cover the proxy-as-transparent-forwarder invariant for the non-cached path: - TestForwardUncachedPropagatesContentLength: regression — outbound ContentLength matches inbound, no Transfer-Encoding: chunked. Verified this fails on the pre-fix code (origin sees ContentLength=-1 and Transfer-Encoding: chunked). - TestForwardUncachedPreservesRequestHeaders: Authorization, x-amz-*, custom headers round-trip to origin verbatim. - TestForwardUncachedStripsHopByHopBothDirections: Connection / Keep-Alive stripped per RFC 7230 in both request and response, while non-hop-by-hop headers pass through. - TestForwardUncachedPreservesQueryString: AWS multipart-upload params (?uploads, ?partNumber, ?uploadId=...) round-trip exactly so Sigv4's canonical-request hash is preserved. - TestForwardUncachedPreservesResponseBodyBytewise / Non2xx: response body bytes (binary, XML envelopes) are forwarded byte-for-byte. Locks in that the log_preview capture on non-2xx doesn't corrupt the body. - TestForwardUncachedPreservesMethod: PUT/POST/DELETE/HEAD all reach the origin unchanged.
1 parent bf258dd commit a767e41

2 files changed

Lines changed: 267 additions & 0 deletions

File tree

cmd/cache-proxy/proxy.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,22 @@ func (p *CacheProxy) serveBody(w http.ResponseWriter, data []byte, rangeHeader,
399399
// the non-cached path was a black hole and an upstream-rejected write
400400
// surfaced only as a DuckDB-side "HTTP code 501" with no proxy-side
401401
// breadcrumb to correlate against.
402+
//
403+
// Transparency: the proxy must not silently mutate the request shape DuckDB
404+
// chose, because AWS Sigv4 signs `host;x-amz-content-sha256;x-amz-date` (see
405+
// duckdb-httpfs/src/s3fs.cpp:84) — which means Content-Length and
406+
// Transfer-Encoding are NOT signed and we're free to set them, but we
407+
// should match what the client sent so the wire shape is preserved.
408+
//
409+
// The bug we're fixing here: http.NewRequestWithContext only auto-populates
410+
// req.ContentLength when the body is *bytes.Buffer / *bytes.Reader /
411+
// *strings.Reader (per its docstring). For a generic io.ReadCloser like
412+
// http.Request.Body, ContentLength stays 0 and Go's Transport falls back
413+
// to Transfer-Encoding: chunked on the outbound. AWS S3 returns
414+
// 501 NotImplemented for chunked PUT — so even though DuckDB sent a clean
415+
// Content-Length-bearing PUT, the proxy was rewriting it as chunked and
416+
// S3 rejected it. The fix is to mirror ContentLength + TransferEncoding +
417+
// Trailer from the inbound request so the proxy is wire-shape-transparent.
402418
func (p *CacheProxy) forwardUncached(w http.ResponseWriter, r *http.Request) {
403419
req, err := http.NewRequestWithContext(r.Context(), r.Method, r.URL.String(), r.Body)
404420
if err != nil {
@@ -407,6 +423,11 @@ func (p *CacheProxy) forwardUncached(w http.ResponseWriter, r *http.Request) {
407423
http.Error(w, err.Error(), http.StatusBadRequest)
408424
return
409425
}
426+
// Mirror body-framing fields the inbound request had so Go's Transport
427+
// sends the same encoding (Content-Length vs chunked) as DuckDB chose.
428+
req.ContentLength = r.ContentLength
429+
req.TransferEncoding = r.TransferEncoding
430+
req.Trailer = r.Trailer
410431
for k, vv := range r.Header {
411432
if hopByHop[strings.ToLower(k)] {
412433
continue

cmd/cache-proxy/proxy_test.go

Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -647,3 +647,249 @@ func TestForwardUncachedTruncatesLargeBodyInLog(t *testing.T) {
647647
t.Errorf("expected previewBody truncation marker in log, got:\n%s", out)
648648
}
649649
}
650+
651+
// TestForwardUncachedPropagatesContentLength is the regression test for the
652+
// AWS S3 "501 Not Implemented" error on parquet PUTs. The proxy used to
653+
// build its outbound request via http.NewRequestWithContext with the
654+
// inbound r.Body as the body — and because r.Body is a generic
655+
// io.ReadCloser (not one of the *bytes.Buffer / *bytes.Reader /
656+
// *strings.Reader types Go auto-detects), Go's Transport saw ContentLength=0
657+
// and fell back to Transfer-Encoding: chunked. S3 rejects chunked PUT with
658+
// 501 even though DuckDB sent a perfectly valid Content-Length-bearing
659+
// request. After the fix, the outbound request must carry over the inbound's
660+
// ContentLength so the wire shape is preserved.
661+
func TestForwardUncachedPropagatesContentLength(t *testing.T) {
662+
proxy := newTestProxy(t)
663+
payload := []byte("parquet-bytes-of-known-length")
664+
665+
var (
666+
gotMethod string
667+
gotContentLength int64
668+
gotTE string
669+
gotBody []byte
670+
)
671+
_, originURL := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
672+
gotMethod = r.Method
673+
gotContentLength = r.ContentLength
674+
gotTE = r.Header.Get("Transfer-Encoding")
675+
// httputil sometimes also strips Transfer-Encoding into r.TransferEncoding
676+
if gotTE == "" && len(r.TransferEncoding) > 0 {
677+
gotTE = strings.Join(r.TransferEncoding, ",")
678+
}
679+
gotBody, _ = io.ReadAll(r.Body)
680+
w.WriteHeader(http.StatusOK)
681+
})
682+
683+
req := httptest.NewRequest(http.MethodPut, originURL+"/b/k.parquet", bytes.NewReader(payload))
684+
req.Host = req.URL.Host
685+
// httptest.NewRequest only auto-sets ContentLength for the special body
686+
// types; do it explicitly so we're modeling the inbound shape DuckDB
687+
// uses (Content-Length present, no Transfer-Encoding).
688+
req.ContentLength = int64(len(payload))
689+
rec := httptest.NewRecorder()
690+
691+
proxy.HandleProxy(rec, req)
692+
693+
if rec.Code != http.StatusOK {
694+
t.Fatalf("status = %d, want 200", rec.Code)
695+
}
696+
if gotMethod != http.MethodPut {
697+
t.Errorf("origin saw method %q, want PUT", gotMethod)
698+
}
699+
if gotContentLength != int64(len(payload)) {
700+
t.Errorf("origin saw ContentLength=%d, want %d (proxy not propagating)", gotContentLength, len(payload))
701+
}
702+
if gotTE == "chunked" {
703+
t.Errorf("origin saw Transfer-Encoding: chunked — proxy is rewriting Content-Length-bearing PUTs as chunked, AWS S3 returns 501 for this")
704+
}
705+
if !bytes.Equal(gotBody, payload) {
706+
t.Errorf("body mismatch:\n got: %q\n want: %q", gotBody, payload)
707+
}
708+
}
709+
710+
// TestForwardUncachedPreservesRequestHeaders verifies that arbitrary
711+
// non-hop-by-hop request headers (Authorization, x-amz-*, custom) round-trip
712+
// to the origin unchanged. Critical for AWS Sigv4: any header in the signed
713+
// list (host;x-amz-content-sha256;x-amz-date) being mutated would invalidate
714+
// the signature; the rest still matter for content addressability.
715+
func TestForwardUncachedPreservesRequestHeaders(t *testing.T) {
716+
proxy := newTestProxy(t)
717+
want := map[string]string{
718+
"Authorization": "AWS4-HMAC-SHA256 Credential=AKIA/...",
719+
"X-Amz-Content-Sha256": "UNSIGNED-PAYLOAD",
720+
"X-Amz-Date": "20260505T120000Z",
721+
"X-Amz-Security-Token": "FwoGZ...",
722+
"Content-Type": "application/octet-stream",
723+
"X-Custom-Header": "verbatim",
724+
}
725+
726+
got := map[string]string{}
727+
_, originURL := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
728+
for k := range want {
729+
got[k] = r.Header.Get(k)
730+
}
731+
w.WriteHeader(http.StatusOK)
732+
})
733+
734+
req := httptest.NewRequest(http.MethodPut, originURL+"/b/k", bytes.NewReader([]byte("x")))
735+
req.Host = req.URL.Host
736+
req.ContentLength = 1
737+
for k, v := range want {
738+
req.Header.Set(k, v)
739+
}
740+
741+
proxy.HandleProxy(httptest.NewRecorder(), req)
742+
743+
for k, w := range want {
744+
if got[k] != w {
745+
t.Errorf("header %s: got %q, want %q", k, got[k], w)
746+
}
747+
}
748+
}
749+
750+
// TestForwardUncachedStripsHopByHopBothDirections verifies that
751+
// hop-by-hop headers (per RFC 7230 §6.1) are NOT forwarded in either
752+
// direction. Forwarding hop-by-hop headers can confuse origin / client
753+
// parsers — Connection, Keep-Alive, TE, Trailers, Transfer-Encoding,
754+
// Upgrade, Proxy-Authorization, Proxy-Authenticate.
755+
func TestForwardUncachedStripsHopByHopBothDirections(t *testing.T) {
756+
proxy := newTestProxy(t)
757+
758+
var originSawConnection, originSawKeepAlive string
759+
_, originURL := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
760+
originSawConnection = r.Header.Get("Connection")
761+
originSawKeepAlive = r.Header.Get("Keep-Alive")
762+
w.Header().Set("Connection", "should-not-leak")
763+
w.Header().Set("Keep-Alive", "timeout=5")
764+
w.Header().Set("X-Allowed", "yes")
765+
w.WriteHeader(http.StatusOK)
766+
})
767+
768+
req := httptest.NewRequest(http.MethodPut, originURL+"/b/k", bytes.NewReader([]byte("x")))
769+
req.Host = req.URL.Host
770+
req.ContentLength = 1
771+
req.Header.Set("Connection", "close")
772+
req.Header.Set("Keep-Alive", "timeout=5")
773+
rec := httptest.NewRecorder()
774+
775+
proxy.HandleProxy(rec, req)
776+
777+
if originSawConnection != "" {
778+
t.Errorf("origin saw Connection header from inbound, hop-by-hop should be stripped: %q", originSawConnection)
779+
}
780+
if originSawKeepAlive != "" {
781+
t.Errorf("origin saw Keep-Alive header from inbound, hop-by-hop should be stripped: %q", originSawKeepAlive)
782+
}
783+
if got := rec.Header().Get("Connection"); got != "" {
784+
t.Errorf("client saw response Connection header from origin, hop-by-hop should be stripped: %q", got)
785+
}
786+
if got := rec.Header().Get("Keep-Alive"); got != "" {
787+
t.Errorf("client saw response Keep-Alive header, hop-by-hop should be stripped: %q", got)
788+
}
789+
if got := rec.Header().Get("X-Allowed"); got != "yes" {
790+
t.Errorf("client lost non-hop-by-hop response header X-Allowed: %q", got)
791+
}
792+
}
793+
794+
// TestForwardUncachedPreservesQueryString covers AWS S3 multipart-upload
795+
// query params (?uploads, ?partNumber=N&uploadId=...). Sigv4's canonical
796+
// request includes the query string, so any mutation would 403.
797+
func TestForwardUncachedPreservesQueryString(t *testing.T) {
798+
proxy := newTestProxy(t)
799+
800+
var gotQuery string
801+
_, originURL := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
802+
gotQuery = r.URL.RawQuery
803+
w.WriteHeader(http.StatusOK)
804+
})
805+
806+
url := originURL + "/b/k.parquet?uploads=&partNumber=3&uploadId=ABC%3DDEF"
807+
req := httptest.NewRequest(http.MethodPost, url, bytes.NewReader([]byte("x")))
808+
req.Host = req.URL.Host
809+
req.ContentLength = 1
810+
proxy.HandleProxy(httptest.NewRecorder(), req)
811+
812+
if gotQuery != "uploads=&partNumber=3&uploadId=ABC%3DDEF" {
813+
t.Errorf("query mutated:\n got: %q\n want: %q", gotQuery, "uploads=&partNumber=3&uploadId=ABC%3DDEF")
814+
}
815+
}
816+
817+
// TestForwardUncachedPreservesResponseBodyBytewise locks in that 2xx
818+
// response bodies are forwarded byte-for-byte (no compression / no
819+
// transcoding). DuckDB downstream may parse this as XML / parquet and
820+
// any mutation would corrupt it.
821+
func TestForwardUncachedPreservesResponseBodyBytewise(t *testing.T) {
822+
proxy := newTestProxy(t)
823+
824+
originBody := []byte("\x00\x01\x02\x03 binary <xml/> body \xff\xfe")
825+
_, originURL := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
826+
w.Header().Set("Content-Type", "application/octet-stream")
827+
w.WriteHeader(http.StatusOK)
828+
_, _ = w.Write(originBody)
829+
})
830+
831+
req := httptest.NewRequest(http.MethodPut, originURL+"/b/k", bytes.NewReader([]byte("x")))
832+
req.Host = req.URL.Host
833+
req.ContentLength = 1
834+
rec := httptest.NewRecorder()
835+
proxy.HandleProxy(rec, req)
836+
837+
if !bytes.Equal(rec.Body.Bytes(), originBody) {
838+
t.Errorf("response body mutated:\n got: %q\n want: %q", rec.Body.Bytes(), originBody)
839+
}
840+
if got := rec.Header().Get("Content-Type"); got != "application/octet-stream" {
841+
t.Errorf("Content-Type lost: got %q", got)
842+
}
843+
}
844+
845+
// TestForwardUncachedPreservesNon2xxResponseBodyBytewise covers the
846+
// log-preview path on non-2xx — the proxy reads the full body for the
847+
// body_preview log attr, but must still forward it verbatim to the client.
848+
// Specifically the AWS S3 XML error envelope has to reach DuckDB so its
849+
// own error parsing can extract the <Code>...</Code>.
850+
func TestForwardUncachedPreservesNon2xxResponseBodyBytewise(t *testing.T) {
851+
proxy := newTestProxy(t)
852+
853+
originBody := []byte(`<?xml version="1.0"?><Error><Code>NotImplemented</Code><Message>foo</Message></Error>`)
854+
_, originURL := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
855+
w.WriteHeader(http.StatusNotImplemented)
856+
_, _ = w.Write(originBody)
857+
})
858+
859+
req := httptest.NewRequest(http.MethodPut, originURL+"/b/k", bytes.NewReader([]byte("x")))
860+
req.Host = req.URL.Host
861+
req.ContentLength = 1
862+
rec := httptest.NewRecorder()
863+
proxy.HandleProxy(rec, req)
864+
865+
if rec.Code != http.StatusNotImplemented {
866+
t.Fatalf("status = %d, want 501", rec.Code)
867+
}
868+
if !bytes.Equal(rec.Body.Bytes(), originBody) {
869+
t.Errorf("non-2xx body mutated:\n got: %q\n want: %q", rec.Body.Bytes(), originBody)
870+
}
871+
}
872+
873+
// TestForwardUncachedPreservesMethod walks every method that hits the
874+
// non-GET path and checks the origin saw the same method. Ensures we
875+
// don't accidentally specialise on PUT/POST and break HEAD/DELETE/etc.
876+
func TestForwardUncachedPreservesMethod(t *testing.T) {
877+
for _, method := range []string{http.MethodPut, http.MethodPost, http.MethodDelete, http.MethodHead} {
878+
t.Run(method, func(t *testing.T) {
879+
proxy := newTestProxy(t)
880+
var gotMethod string
881+
_, originURL := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
882+
gotMethod = r.Method
883+
w.WriteHeader(http.StatusOK)
884+
})
885+
886+
req := httptest.NewRequest(method, originURL+"/b/k", bytes.NewReader([]byte("x")))
887+
req.Host = req.URL.Host
888+
req.ContentLength = 1
889+
proxy.HandleProxy(httptest.NewRecorder(), req)
890+
if gotMethod != method {
891+
t.Errorf("method mutated: got %q, want %q", gotMethod, method)
892+
}
893+
})
894+
}
895+
}

0 commit comments

Comments
 (0)