Repository navigation
WebSocket transport ignores WebSocketConfig headers, subprotocols, and user_agent #165
Description
Summary
WebSocketConfig exposes headers, subprotocols, and user_agent, with builder
methods for each, but the WebSocket handshake request is constructed from a fixed
set of six headers and never reads any of the three fields. Anything set via
with_header, with_subprotocol/with_subprotocols, or with_user_agent is
silently discarded — the configuration appears to be applied and has no effect on
the wire.
Verified against the published mqtt5 0.40.0 crate source.
Where
src/transport/websocket.rs, in impl Transport for WebSocketTransport::connect:
let request = Request::builder()
.uri(self.config.url.as_str())
.header("Host", self.config.url.host_str().unwrap_or("localhost"))
.header("Connection", "Upgrade")
.header("Upgrade", "websocket")
.header("Sec-WebSocket-Version", "13")
.header("Sec-WebSocket-Key", tungstenite::handshake::client::generate_key())
.header("Sec-WebSocket-Protocol", "mqtt") // hardcoded; ignores config.subprotocols
.body(())This is the only place a handshake request is built. Across the whole crate, the
only reads of config.headers are:
- the
insertinsidewith_headeritself, - a read of the response headers after the upgrade,
- a unit test asserting the config map contains what was just put into it.
user_agent is read only by its own setter and the same style of config-only test.
subprotocols is read only by subprotocol(), which returns
self.config.subprotocols.first() — the locally configured value, not the
negotiated one (its own comment notes "in a real implementation, this would return
the negotiated subprotocol").
Why it survived
The existing tests assert on the config struct rather than on the produced request:
let config = WebSocketConfig::new("wss://example.com/mqtt")?
.with_subprotocol("mqttv5.0")
.with_header("Authorization", "Bearer token123")
.with_user_agent("custom-client/1.0");
assert_eq!(config.headers.get("Authorization"), ...);
assert_eq!(config.user_agent, Some("custom-client/1.0".to_string()));These pass whether or not the fields are ever sent, because request construction is
inlined in async fn connect and can only be exercised against a live server.
Corroborating detail: the default user_agent is "mqtt-v5/0.4.0", a version
string from 36 releases ago, which suggests it has never been observed on the wire.
Impact
The documented use case is the one that breaks. The module docs advertise "Custom
headers support" and show .with_header("Authorization", "Bearer token123"), but
bearer-token and broker-specific handshake authentication over WebSocket cannot
work. Concretely, managed-cloud brokers that authenticate the upgrade request via
custom headers — e.g. AWS IoT Core custom authorizers, which are WebSocket-only and
require x-amz-customauthorizer-name, x-amz-customauthorizer-signature, and a
token header — will reject the connection, with no indication that the configured
headers were dropped. This sits awkwardly against the README's stated cloud-broker
compatibility.
Hardcoding Sec-WebSocket-Protocol: mqtt also makes with_subprotocol("mqttv5.0")
and with_subprotocols(&[...]) inert, so a broker requiring a specific subprotocol
token cannot be reached.
For comparison, rumqttc handles this with a caller-supplied request modifier
(MqttOptions::set_request_modifier) applied to the upgrade request, which also
lets the headers be recomputed per reconnect attempt — useful when the credential
is a short-lived signed token.
Reproduction
Static: grep -rn '\.headers' src/transport/websocket.rs shows no read of
config.headers in the request path.
Runtime:
let config = WebSocketConfig::new("ws://127.0.0.1:8080/mqtt")?
.with_header("X-Custom", "expected")
.with_subprotocol("mqttv5.0")
.with_user_agent("repro/1.0");
let mut transport = WebSocketTransport::new(config);
transport.connect().await?;Point it at any WebSocket server that logs the upgrade request. Expected:
X-Custom: expected, Sec-WebSocket-Protocol: mqttv5.0, User-Agent: repro/1.0.
Actual: none present; Sec-WebSocket-Protocol: mqtt.
Suggested fix
Apply the three fields when building the request:
- iterate
self.config.headersinto the builder; - set
Sec-WebSocket-Protocolfromself.config.subprotocols.join(", "), falling
back tomqttwhen empty; - set
User-Agentwhenself.config.user_agentisSome.
Two details worth deciding explicitly: whether user-supplied headers may override
the protocol-required ones (Upgrade, Connection, Sec-WebSocket-Key,
Sec-WebSocket-Version) — rejecting or ignoring those is probably safer than
letting them through — and whether header names are validated up front, since
HashMap<String, String> admits values that HeaderValue::from_str will reject at
connect time.
To make the behavior testable without a live server, extracting request
construction into a pure function would let the existing tests assert on the
request rather than the config:
fn build_handshake_request(config: &WebSocketConfig) -> Result<Request<()>>Happy to open a PR along these lines if the approach looks right.
Activity
fabracht commentedon Oct 2, 2026
Thanks for the detailed report! You're welcome to open the PR along the lines you described, and I'll review it.
reserve body headers, order custom headers, and note behavior changes (…
bryan-ashby commentedon Oct 2, 2026
Thanks! The transport fix is up as #189. While working on it I found two follow-ups:
wss://were ignored too. That's WSS transport ignoresWebSocketConfig::tls_config(custom CA, client certificate, ALPN) #190, with a fix in use WebSocketConfig's TLS configuration for wss:// connections (#190) #191, which builds on send WebSocketConfig headers, subprotocols and user agent in the upgrade request (#165) #189.MqttClientcan't send custom handshake headers yet, and I'd like your steer before writing code.ClientInner::connect_websocketandconnect_websocket_secure(client/inner.rs) buildWebSocketConfig::new(url)from the URL alone, soMqttClient::connect("ws://…")andconnect("wss://…")send no custom headers or subprotocols. Today, only callers who driveWebSocketTransportdirectly get send WebSocketConfig headers, subprotocols and user agent in the upgrade request (#165) #189's fix. (The client's stored TLS config overwss://is a separate gap; use WebSocketConfig's TLS configuration for wss:// connections (#190) #191 fixes it.)Two shapes I can see:
ConnectOptions::with_websocket_config(...), or a setter onMqttClient. The client would then apply it on each connect and reconnect. It's simple, but the headers are fixed when the client is configured.set_request_modifier: an async callback that receives the handshake request before every connection attempt. It's more flexible: a short-lived signed token (e.g. an AWS IoT custom-authorizer signature, or a presigned URL) can be recomputed on every reconnect instead of going stale.They aren't exclusive. A static config could cover the common case, with the modifier as the escape hatch. Do you have a preference, or a different shape in mind?
fabracht commentedon Oct 2, 2026
I'd do both. First a static config, something like
ConnectOptions::with_websocket_config(WebSocketConfig), applied on every connect and reconnect. Then the request modifier for the signed-token case. Separate PRs are fine.