Skip to content

reverseproxy: re-normalize WebSocket header casing after header op rebuild#7803

Closed
tejgokani wants to merge 1 commit into
caddyserver:masterfrom
tejgokani:fix/7784-ws-header-casing-after-rebuild
Closed

reverseproxy: re-normalize WebSocket header casing after header op rebuild#7803
tejgokani wants to merge 1 commit into
caddyserver:masterfrom
tejgokani:fix/7784-ws-header-casing-after-rebuild

Conversation

@tejgokani

Copy link
Copy Markdown

Summary

Fixes #7784. WebSocket header casing (RFC 6455 Sec-WebSocket-*) was being silently reverted to Go's canonical form (Sec-Websocket-*) on the request path whenever any header_up was configured, or whenever the upstream was HTTPS (the HTTP transport auto-injects a Host op for HTTPS upstreams, which triggers the same rebuild block). Upstreams that compare these header names case-sensitively then rejected the WebSocket handshake. The response-side path in streaming.go was already correct; this brings the request-side path to parity.

Problem

prepareRequest normalizes WebSocket header casing at the end of header preparation. But proxyLoopIteration rebuilds r.Header on every iteration (so retries don't accumulate header ops) by calling copyHeader, which uses http.Header.Add — and Add runs textproto.CanonicalMIMEHeaderKey, turning Sec-WebSocket-Key back into Sec-Websocket-Key. Nothing re-normalized afterwards.

Repro from the issue:

GET / HTTP/1.1
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13

through reverse_proxy 127.0.0.1:9081 { header_up X-Repro-Trigger rebuild }.

Wire bytes the upstream sees, pre-fix:

Connection: Upgrade
Sec-Websocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-Websocket-Version: 13
Upgrade: websocket

Wire bytes the upstream sees, post-fix:

Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Upgrade: websocket

(Captured with a raw net.Listen upstream so the bytes aren't canonicalized by Go's net/http server on receive.)

Solution

Extracted the inline header-rebuild block from proxyLoopIteration into a small private rebuildRequestHeaders helper. The helper preserves the exact prior behavior (same early-out condition, same op-application order) and additionally calls normalizeWebsocketHeaders(r.Header) at the end, mirroring the response-side normalization at streaming.go:91. Extracting the helper was necessary to write a regression test that fails if the normalization is later removed.

Changes

  • modules/caddyhttp/reverseproxy/reverseproxy.go — extract rebuildRequestHeaders helper; add normalizeWebsocketHeaders call after both transport- and user-ops are applied.
  • modules/caddyhttp/reverseproxy/headers_test.go — add TestRebuildRequestHeadersPreservesWebsocketCasing (table-driven: user header_up only, HTTPS transport-injected Host op only, and both together) and TestRebuildRequestHeadersIsNoOpWithoutOps.

Testing

Quality gates run locally on macOS arm64 / Go 1.25.5:

go test -race -short ./modules/caddyhttp/reverseproxy/...   # ok 2.5s
go test -race -short ./modules/caddyhttp/headers/...        # ok 2.5s
go build ./...                                              # exit 0
gofmt -l <changed-files>                                    # clean
go vet ./modules/caddyhttp/reverseproxy/...                 # clean
golangci-lint run --timeout 5m ./modules/caddyhttp/reverseproxy/...  # 0 issues

I also verified the regression test does its job: with the new normalizeWebsocketHeaders call commented out, all three sub-tests of TestRebuildRequestHeadersPreservesWebsocketCasing fail with the exact Sec-Websocket-* leak shown in the issue; restoring the call makes them pass.

Manual end-to-end repro (raw net.Listen upstream + reverse_proxy with header_up) showed the wire-level header casing transition documented under Problem above.

Closes #7784


Assistance Disclosure

I authored and tested the change, and I am able to explain every line. I used Claude (Opus 4.7) for limited assistance: comparing two implementation shapes I had drafted (a one-line inline fix vs. extracting the rebuild block into a small helper) and selecting the shape that gives a clean regression-test seam, plus drafting the doc comment on rebuildRequestHeaders. The fix logic, test cases, and manual repro were verified by me end-to-end.

…build

prepareRequest normalizes WebSocket header casing per RFC 6455, but the
per-iteration header rebuild in proxyLoopIteration ran copyHeader (which
applies http.Header.Add and canonicalizes keys back to Go's textproto
form, e.g. Sec-WebSocket-Key -> Sec-Websocket-Key) and never re-normalized
afterwards. The rebuild runs whenever any request header op is configured
or whenever the HTTPS transport auto-injects its Host op, so the canonical
casing reached upstreams that compare header names case-sensitively and
rejected the WebSocket handshake. The response-side path in streaming.go
was already correct; this brings the request-side path to parity.

Extract the inline rebuild block into a small private rebuildRequestHeaders
helper (no behavior change beyond the new normalize call) so the bug-prone
code path has a clean unit-test seam. Add table-driven regression tests
covering user header_up only, the transport-injected Host op alone
(simulating an HTTPS upstream), and both together, plus a no-op test that
verifies the helper leaves r.Header untouched when no ops are configured.

Fixes caddyserver#7784
@CLAassistant

CLAassistant commented Jun 6, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@tejgokani

Copy link
Copy Markdown
Author

@CLAassistant signed

@tejgokani

Copy link
Copy Markdown
Author

Closing as a duplicate of #7786, which was opened five days before this one and which I missed during recon. Deferring to that PR. Apologies to @sapirbaruch for the duplicate work — left a comment on #7786 with two things from this PR that might be worth folding in (a regression test that exercises the rebuild path, plus a raw-TCP wire-level repro pattern) in case they save time.

@tejgokani tejgokani closed this Jun 6, 2026
@tejgokani
tejgokani deleted the fix/7784-ws-header-casing-after-rebuild branch June 6, 2026 15:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

WebSocket header casing normalization reverted when header ops are configured

2 participants