Issue Details
The RFC 6455 WebSocket header casing added by normalizeWebsocketHeaders (from #6621) gets reverted to Go's canonical casing before the request is sent upstream, whenever the reverse_proxy has request header operations. Upstreams that compare these header names case-sensitively reject the handshake (I know the consensus would be to fix the application, however this is not always possible as my use case is to proxy server's BMC interfaces which most are EOL and unsupported).
This happens when any header_up is configured, or the upstream is HTTPS (the transport injects a Host op automatically). So wss:// to an HTTPS backend is affected even with no explicit header config.
prepareRequest normalizes the casing correctly (Sec-WebSocket-Key). But proxyLoopIteration then rebuilds the header map with copyHeader (which uses Header.Add → CanonicalHeaderKey), turning it back into Sec-Websocket-Key, and nothing re-normalizes afterward:
Go's http.Transport writes map keys verbatim, so the canonical (non-RFC) casing reaches the wire.
The response upgrade path already gets this right, the request path is just missing the same step.
Suggested fix
Re-run normalizeWebsocketHeaders(r.Header) at the end of that rebuild block, mirroring the response path.
https://github.com/caddyserver/caddy/blob/master/modules/caddyhttp/reverseproxy/reverseproxy.go#L686-L700
if transportOps != nil || userOps != nil {
r.Header = make(http.Header)
copyHeader(r.Header, reqHeader)
r.Host = reqHost
if transportOps != nil {
transportOps.ApplyToRequest(r)
}
if userOps != nil {
userOps.ApplyToRequest(r)
}
normalizeWebsocketHeaders(r.Header)
}
Assistance Disclosure
AI used
If AI was used, describe the extent to which it was used.
Was used to debug my original issue with caddy and proxying BMC interfaces, and to draft this issue.
Issue Details
The RFC 6455 WebSocket header casing added by normalizeWebsocketHeaders (from #6621) gets reverted to Go's canonical casing before the request is sent upstream, whenever the reverse_proxy has request header operations. Upstreams that compare these header names case-sensitively reject the handshake (I know the consensus would be to fix the application, however this is not always possible as my use case is to proxy server's BMC interfaces which most are EOL and unsupported).
This happens when any header_up is configured, or the upstream is HTTPS (the transport injects a Host op automatically). So wss:// to an HTTPS backend is affected even with no explicit header config.
prepareRequest normalizes the casing correctly (Sec-WebSocket-Key). But proxyLoopIteration then rebuilds the header map with copyHeader (which uses Header.Add → CanonicalHeaderKey), turning it back into Sec-Websocket-Key, and nothing re-normalizes afterward:
Go's http.Transport writes map keys verbatim, so the canonical (non-RFC) casing reaches the wire.
The response upgrade path already gets this right, the request path is just missing the same step.
Suggested fix
Re-run normalizeWebsocketHeaders(r.Header) at the end of that rebuild block, mirroring the response path.
https://github.com/caddyserver/caddy/blob/master/modules/caddyhttp/reverseproxy/reverseproxy.go#L686-L700
Assistance Disclosure
AI used
If AI was used, describe the extent to which it was used.
Was used to debug my original issue with caddy and proxying BMC interfaces, and to draft this issue.