fix(websocket): mask outgoing CLOSE frames in client mode #12

Merged
Relism merged 1 commits from hotfix/ws-close-frame-masking into master 2026-08-14 23:02:13 +00:00
Owner

Summary

  • WebSocketSession.close(int) hand-wrote a raw, always-unmasked 4-byte CLOSE frame, bypassing writeFrame's maskOutgoing handling that sendText/send/sendPong already go through correctly.
  • A client-mode session (maskOutgoing = true — WS-client usage, e.g. Pathway's UpstreamWebSocketConnector relaying a proxied client's close to a backend) therefore sent an RFC-6455-invalid unmasked frame.
  • This was latent until this same HTTP/2 branch's readFrame rewrite added the receive-side masking check RFC 6455 §5.1 requires: a strict peer now rejects the malformed frame with WebSocketProtocolException("client frame must be masked") before ever exposing it as a CLOSE, silently dropping the close instead of relaying it.
  • Reproduced end to end via Pathway's ProxyWebSocketIntegrationTest.clientCloseIsForwardedToTheBackend, which was failing deterministically against master.

Fix

close(int) now builds its 2-byte payload and calls the same writeFrame path every other outgoing frame uses, so masking (or not) follows maskOutgoing automatically. Existing server-mode close_setsClosedAndWritesFrame is unchanged (byte-for-byte identical output).

Test plan

  • New unit test close_masksWhenActingAsClient (mirrors existing sendText coverage)
  • New round-trip regression readFrame_acceptsCloseFrameWrittenByAClientSession reproducing the actual bug
  • Full flash module suite: 697/697 green
  • Verified end-to-end against Pathway's ProxyWebSocketIntegrationTest (previously-failing test now passes)
## Summary - `WebSocketSession.close(int)` hand-wrote a raw, always-unmasked 4-byte CLOSE frame, bypassing `writeFrame`'s `maskOutgoing` handling that `sendText`/`send`/`sendPong` already go through correctly. - A client-mode session (`maskOutgoing = true` — WS-client usage, e.g. Pathway's `UpstreamWebSocketConnector` relaying a proxied client's close to a backend) therefore sent an RFC-6455-invalid unmasked frame. - This was latent until this same HTTP/2 branch's `readFrame` rewrite added the receive-side masking check RFC 6455 §5.1 requires: a strict peer now rejects the malformed frame with `WebSocketProtocolException("client frame must be masked")` before ever exposing it as a CLOSE, silently dropping the close instead of relaying it. - Reproduced end to end via Pathway's `ProxyWebSocketIntegrationTest.clientCloseIsForwardedToTheBackend`, which was failing deterministically against `master`. ## Fix `close(int)` now builds its 2-byte payload and calls the same `writeFrame` path every other outgoing frame uses, so masking (or not) follows `maskOutgoing` automatically. Existing server-mode `close_setsClosedAndWritesFrame` is unchanged (byte-for-byte identical output). ## Test plan - [x] New unit test `close_masksWhenActingAsClient` (mirrors existing `sendText` coverage) - [x] New round-trip regression `readFrame_acceptsCloseFrameWrittenByAClientSession` reproducing the actual bug - [x] Full `flash` module suite: 697/697 green - [x] Verified end-to-end against Pathway's `ProxyWebSocketIntegrationTest` (previously-failing test now passes)
Relism added 1 commit 2026-08-14 22:58:04 +00:00
WebSocketSession.close(int) hand-wrote a raw, always-unmasked 4-byte CLOSE
frame, bypassing writeFrame's maskOutgoing handling that sendText/send/
sendPong already go through correctly. A client-mode session (maskOutgoing
true — WS-client usage, e.g. Pathway's UpstreamWebSocketConnector relaying a
proxied client's close to a backend) therefore sent an RFC-6455-invalid
unmasked frame.

This was latent until this same HTTP/2 branch's readFrame rewrite added the
receive-side masking check RFC 6455 §5.1 requires: a strict peer now rejects
the malformed frame with WebSocketProtocolException("client frame must be
masked") before ever exposing it as a CLOSE, silently dropping the close
instead of relaying it — reproduced end to end via Pathway's
ProxyWebSocketIntegrationTest.clientCloseIsForwardedToTheBackend.

close(int) now builds its 2-byte payload and calls the same writeFrame path
every other outgoing frame uses, so masking (or not) follows maskOutgoing
automatically. Existing server-mode close_setsClosedAndWritesFrame is
unchanged (byte-for-byte identical output — no mask bit, no key). Added
close_masksWhenActingAsClient (mirrors the existing sendText coverage) and a
round-trip regression, readFrame_acceptsCloseFrameWrittenByAClientSession,
that reproduces the actual bug: a client session's close() output fed
straight into a server session's readFrame().

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011LLwcyHUnbApCrY33gvgoa
Relism merged commit 5954cac66c into master 2026-08-14 23:02:13 +00:00
Relism deleted branch hotfix/ws-close-frame-masking 2026-08-14 23:02:13 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: Relism/Flash5#12