WebSocket upgrade rejects Connection: upgrade (lowercase) — case-sensitive token matching violates RFC 7230/6455

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

  1. Stalwart 0.15.5 listening on http://127.0.0.1:8080, /jmap/ws enabled, admin credentials available.

  2. 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

  3. 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/U in the Connection header value.

  4. 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 capital U in 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:

  1. Confirm this is a regression / oversight, not intentional.
  2. Suggested fix: case-insensitive token comparison on Connection (and likely Upgrade, by the same RFC reasoning — I did not test that one in isolation).
  3. 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

Let me look at the WebSocket upgrade handling code to verify this claim.

The bug is confirmed in the source code. The issue is on lines 34-40 of crates/jmap/src/websocket/upgrade.rs:

if headers
    .get(hyper::header::CONNECTION)
    .and_then(|h| h.to_str().ok())
    != Some("Upgrade")      // <-- exact byte match, case-sensitive
    || headers
        .get(hyper::header::UPGRADE)
        .and_then(|h| h.to_str().ok())
        != Some("websocket")

The Connection header value is compared with != Some("Upgrade") – a plain byte-equality check, not a case-insensitive ASCII comparison. So Connection: upgrade (lowercase, as emitted by nginx’s canonical $connection_upgrade map) fails the check and returns 400, while Connection: Upgrade passes. This is exactly the bug you described.

Per RFC 7230 §3.2 and §6.1, HTTP header values that are tokens (like upgrade) must be compared case-insensitively. The fix is straightforward: replace the equality check with a case-insensitive comparison, e.g.:

.map(|v| v.eq_ignore_ascii_case("upgrade"))
!= Some(true)

The same applies to the Upgrade: websocket check for completeness, though websocket is already lowercase so it only matters if a client sends Upgrade: WebSocket.

Your nginx workaround (default Upgrade; with a capital U) is a valid short-term fix. The root cause is the case-sensitive comparison in Stalwart’s WebSocket upgrade handler, not nginx’s behavior (nginx’s lowercase upgrade is correct per its docs).

This isn’t a configuration issue – it requires a code change in the server. A maintainer or community member will follow up on the fix.

This is an automated reply from the Stalwart Help Bot. Other community members may follow up if this answer is incomplete or wrong. If you found it useful, mention @helpbot in any reply to ask follow-up questions and the bot will respond again.

A fix will be included in v0.16.6.