Skip to content

reverseproxy: re-apply WebSocket header normalization after header ops#7786

Open
sapirbaruch wants to merge 3 commits into
caddyserver:masterfrom
sapirbaruch:fix-websocket-header-normalization
Open

reverseproxy: re-apply WebSocket header normalization after header ops#7786
sapirbaruch wants to merge 3 commits into
caddyserver:masterfrom
sapirbaruch:fix-websocket-header-normalization

Conversation

@sapirbaruch
Copy link
Copy Markdown

@sapirbaruch sapirbaruch commented Jun 1, 2026

Fixes #7784.

When transport or headers ops are configured on a reverse proxy handler, proxyLoopIteration rebuilds r.Header from scratch using copyHeader. Because copyHeader uses http.Header.Add internally, Go's CanonicalHeaderKey canonicalizes the header names — this lowercases the second word in Sec-WebSocket-*, so Sec-WebSocket-Key becomes Sec-Websocket-Key.

normalizeWebsocketHeaders is already called in prepareRequest to fix this exact problem, but the subsequent header rebuild in the retry loop undoes it. Adding the same call after the rebuild keeps the headers in RFC 6455-compliant form (Sec-WebSocket-* with uppercase S) regardless of whether header ops are configured.

The call is cheap and safe for non-WebSocket requests: normalizeWebsocketHeaders iterates a five-element map and only mutates headers that actually exist, so it's effectively a no-op when there are no Sec-WebSocket-* headers present.


AI assistance disclosure: This PR was developed with the assistance of an AI tool (Claude), per the project's contribution guidelines.

When transport or user header ops are configured, proxyLoopIteration
rebuilds r.Header from scratch using copyHeader. copyHeader calls
http.Header.Add internally which canonicalizes header names via
CanonicalHeaderKey — this lowercases the 'S' in WebSocket, turning
"Sec-WebSocket-Key" into "Sec-Websocket-Key".

normalizeWebsocketHeaders was already called in prepareRequest to fix
this, but the subsequent header rebuild in proxyLoopIteration undoes
it. Calling normalizeWebsocketHeaders again after the rebuild restores
the RFC 6455-compliant casing for all Sec-WebSocket-* headers.

Fixes caddyserver#7784
@CLAassistant
Copy link
Copy Markdown

CLAassistant commented Jun 1, 2026

CLA assistant check
All committers have signed the CLA.

@mholt
Copy link
Copy Markdown
Member

mholt commented Jun 2, 2026

Can you the human please sign the CLA please @sapirbaruch ?

@mholt
Copy link
Copy Markdown
Member

mholt commented Jun 2, 2026

Also would your LLM be able to add a test?

@francislavoie francislavoie added the bug 🐞 Something isn't working label Jun 2, 2026
@francislavoie francislavoie added this to the v2.11.4 milestone Jun 2, 2026
Add TestNormalizeWebsocketHeaders covering the four cases:
- canonical (lowercased) header names are renamed to RFC 6455 form
- headers already in the correct form are left unchanged
- non-WebSocket headers are untouched
- empty header map is a no-op

Add TestNormalizeWebsocketHeadersSurvivesCopyHeader as a targeted
regression test for caddyserver#7784: simulates the header-rebuild that
proxyLoopIteration performs when transport or header ops are
configured, verifies that calling normalizeWebsocketHeaders afterwards
restores Sec-WebSocket-* to the RFC 6455 casing.
@mholt
Copy link
Copy Markdown
Member

mholt commented Jun 2, 2026

My last question before we merge it, that I just considered, is should we have Go fix CanonicalHeaderKey instead of us hacking around it? If the canonical form is WebSocket maybe it shouldn't be changing it to Websocket.

@sapirbaruch
Copy link
Copy Markdown
Author

The Go team explicitly closed this as won't-fix back in 2016 (golang/go#18495). Brad Fitzpatrick's response:

RFC 6455 can't mandate the case of headers, since HTTP/1 says that they're case insensitive. And in HTTP/2 there is no case on the wire. I don't want to complicate Go and encourage buggy libraries from assuming case.

So CanonicalHeaderKey will never special-case WebSocket headers — the normalization has to live in Caddy. The normalizeWebsocketHeaders helper (introduced in #6621) is the right layer for this, and the fix just makes sure it's called after the header rebuild in the retry loop, not only before it.

http.Header.Get re-canonicalizes its argument via CanonicalHeaderKey,
so rebuilt.Get("Sec-WebSocket-Key") looks up the Go-canonical form
"Sec-Websocket-Key" — the key normalizeWebsocketHeaders just deleted.
Use a direct map lookup instead to assert the RFC 6455 key is present.
if userOps != nil {
userOps.ApplyToRequest(r)
}
normalizeWebsocketHeaders(r.Header)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A comment about why this is needed should be added. Otherwise it's not intuitive why websocket headers need to be normalized for non websockets requests.

@WeidiDeng
Copy link
Copy Markdown
Member

AI disclosure is required.

@mholt mholt modified the milestones: v2.11.4, v2.11.5 Jun 3, 2026
@mholt
Copy link
Copy Markdown
Member

mholt commented Jun 3, 2026

Thanks for the reminder @WeidiDeng . Yes, the PR is missing our standard assistance disclosure. It's required even if not using AI, but I can tell this one is, and I think in my head since I could tell I forgot to check for the disclosure.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug 🐞 Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

WebSocket header casing normalization reverted when header ops are configured

5 participants