reverseproxy: strip hop-by-hop headers from interim responses (#8256)
Some checks failed
Tests / test (./cmd/caddy/caddy, ~1.26.0, macos-14, 0, 1.26, mac) (push) Has been cancelled
Tests / test (./cmd/caddy/caddy, ~1.26.0, ubuntu-latest, 0, 1.26, linux) (push) Has been cancelled
Tests / test (./cmd/caddy/caddy.exe, ~1.26.0, windows-latest, True, 1.26, windows) (push) Has been cancelled
Tests / test (s390x on IBM Z) (push) Has been cancelled
Tests / goreleaser-check (push) Has been cancelled
Cross-Build / build (~1.26.0, 1.26, aix) (push) Has been cancelled
Cross-Build / build (~1.26.0, 1.26, darwin) (push) Has been cancelled
Cross-Build / build (~1.26.0, 1.26, dragonfly) (push) Has been cancelled
Cross-Build / build (~1.26.0, 1.26, freebsd) (push) Has been cancelled
Cross-Build / build (~1.26.0, 1.26, illumos) (push) Has been cancelled
Cross-Build / build (~1.26.0, 1.26, linux) (push) Has been cancelled
Cross-Build / build (~1.26.0, 1.26, netbsd) (push) Has been cancelled
Cross-Build / build (~1.26.0, 1.26, openbsd) (push) Has been cancelled
Cross-Build / build (~1.26.0, 1.26, solaris) (push) Has been cancelled
Cross-Build / build (~1.26.0, 1.26, windows) (push) Has been cancelled
Lint / lint (push) Has been cancelled
Lint / lint-1 (push) Has been cancelled
Lint / lint-2 (push) Has been cancelled
Lint / govulncheck (push) Has been cancelled
Lint / dependency-review (push) Has been cancelled
OpenSSF Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled

This commit is contained in:
Mohamed A. Abdel Fattah 2026-10-20 10:11:30 +03:00 • committed by GitHub
parent 8ba327ef3f
commit bfdbeb4a1b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 88 additions and 5 deletions

View file

@ -185,3 +185,77 @@ func TestInformationalResponseKeepsHandlerHeaders(t *testing.T) {
t.Errorf("Link from the 103 leaked into the final response: %q", got)
}
}
// TestInformationalResponseStripsHopByHopHeaders verifies that hop-by-hop
// headers from a 1xx response are not forwarded to the client, including
// headers nominated by the Connection header.
func TestInformationalResponseStripsHopByHopHeaders(t *testing.T) {
hopByHop := map[string]string{
"Alt-Svc": `h2="attacker.example:443"; ma=86400`,
"Keep-Alive": "timeout=999",
"Proxy-Authenticate": `Basic realm="phish"`,
"X-Internal": "secret",
}
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Link", "</style.css>; rel=preload")
for k, v := range hopByHop {
w.Header().Set(k, v)
}
w.Header().Set("Connection", "X-Internal")
w.WriteHeader(http.StatusEarlyHints)
for k := range hopByHop {
w.Header().Del(k)
}
w.Header().Del("Link")
w.Header().Del("Connection")
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) {
_ = 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")
}
for k := range hopByHop {
if got := earlyHints[0].Get(k); got != "" {
t.Errorf("hop-by-hop header %s on the 103: got %q, want it stripped", k, got)
}
}
if got := earlyHints[0].Get("Connection"); got != "" {
t.Errorf("Connection on the 103: got %q, want it stripped", got)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("status: got %d, want %d", resp.StatusCode, http.StatusOK)
}
}

View file

@ -1211,7 +1211,9 @@ func (h *Handler) reverseProxy(rw http.ResponseWriter, req *http.Request, origRe
}
h := rw.Header()
ownHeaders := h.Clone()
copyHeader(h, http.Header(header))
upstreamHeaders := http.Header(header).Clone()
stripHopByHopHeaders(upstreamHeaders)
copyHeader(h, upstreamHeaders)
rw.WriteHeader(code)
// Clear headers coming from the backend, keeping our own
@ -1399,10 +1401,7 @@ func (h *Handler) finalizeResponse(
// and restore it with a canonical Connection header.
upgradeVal := res.Header.Get("Upgrade")
removeConnectionHeaders(res.Header)
for _, h := range hopHeaders {
res.Header.Del(h)
}
stripHopByHopHeaders(res.Header)
if res.StatusCode == http.StatusSwitchingProtocols && upgradeVal != "" {
res.Header.Set("Upgrade", upgradeVal)
@ -1851,6 +1850,16 @@ func removeConnectionHeaders(h http.Header) {
}
}
// stripHopByHopHeaders removes hop-by-hop headers from h, including any
// headers nominated by the Connection header.
// See RFC 7230, section 6.1.
func stripHopByHopHeaders(h http.Header) {
removeConnectionHeaders(h)
for _, hop := range hopHeaders {
h.Del(hop)
}
}
// statusError returns an error value that has a status code.
func statusError(err error) error {
// if a response-based retry was exhausted, use the actual upstream