cloudflare / cloudflare/cloudflared
WebSocket `Upgrade: websocket` header dropped at origin when cloudflared is on default `--protocol=auto` (QUIC); `--protocol=http2` is a reliable workaround
- Dominant language
- Go
- Stars
- 15.6k
- Forks
- 1.4k
- PR merge metrics
- No merged PRs in 30d
Description
## Summary
When `cloudflared` is left on its default `--protocol=auto` (which prefers QUIC), incoming WebSocket upgrades arrive at the origin **without** their `Upgrade: websocket` header. The origin sees a plain `GET` and responds 400. Browsers surface this as `code: 1006, wasClean: false`, no `open` event ever fires. Setting `--protocol=http2` reliably restores the upgrade end-to-end. HTTP (non-upgrade) traffic through the same tunnel is unaffected.
## Environment
- `cloudflared` running inside the official Home Assistant add-on [`homeassistant-apps/app-cloudflared`](https://github.com/homeassistant-apps/app-cloudflared), `no-autoupdate: true` (so the binary is whatever shipped with that add-on version).
- The cloudflared connection advertises these features in `GET /accounts/{a}/cfd_tunnel/{t}/connections`:
`allow_remote_config`, `serialized_headers`, `support_datagram_v2`, `support_quic_eof`, `management_logs`.
- Origin is Home Assistant (`aiohttp` `WebSocketResponse`) at `http://homeassistant:8123`.
- Cloudflare Access in front of the hostname; same Access policy across the broken WS path and the working HTTP paths.
## Symptom
Browser opens `wss://example.com/api/websocket`. Outcome:
- WebSocket closes immediately with `code: 1006`, `wasClean: false`, ~260 ms, no `open` event.
- Chrome DevTools logs the request as a plain `GET /api/websocket` returning `HTTP 400`, `Content-Type: text/plain`, body:
```
No WebSocket UPGRADE hdr: None
Can "Upgrade" only to "WebSocket".
```
That body is generated by `aiohttp.WebSocketResponse.prepare()` exactly when the `Upgrade` request header is missing or not `websocket`. So by the time the request reaches the origin via cloudflared, the `Upgrade: websocket` header is gone.
All HTTP traffic through the same hostname works normally - `/`, `/auth/providers`, `/api/`, the recovery-script's cache-busting `/?_cb=...` probe - Cloudflare Access cookie auth validates and the origin returns the expected statuses. So the failure is isolated to the WebSocket upgrade path.
## Workaround
Setting `--protocol=http2` on cloudflared (in the HA add-on: `run_parameters: ["--protocol=http2"]`) restores the upgrade. With HTTP/2 transport to the edge, the `Upgrade` header survives end-to-end and the origin issues the expected `101 Switching Protocols`. Verified live; the symptom resolved within the time it took the add-on to restart.
Zone-level HTTP/3 (browser <-> edge) stays on and is unaffected by this change - only the cloudflared <-> edge link drops from QUIC to HTTP/2.
## Why this matters
HA + Cloudflare Tunnel + Cloudflare Access is one of the most commonly recommended self-hosted configurations. When this fires, the failure is silent on the user side - the Service-Worker-cached frontend keeps loading, automations keep running on the server, but every dashboard ends up displaying "Unable to connect to Home Assistant" forever, and users have no obvious path from that symptom to "drop QUIC".
## Ask
Either fix the QUIC <-> HTTP/1.1 translation so `Upgrade: websocket` propagates correctly, or document explicitly that `--protocol=http2` is required for WebSocket origins. The README for the HA cloudflared add-on does not currently mention this.
## Update — 2026-05-29: symptom recurred despite `--protocol=http2`
The workaround documented above is unreliable. Today (2026-05-29 UTC) the exact same symptom returned without any user-side change:
- cloudflared add-on confirmed running `--protocol=http2`. Add-on log line at startup: `INF Settings: map[... p:http2 protocol:http2]` followed by `INF Initial protocol http2`, and every `Registered tunnel connection` line shows `protocol=http2` (4 of them, to fra03/hel01/hel02). No `quic` anywhere in the cloudflared log.
- Browser-side symptom identical to the original: `wss://.../api/websocket` closes with `code: 1006, wasClean: false`, ~278 ms, no `open` event. Plain GET to `/api/websocket` returns the same HA aiohttp 400 with body `No WebSocket UPGRADE hdr: None`.
- Browser cf-ray was on the `LAX` edge POP both during the broken state and after self-recovery, so the variable isn't the geographic edge.
- No user action between broken → working. The symptom self-resolved within hours.
This means **`--protocol=http2` is not a reliable workaround** — the bug surfaces on HTTP/2 transport too. The QUIC path may amplify it, but the failure isn't purely in the QUIC ↔ HTTP/1.1 translation. Something upstream of (or at) cloudflared on the HTTP/2 path is also affected.
### Correlation worth flagging
Both occurrences I've seen sit immediately after a Cloudflare managed DDoS L7 ruleset push:
- First occurrence (~May 22-23): DDoS L7 ruleset pushed from `3299` → `3300` on **2026-05-22T11:49:51Z**.
- This occurrence (2026-05-29): DDoS L7 ruleset pushed from `3300` → `3301` on **2026-05-28T18:35:29Z** (~10 hours before the symptom became visible).
Two correlated events isn't proof, but the timing has the shape of a managed-ruleset regression that gets rolled back — both times the symptom resolved without user action, and nothing changed on my side in between. If anyone on the cloudflared side can correlate with internal DDoS L7 rule changes around those timestamps, that would close the loop.
### Updated ask
- Investigate whether the HTTP/2 transport path can also lose the `Upgrade: websocket` header (transiently or otherwise), not just QUIC.
- Coordinate with whoever owns DDoS L7 managed rules on whether the May 22 and May 28 pushes touched anything that could affect WS upgrade routing through tunneled origins.
## Related reading
A write-up of the symptom and the same fix, plus a browser-side detection probe for the upgrade-stripped signature, lives at: https://github.com/mayerwin/HA-Cloudflare-Access-Recovery#second-failure-mode-websocket-upgrade-stripping
Contributor guide
Assessment
This issue has not been assessed yet.