From 519be12306c5bfb325bd6bf9591ddbd4cdccff3e Mon Sep 17 00:00:00 2001 From: Jay Hemnani <193022578+jayhemnani9910@users.noreply.github.com> Date: Fri, 9 Oct 2026 12:12:26 +0530 Subject: [PATCH] reverseproxy: Keep own headers after 1xx responses (#8169) * reverseproxy: Keep own headers after 1xx responses Since #6656 the 1xx handler clears the whole header map after writing the informational response, which also drops headers set before proxying (for example by the header handler). Restore those after clearing, as #5091 did. * reverseproxy: Use maps.Copy to restore own headers * reverseproxy: Check the 103 reaches the client in the 1xx headers test --- .../caddyhttp/reverseproxy/headers_test.go | 60 +++++++++++++++++++ .../caddyhttp/reverseproxy/reverseproxy.go | 5 +- 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/modules/caddyhttp/reverseproxy/headers_test.go b/modules/caddyhttp/reverseproxy/headers_test.go index cb4a664f8..ad90d04f7 100644 --- a/modules/caddyhttp/reverseproxy/headers_test.go +++ b/modules/caddyhttp/reverseproxy/headers_test.go @@ -2,7 +2,10 @@ package reverseproxy import ( "context" + "net/http" "net/http/httptest" + "net/http/httptrace" + "net/textproto" "testing" "github.com/caddyserver/caddy/v2/modules/caddyhttp" @@ -125,3 +128,60 @@ func TestAddForwardedHeaders_UnixSocketTrustedNoExistingHeaders(t *testing.T) { t.Errorf("X-Forwarded-Host = %q, want %q", got, "example.com") } } + +// TestInformationalResponseKeepsHandlerHeaders verifies that a 1xx response +// from the upstream does not remove response headers set before proxying. +func TestInformationalResponseKeepsHandlerHeaders(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Link", "; rel=preload") + w.WriteHeader(http.StatusEarlyHints) + w.Header().Del("Link") + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(backend.Close) + + h := minimalHandler(0, &Upstream{Host: new(Host), Dial: backend.Listener.Addr().String()}) + + front := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Strict-Transport-Security", "max-age=31536000") + _ = h.ServeHTTP(w, prepareTestRequest(r), caddyhttp.HandlerFunc(func(http.ResponseWriter, *http.Request) error { + return nil + })) + })) + t.Cleanup(front.Close) + + var earlyHints []textproto.MIMEHeader + trace := &httptrace.ClientTrace{ + Got1xxResponse: func(code int, header textproto.MIMEHeader) error { + if code == http.StatusEarlyHints { + earlyHints = append(earlyHints, header) + } + return nil + }, + } + req, err := http.NewRequestWithContext(httptrace.WithClientTrace(context.Background(), trace), http.MethodGet, front.URL, nil) + if err != nil { + t.Fatal(err) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + resp.Body.Close() + + if len(earlyHints) != 1 { + t.Fatalf("103 responses: got %d, want 1", len(earlyHints)) + } + if got := earlyHints[0].Get("Link"); got != "; rel=preload" { + t.Errorf("Link on the 103: got %q, want %q", got, "; rel=preload") + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("status: got %d, want %d", resp.StatusCode, http.StatusOK) + } + if got := resp.Header.Get("Strict-Transport-Security"); got != "max-age=31536000" { + t.Errorf("Strict-Transport-Security: got %q, want %q", got, "max-age=31536000") + } + if got := resp.Header.Get("Link"); got != "" { + t.Errorf("Link from the 103 leaked into the final response: %q", got) + } +} diff --git a/modules/caddyhttp/reverseproxy/reverseproxy.go b/modules/caddyhttp/reverseproxy/reverseproxy.go index 874888101..05ffc7bc4 100644 --- a/modules/caddyhttp/reverseproxy/reverseproxy.go +++ b/modules/caddyhttp/reverseproxy/reverseproxy.go @@ -23,6 +23,7 @@ import ( "errors" "fmt" "io" + "maps" "net" "net/http" "net/http/httptrace" @@ -1174,12 +1175,14 @@ func (h *Handler) reverseProxy(rw http.ResponseWriter, req *http.Request, origRe return nil } h := rw.Header() + ownHeaders := h.Clone() copyHeader(h, http.Header(header)) rw.WriteHeader(code) - // Clear headers coming from the backend + // Clear headers coming from the backend, keeping our own // (it's not automatically done by ResponseWriter.WriteHeader() for 1xx responses) clear(h) + maps.Copy(h, ownHeaders) return nil },