Skip to content

WebSocket transport ignores WebSocketConfig headers, subprotocols, and user_agent #165

Closed

Description

@NuSkooler

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 insert inside with_header itself,
  • 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.headers into the builder;
  • set Sec-WebSocket-Protocol from self.config.subprotocols.join(", "), falling
    back to mqtt when empty;
  • set User-Agent when self.config.user_agent is Some.

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

  1. fabracht commented on Oct 2, 2026

    @fabracht
    Contributor

    Thanks for the detailed report! You're welcome to open the PR along the lines you described, and I'll review it.

  2. bryan-ashby commented on Oct 2, 2026

    @bryan-ashby
    Contributor

    Thanks! The transport fix is up as #189. While working on it I found two follow-ups:

    1. TLS settings on wss:// were ignored too. That's WSS transport ignores WebSocketConfig::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.
    2. MqttClient can't send custom handshake headers yet, and I'd like your steer before writing code. ClientInner::connect_websocket and connect_websocket_secure (client/inner.rs) build WebSocketConfig::new(url) from the URL alone, so MqttClient::connect("ws://…") and connect("wss://…") send no custom headers or subprotocols. Today, only callers who drive WebSocketTransport directly get send WebSocketConfig headers, subprotocols and user agent in the upgrade request (#165) #189's fix. (The client's stored TLS config over wss:// is a separate gap; use WebSocketConfig's TLS configuration for wss:// connections (#190) #191 fixes it.)

    Two shapes I can see:

    • A WebSocket config on the client, e.g. ConnectOptions::with_websocket_config(...), or a setter on MqttClient. The client would then apply it on each connect and reconnect. It's simple, but the headers are fixed when the client is configured.
    • A request modifier, like rumqttc's 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?

  3. fabracht commented on Oct 2, 2026

    @fabracht
    Contributor

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions