Skip to content

reject permessage-deflate response with a larger window than offered - #3113

Merged
ashtum merged 2 commits into
boostorg:developfrom
sahvx655-wq:pmd-response-window
Oct 17, 2026
Merged

ashtum merged 2 commits into
boostorg:developfrom
sahvx655-wq:pmd-response-window

Conversation

@sahvx655-wq

Copy link
Copy Markdown
Contributor

A permessage-deflate client never checks the server's negotiation response against its own offer. on_response_pmd reads Sec-WebSocket-Extensions from the 101 into a pmd_offer and stores it verbatim (the existing VFALCO note flags exactly this), so a server can hand back server_max_window_bits/client_max_window_bits larger than the client asked for and the client will zi.reset/zo.reset with that larger window. I reproduced it by offering server_max_window_bits=10 and replying with permessage-deflate; server_max_window_bits=15: the handshake completes and get_status reports a 15-bit (32 KiB) inflate window, ignoring the cap the application configured through permessage_deflate. rfc7692 7.1.2.1/7.1.2.2 require the response window to be no larger than the offer, and 7.1.1 requires the client to fail the connection otherwise, so a peer can force this endpoint past a memory bound it deliberately set.

The check belongs beside the response parsing, so on_response_pmd now compares the selected server_max_window_bits/client_max_window_bits against the values already held in pmd_opts_ and fails the handshake with a new error::bad_sec_extensions when either exceeds the offer. It only rejects a window the peer explicitly sends that is larger than the client asked for, so a compliant server (which sends an equal or smaller value, or omits the parameter) is untouched and the existing deflate handshake tests still pass. Added a regression test that drives the client handshake against a crafted response covering both the server and client window cases.

@codecov

codecov Bot commented Sep 27, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.91837% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 93.97%. Comparing base (345f042) to head (0ac76b0).

Files with missing lines Patch % Lines
include/boost/beast/websocket/detail/impl_base.hpp 92.59% 2 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@             Coverage Diff             @@
##           develop    #3113      +/-   ##
===========================================
- Coverage    94.00%   93.97%   -0.03%     
===========================================
  Files          177      177              
  Lines        13857    13898      +41     
===========================================
+ Hits         13026    13061      +35     
- Misses         831      837       +6     
Files with missing lines Coverage Δ
...ude/boost/beast/websocket/detail/pmd_extension.hpp 100.00% <100.00%> (ø)
...ude/boost/beast/websocket/detail/pmd_extension.ipp 98.62% <100.00%> (+0.12%) ⬆️
include/boost/beast/websocket/impl/error.ipp 96.92% <100.00%> (+0.04%) ⬆️
include/boost/beast/websocket/impl/stream_impl.hpp 95.83% <100.00%> (+<0.01%) ⬆️
include/boost/beast/websocket/detail/impl_base.hpp 86.80% <92.59%> (+0.55%) ⬆️

... and 1 file with indirect coverage changes


Continue to review full report in Codecov by Harness.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 345f042...0ac76b0. Read the comment docs.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@sahvx655-wq

Copy link
Copy Markdown
Contributor Author

Merged develop to pick up the CI workflow fixes: the gcc 5-8 and clang 3.7-9 jobs were dying at checkout (the forced node 24 runtime can't start inside the ubuntu 16.04/18.04 containers, per the job logs) and the clang-win jobs failed while bootstrapping b2 itself, so none of those were related to the patch.

Also covered the new error::bad_sec_extensions mapping in test/beast/websocket/error.cpp, which was the one uncovered line codecov flagged. b2 libs/beast/test/beast/websocket passes locally against current develop.

@ashtum ashtum left a comment •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thank you for the PR.

The changes are not compliant with a few sections of RFC 7692, which I've noted in comments on the relevant lines. Note that most of the conformance issues mentioned are pre-existing, but they become important if the goal of this PR is to address them.

We may also want to decide whether to be lenient with certain RFC requirements based on how well existing servers conform to them in the wild. Therefore, some research is needed for each rejection condition. Since this only affects client mode, comparing the behavior of major browsers (Chrome and Firefox) and well-known WebSocket client implementations in Python, Rust, and Go should be sufficient to inform each decision.

Comment thread include/boost/beast/websocket/detail/impl_base.hpp Outdated
Comment thread include/boost/beast/websocket/detail/impl_base.hpp Outdated
Comment thread include/boost/beast/websocket/detail/impl_base.hpp Outdated
@ashtum

ashtum commented Oct 12, 2026 •

Copy link
Copy Markdown
Collaborator

The following table is generated using Claude Fable 5.1 to survey how well-known WebSocket client implementations handle the server's permessage-deflate response:

Legend: websockets = python-websockets; lws = libwebsockets; Chromium policy = its IsCompatibleWith, encoded but never called.

# Response RFC 7692 Wild Beast: now → proposed
1 server window: offered 10, got 15 §7.1.2.1: invalid Fail: websockets, .NET · Accept: ws, lws, Starscream inflater = response → fail
2 server window: offered 10, got nothing §5 request, §7.1.2.1: invalid; §7.1.3: single offer = "cannot do more" Fail: websockets, .NET, Chromium policy · Accept at 32 KiB: ws, lws, Starscream 32 KiB, option ignored → fail
3 client window: offered 10, got 15 §7.1.2.2 ¶3, §7.2.1: valid Fail: ws, websockets, .NET · Accept: lws, Starscream · nobody clamps compressor = response → fail (wild, not RFC)
4 client window: offered 10, got nothing §7.1.2.2 ¶2: honour own hint Own value: ws, websockets · Fail: .NET · 15: lws 15, breaks own hint → offered value
5 client window: got client_max_window_bits with no value §7.1.2.2 ¶3, §7: invalid Fail: Chromium, Firefox, ws, websockets, .NET, lws, coder, OkHttp · Accept: Tomcat, Starscream, undici accepted as 15 → fail
6 client window: not offered, got 10 (decorator only) §7.1.2.2: invalid Fail: websockets, coder, OkHttp, Chromium policy · Accept: Firefox, Jetty, Tomcat, gorilla, Starscream, undici unreachable → fail
7 server window: not offered, got 10 (decorator only) §7.1.2.1: valid Accept: all unreachable → accept
8 client window: got 8 valid; raw deflate can't do 8 At 9: Chromium, lws · Fail: websockets, .NET, Firefox, Tomcat, coder · As given: ws, Starscream compress at 9 → unchanged (max distance 250 < 256)
9 parameter unknown, repeated, or out of range (=16) §7: fail Fail: Chromium, Firefox, ws, websockets, Tomcat, coder, OkHttp · Ignore: gorilla, Jetty, Starscream, undici, .NET uncompressed, bad_reserved_bits later → follow-up
10 permessage-deflate accepted, never offered 6455 §4.1 step 5, 7692 §5: fail Fail: Chromium, Firefox, ws, websockets, Jetty, lws, coder · Accept: gorilla, Tomcat, .NET, Starscream ignored, bad_reserved_bits later → fail
11 an extension accepted that was never offered 6455 §4.1 step 5: fail Fail: Chromium, Firefox, ws, websockets, Jetty, lws, coder, OkHttp · Ignore: gorilla, .NET, Starscream, undici ignored → follow-up
12 server_no_context_takeover offered, not echoed §7.1.1.1: invalid Fail: websockets, Chromium policy; gorilla needs both flags · Ignore: everyone else takeover kept → out of scope

The proposed implementation:

detail::pmd_offer const offer = pmd_config_;      // as sent, see do_pmd_config(req)
detail::pmd_offer agreed;
detail::pmd_read(agreed, res);
if(agreed.accept)
{
    if( ! offer.accept ||                               // rfc6455 4.1 step 5, rfc7692 5: not offered
        (offer.server_max_window_bits > 0 &&            // rfc7692 7.1.2.1: must be echoed, <= offer
            (agreed.server_max_window_bits == 0 ||
             agreed.server_max_window_bits > offer.server_max_window_bits)) ||
        agreed.client_max_window_bits == -1 ||          // rfc7692 7.1.2.2: response form requires a value
        (offer.client_max_window_bits == 0 &&           // rfc7692 7.1.2.2: MUST NOT appear if not offered
            agreed.client_max_window_bits != 0) ||
        (offer.client_max_window_bits > 0 &&            // ws, python-websockets, .NET: above offered value
            agreed.client_max_window_bits > offer.client_max_window_bits))
    {
        BOOST_BEAST_ASSIGN_EC(ec, error::bad_sec_extensions);
        return;
    }
    if(agreed.client_max_window_bits == 0 &&            // omitted: honor our own hint (ws, websockets)
        offer.client_max_window_bits > 0)
        agreed.client_max_window_bits = offer.client_max_window_bits;
}
pmd_config_ = agreed;                                   // 8 stays; open_pmd compresses at 9

@vinniefalco

Copy link
Copy Markdown
Member

That's quite the code block :)

@sahvx655-wq

Copy link
Copy Markdown
Contributor Author

That table settles the wild-behaviour question. I've taken the proposed implementation: on_response_pmd now validates the response against the offer as sent (pmd_config_ is filled from the request in build_request_pmd, per your do_pmd_config note), rejects a permessage-deflate element we never offered (rfc7692 5), rejects a valueless client_max_window_bits in the response since that form is only valid in an offer (7.1.2.2), and treats client_max_window_bits as a hint the server may drop or reduce rather than one it must echo. It stays error::bad_sec_extensions throughout.

One adjustment to the server-window branch. The literal condition rejects whenever the server omits server_max_window_bits and we offered a value, but build_request_pmd always offers server_max_window_bits from pmd_opts_, which is 15 by default, so a compliant server accepting with a bare permessage-deflate would be refused, and that includes Beast's own server (its negotiate omits the parameter once the agreed value is 15). So I read an omitted response value as the default 15 and reject only when that effective value exceeds the offer, which keeps every row of the table and leaves the default handshake intact. I walked each case through the new condition and testExtResponseWindow now covers them: omitting below a reduced offer rejects, omitting under the default-15 offer accepts, the hint is kept when the server stays silent, the valueless response form and an unoffered acceptance both reject, and a window of 8 is accepted.

@ashtum

ashtum commented Oct 13, 2026

Copy link
Copy Markdown
Collaborator

@sahvx655-wq, I restructured the code to make each rejection condition easier to read.

I also clamped agreed.client_max_window_bits to offer.client_max_window_bits instead of rejecting the handshake, since this doesn't affect memory usage and makes our implementation more lenient toward server implementations in the wild.

Comment thread include/boost/beast/websocket/detail/impl_base.hpp Outdated
@ashtum
ashtum force-pushed the pmd-response-window branch 2 times, most recently from c73a65a to 22f612a Compare October 16, 2026 12:14
sahvx655-wq and others added 2 commits October 16, 2026 20:35
The client used to overwrite its own offer with whatever the server
returned in Sec-WebSocket-Extensions. on_response_pmd now checks the
response and fails the handshake with the new error::bad_sec_extensions
(condition::handshake_failed) when:

* permessage-deflate is accepted although the client never offered it
* server_max_window_bits, or its 15-bit default when omitted, exceeds
  the offered limit
* client_max_window_bits is present without a value
* client_max_window_bits is returned although it was not offered

When the response omits client_max_window_bits or returns a larger
value than offered, the offered value is kept so compression uses the
window the client proposed.

Co-authored-by: Mohammad Nejati <ashtumashtum@gmail.com>
@ashtum
ashtum force-pushed the pmd-response-window branch from 22f612a to 0ac76b0 Compare October 16, 2026 17:06
@ashtum
ashtum merged commit d96126e into boostorg:develop Oct 17, 2026
75 of 76 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.

3 participants