Skip to content

fix(engineioxide): enforce max_payload on the websocket transport - #762

Closed
schulzfel wants to merge 1 commit into
Totodore:mainfrom
schulzfel:fix/ws-max-payload
Closed

fix(engineioxide): enforce max_payload on the websocket transport#762
schulzfel wants to merge 1 commit into
Totodore:mainfrom
schulzfel:fix/ws-max-payload

Conversation

@schulzfel

Copy link
Copy Markdown

Problem

EngineIoConfig.max_payload is only enforced for the polling transport (HTTP request bodies). Websocket connections are initialized with tungstenite's default WebSocketConfig, which allows ~64 MiB messages / 16 MiB frames — so the configured payload ceiling is silently unenforced for websocket clients. A server operator who sets max_payload to bound inbound payloads is only actually protected on polling.

Fix

Apply the same ceiling to inbound websocket traffic in ws::on_init:

let ws_config = WebSocketConfig::default()
    .read_buffer_size(engine.config.ws_read_buffer_size)
    .max_message_size(Some(engine.config.max_payload as usize))
    .max_frame_size(Some(engine.config.max_payload as usize));

max_frame_size bounds each frame at parse time and max_message_size bounds reassembled fragmented messages, giving both transports identical enforcement. An oversized message now closes the connection with a capacity error (surfaced as DisconnectReason::TransportError) instead of being delivered to the handler.

The max_payload doc comment is updated to mention websocket.

Tests

New crates/engineioxide/tests/ws_max_payload.rs (runs with --features __test_harness like the other integration tests):

  • ws_message_within_max_payload_is_delivered — messages under the limit still reach the handler.
  • ws_message_exceeding_max_payload_closes_the_connection — an oversized message disconnects with TransportError and never reaches the handler.

The second test fails without the ws.rs change and passes with it.

Notes

Downstream we currently carry this as a vendored two-line patch on 0.17.1 to safely enable the websocket transport behind a strict payload budget; happy to adjust anything (naming, error surface, backport) to get payload parity landed upstream.

max_payload was only enforced for the polling transport (http request
bodies). Websocket connections rode tungstenite's defaults (~64 MiB
message / 16 MiB frame), so the configured ceiling was silently
unenforced for websocket clients.

Map the same ceiling onto inbound websocket frames (max_frame_size)
and reassembled messages (max_message_size), giving both transports
identical payload enforcement. An oversized message now closes the
connection with a transport error instead of being delivered.
@codspeed-hq

codspeed-hq Bot commented Jul 17, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 87 untouched benchmarks


Comparing schulzfel:fix/ws-max-payload (979b193) with main (46df8dd)

Open in CodSpeed

@Totodore Totodore left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the PR,
However I think we should provide two different options for those two params (frame_size and message_size). These don't have exactly the same implication than max_payload setting.

Also could you propagate this to the socketioxide builder (see https://docs.rs/socketioxide/latest/socketioxide/struct.SocketIoBuilder.html#method.ws_read_buffer_size).

Comment on lines +120 to +123
// Apply the configured `max_payload` ceiling to inbound websocket
// frames/messages, matching the polling transport. Without this,
// tungstenite's defaults (~64 MiB message / 16 MiB frame) apply and
// `max_payload` is silently unenforced for websocket clients.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// Apply the configured `max_payload` ceiling to inbound websocket
// frames/messages, matching the polling transport. Without this,
// tungstenite's defaults (~64 MiB message / 16 MiB frame) apply and
// `max_payload` is silently unenforced for websocket clients.

Useless AI-comments

@greluc

greluc commented Aug 24, 2026

Copy link
Copy Markdown

Independent confirmation from a second downstream, and an offer to finish the requested changes.

We hit this while porting the signalling server of AnotherCrewLink (proximity voice chat for Among Us) to socketioxide 0.18.6 / engineioxide 0.17.6. We reached the same three lines independently before finding this PR, which is why I am commenting rather than opening a duplicate issue.

One detail worth adding to the case: the gap is widest exactly in the configuration this crate encourages. Our server is transports([TransportType::Websocket]) — both of our shipping clients connect that way, and refusing polling removes an advisory surface — so max_payload ends up enforced on a transport we do not mount at all. We set 64 KiB; the effective inbound ceiling is tungstenite's 64 MiB. A factor of a thousand, and the configured number survives only as an advertisement in the OPEN packet that a hostile client ignores.

Two things that make it unfixable outside the crate, in case they help justify the change:

  • An application-level size check on the decoded event, which we have, refuses the payload only after tungstenite has assembled the message. It reports the abuse; it does not prevent the allocation it exists to prevent.
  • It cannot be pushed to the reverse proxy. client_max_body_size and its equivalents stop applying at the Upgrade, and neither nginx nor Caddy has a directive bounding a frame post-upgrade.

That leaves a process memory limit as the only real backstop, which turns a hostile message into a restart instead of a refusal. We currently carry it as an accepted risk with MemoryMax=512M in our systemd unit, and documented for operators, because there is no configuration line that closes it.

On the review feedback: separate options for frame size and message size is the right call — reusing max_payload conflates a per-frame parse bound with a reassembled-message bound, and only the second is what an operator setting a payload budget usually means. If it would help, I am happy to contribute that shape — ws_max_frame_size and ws_max_message_size on EngineIoConfig, each defaulting to max_payload so existing behaviour is unchanged for anyone who sets neither, plus the matching pair on SocketIoBuilder next to ws_read_buffer_size — either as a patch to @schulzfel's branch if they prefer, or as a follow-up PR crediting this one. Just say which you would rather have; I did not want to fork the work without asking.


Disclosure: this comment was written by Claude, an AI assistant, working on the downstream project named above, and posted from my account. Every version number, file path and line number in it was checked against the vendored crate sources rather than recalled. — @greluc

@Totodore

Copy link
Copy Markdown
Owner

@greluc I'll publish a PR with the new options during the week

@greluc

greluc commented Aug 24, 2026

Copy link
Copy Markdown

Ok, thank you very much! :-)

@Totodore

Copy link
Copy Markdown
Owner

Supersed by #775

@Totodore Totodore closed this Aug 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants