Skip to content

Fix 1909 - #1916

Merged
oberstet merged 6 commits into
crossbario:masterfrom
oberstet:fix_1909
Jul 14, 2026
Merged

Fix 1909#1916
oberstet merged 6 commits into
crossbario:masterfrom
oberstet:fix_1909

Conversation

@oberstet

Copy link
Copy Markdown
Contributor

fixes #1909 - this will be two-phase, proper "red green" TDD

oberstet added 2 commits July 14, 2026 13:58
…crossbario#1909)

A permessage-compressed WebSocket message is bounded by maxMessagePayloadSize
against its COMPRESSED wire size (the check in onMessageFrameBegin runs on the
declared frame length, before inflation), so a small compressed frame that
inflates far beyond the configured limit is delivered to the application
(security advisory GHSA-hxp9-w8x3-p566).

Add a backend-neutral protocol-level test (drives _dataReceived with a crafted
masked, compressed frame) asserting the message is rejected before delivery.
The module is deliberately NOT gated on USE_TWISTED, so it runs under both the
Twisted (trial) and asyncio (pytest) coverage phases, and it is parametrized
over every available permessage-compress backend (deflate/bzip2/snappy/brotli;
skipped when the optional dependency is absent) and both processing modes
(whole-message and streaming). A control confirms under-limit messages are still
delivered intact. The rejection cases fail on current code, proving the bypass.

Note: This work was completed with AI assistance (Claude Code).
@oberstet

Copy link
Copy Markdown
Contributor Author

reviewed (by me), tested locally, and now TDD phase 1 is running, let's see it fail (what we do want):

image

…bario#1909)

The previous commit obtained the compressor class via the permessage-compress
extension registry, whose value the ty static-typing gate infers as a union of
all codec classes - so the codec-specific constructor calls tripped
[missing-argument] and failed `just check-typing`, which aborted the job before
the unit tests could run at all.

Use concrete per-codec imports (each gated by ImportError for the optional
snappy/brotli backends) so every constructor call site resolves to a single
signature. No behavioural change: the enforcement tests still fail red on
current code, now for the right reason (the missing enforcement), not the type
checker. Verified locally: `ruff check`, the ANN/UP/TCH ruff pass, and
`ty check` are all clean; the two enforcement tests fail under both the Twisted
and asyncio phases.

Note: This work was completed with AI assistance (Claude Code).
@oberstet

oberstet commented Jul 14, 2026

Copy link
Copy Markdown
Contributor Author

there was an issue with phase 1 - it did fail, but for the wrong reason;) after pointing that out, an ack, and another iteration:

image

and after that 2nd commit, it now works - as in, it fails, but for the right reason:

image

however, there is still a thing missing / to fix for a perfect TDD red-green cycle:

image

so that's yet one more commit and run for the right red! and then we can move on for the right green (the actual fix). I want to do this TDD properly;)

oberstet added 2 commits July 14, 2026 16:43
…o#1909)

maxMessagePayloadSize was enforced only in onMessageFrameBegin(), against the
declared (compressed) frame length, before permessage-compress inflation. A
small compressed frame could therefore inflate far beyond the configured limit
and be delivered to the application - the compressed-size check standing in for
a decompressed-size bound (security advisory GHSA-hxp9-w8x3-p566; same class as
CVE-2016-10544).

Enforce the limit at the inflation site in onFrameData(): for a compressed
message, add the inflation delta (uncompressedLen - compressedLen) to
message_data_total_length - which onMessageFrameBegin() had seeded with the
compressed length - so the running total reflects the UNCOMPRESSED reassembled
message, and re-check against maxMessagePayloadSize. On exceed, fail the
connection with MESSAGE_TOO_BIG before delivery. This is backend-agnostic and
runs before frame data is dispatched, so it protects both whole-message and
streaming consumers and every permessage-compress backend. Uncompressed
messages are unaffected (delta is zero; the frame-begin check already bounds
them), as is the maxFramePayloadSize per-frame wire guard.

Makes the tests added in the previous commit pass across deflate/bzip2/brotli
(snappy when installed), both backends, and both processing modes.

Also cancel the opening-handshake timeout in the test harness after
_connectionMade(): the tests drive protocol bytes directly and never run a
real close handshake, so the scheduled DelayedCall would otherwise leak and
Twisted's trial would report a dirty reactor once the enforcement fix makes
the connection drop cleanly.

Note: This work was completed with AI assistance (Claude Code).
The maxMessagePayloadSize test harness cancelled only the opening-handshake
timeout that _connectionMade() schedules. That is sufficient today - the
enforcement path fails the connection via dropConnection() (failByDrop
defaults True), which never runs the closing handshake, so no close-handshake
timer is scheduled - but it silently depends on failByDrop. Cancel both
handshake timers defensively (mirroring _connectionLost), so a future variant
that drives a real close cannot leak a DelayedCall and trip Twisted trial's
dirty-reactor check. No behavior change: closeHandshakeTimeoutCall is None in
all current test paths, so the added guard is a no-op there.

Note: This work was completed with AI assistance (Claude Code).
@oberstet

Copy link
Copy Markdown
Contributor Author

very good! test results are now exactly as intended:

https://github.com/crossbario/autobahn-python/actions/runs/29343192492/job/87121506560?pr=1916

here is in-depth results archive review:

image

…rossbario#1908)

Write down the wire-vs-application distinction that the maxMessagePayloadSize
security fix establishes, so the semantics that caused the advisory are no
longer ambiguous:

- interfaces.py: clarify the setProtocolOptions() docstrings (server and
  client) so maxFramePayloadSize is the on-the-wire (compressed) per-frame
  size and maxMessagePayloadSize is the reassembled AND decompressed
  (uncompressed, application-level) message size, enforced against the inflated
  size, not the compressed wire size.
- programming.rst: same clarification for the two options in the WebSocket
  programming guide.
- changelog.rst: add a Security section for 26.7.1 with the crossbario#1909 fix (advisory
  GHSA-hxp9-w8x3-p566, including the behaviour change) and the previously
  undocumented crossbario#1908 deflate unconsumed_tail cap fix.

Docs only; no code or behaviour change.

Note: This work was completed with AI assistance (Claude Code).
@oberstet
oberstet merged commit 77d323a into crossbario:master Jul 14, 2026
34 checks passed
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.

[BUG] WebSocket maxMessagePayloadSize is enforced against compressed size, bypassed after permessage-deflate inflation

1 participant