Issue Description
Stalwart’s /jmap/ws handler does case-sensitive matching on the Connection header value. A request carrying Connection: upgrade (lowercase u) is rejected with HTTP 400 “WebSocket upgrade failed”, while the byte-identical request with Connection: Upgrade (capital U) succeeds with 101 Switching Protocols. This violates RFC 7230 §6.1 (Connection tokens are case-insensitive) and RFC 6455 §4.2.1 (the WebSocket upgrade Connection field MUST be matched ASCII case-insensitively).
In production this manifests as “intermittent WebSocket connectivity” from iOS / macOS NSURLSession clients (e.g. Boogie Mail) when Stalwart sits behind nginx using the canonical nginx WebSocket proxy snippet — nginx’s $connection_upgrade map emits lowercase upgrade per the nginx docs (WebSocket proxying), and every /jmap/ws upgrade then fails. REST JMAP (/jmap/session, /jmap/) is unaffected because it doesn’t read the Connection token.
Expected Behavior
Per RFC 7230 §6.1 and RFC 6455 §4.2.1, Connection: upgrade and Connection: Upgrade MUST be treated identically (case-insensitive ASCII match on the token). The WebSocket upgrade should succeed in both cases and return 101 Switching Protocols with the Sec-WebSocket-Accept response.
Actual Behavior
Lowercase Connection: upgrade returns:
HTTP/1.1 400 Bad Request
content-type: application/problem+json
{“type”:“about:blank”,“status”:400,“title”:“Invalid parameters”,“detail”:“WebSocket upgrade failed”}
Capital Connection: Upgrade on the same Stalwart instance with otherwise byte-identical headers returns 101 Switching Protocols. The 400 fires after authentication (unauthenticated requests return 401 in both case variants), so the case-sensitive comparison is inside the WS upgrade validation path, not at a generic header parser.
Reproduction Steps
-
Stalwart 0.15.5 listening on http://127.0.0.1:8080, /jmap/ws enabled, admin credentials available.
-
Send a WS upgrade with capital
U:curl -sS -o /dev/null -w “%{http_code}\n” --http1.1
-u ‘admin:’
-H ‘Connection: Upgrade’ -H ‘Upgrade: websocket’
-H ‘Sec-WebSocket-Version: 13’
-H ‘Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==’
-H ‘Sec-WebSocket-Protocol: jmap’
http://127.0.0.1:8080/jmap/ws→ 101
-
Send the same request with lowercase
u:curl -sS -o /dev/null -w “%{http_code}\n” --http1.1
-u ‘admin:’
-H ‘Connection: upgrade’ -H ‘Upgrade: websocket’
-H ‘Sec-WebSocket-Version: 13’
-H ‘Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==’
-H ‘Sec-WebSocket-Protocol: jmap’
http://127.0.0.1:8080/jmap/ws→ 400
Requests differ only in the single-character case of
u/Uin the Connection header value. -
Stalwart logs the failing request as:
ERROR Bad resource parameters (resource.bad-parameters)
listenerId = “http”, localPort = 8080,
details = “WebSocket upgrade failed”,
reason = “Missing or Invalid Connection or Upgrade headers.”
Relevant Log Output
2026-05-15T07:13:59Z ERROR Bad resource parameters (resource.bad-parameters)
listenerId = “http”, localPort = 8080,
remoteIp = 172.18.0.1, remotePort = 49214,
details = “WebSocket upgrade failed”,
reason = “Missing or Invalid Connection or Upgrade headers.”
Forwarded request as seen on Stalwart’s upstream socket (captured via socat MITM
between nginx and Stalwart):
GET /jmap/ws HTTP/1.1
Host: mail2.hollis.digital
Upgrade: websocket
Connection: upgrade
Authorization: Basic
Sec-WebSocket-Version: 13
Sec-WebSocket-Key:
Sec-WebSocket-Protocol: jmap
Sec-WebSocket-Extensions: permessage-deflate
Stalwart response:
HTTP/1.1 400 Bad Request
content-type: application/problem+json
content-length: 100
{“type”:“about:blank”,“status”:400,“title”:“Invalid parameters”,“detail”:“WebSocket upgrade failed”}
Failing user agents in real production traffic:
- Boogie/90 CFNetwork/3860.100.1 Darwin/25.5.0
- Boogie/64 CFNetwork/3860.400.51 Darwin/25.3.0
(both iOS/macOS NSURLSession via standard nginx reverse proxy)
Stalwart Version
v0.15.x
Installation Method
Docker
Database Backend
PostgreSQL
Blob Storage
PostgreSQL
Search Engine
Internal
Directory Backend
Internal
Additional Context
Reverse proxy: nginx 1.30.0 in front of Stalwart, terminating TLS, forwarding to http://127.0.0.1:8080.
Standard config: proxy_http_version 1.1, proxy_set_header Connection $connection_upgrade where
$connection_upgrade is the canonical map from the nginx docs:
map $http_upgrade $connection_upgrade {
default upgrade;
‘’ close;
}
That map emits lowercase upgrade — which is what triggers this bug.
Workaround we deployed (confirmed fix, but fragile because it disagrees with upstream nginx’s own snippet):
map $http_upgrade $connection_upgrade {
default Upgrade;
‘’ ‘’;
}
Failing client stacks: iOS / macOS NSURLSession (CFNetwork) WebSocket transport.
Symptom on the client side: REST JMAP works (session fetch, methodCalls), but every WebSocket push / change-notification connection drops on upgrade — looks like “intermittent connectivity” to end users.
Originally tracked internally as:
- 2026-05-04: added proxy_http_version 1.1 + long timeouts + buffering off; curl test passed
(because curl preserved capitalUin the header as given), so it looked fixed. - 2026-05-15: discovered live iOS clients still failing; isolated the case-sensitivity bug via
socat MITM between nginx and Stalwart and bisection on header case.
Asks:
- Confirm this is a regression / oversight, not intentional.
- Suggested fix: case-insensitive token comparison on Connection (and likely Upgrade, by the same RFC reasoning — I did not test that one in isolation).
- Happy to retest against v0.16.5 once a fix lands.
I have reviewed the documentation and FAQ and confirm that my issue is NOT addressed there.
on
I have searched this support forum (open and closed topics) and confirm this is not a duplicate.
on
I understand that topics in this category are triaged by a bot first but a human reply will follow up. If I’d prefer a human-only reply, I’ll add the no-ai tag to my topic.
on