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
This commit is contained in:
Jay Hemnani authored and GitHub committed 2026-10-09 16:42:26 +10:00
1 parent 1b3838c1fd
commit 519be12306
2 files changed
+64 -1

No files matched your search

@@ -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", "</style.css>; 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 != "</style.css>; rel=preload" {
t.Errorf("Link on the 103: got %q, want %q", got, "</style.css>; 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)
}
}
@@ -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
},