Repository navigation
fix(http2): honor maxSettings option per RFC 9113 §6.5 #29041
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from 1 commit
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| 'use strict'; | ||
|
|
||
| const common = require('../common'); | ||
| if (!common.hasCrypto) | ||
| common.skip('missing crypto'); | ||
|
|
||
| const http2 = require('http2'); | ||
|
|
||
| const server = http2.createServer({ maxSettings: 1 }); | ||
|
|
||
| // TODO(@jasnell): There is still a session event | ||
| // emitted on the server side but it will be destroyed | ||
| // immediately after creation and there will be no | ||
| // stream created. | ||
| server.on('session', common.mustCall((session) => { | ||
| session.on('stream', common.mustNotCall()); | ||
| session.on('remoteSettings', common.mustNotCall()); | ||
| }, 2)); | ||
| server.on('stream', common.mustNotCall()); | ||
|
|
||
| server.listen(0, common.mustCall(() => { | ||
| // Specify two settings entries when a max of 1 is allowed. | ||
| // Connection should error immediately. | ||
| const client = http2.connect( | ||
| `http://localhost:${server.address().port}`, { | ||
| settings: { | ||
| // The actual settings values do not matter. | ||
| headerTableSize: 1000, | ||
| enablePush: false, | ||
| }, | ||
| }); | ||
|
|
||
| client.on('error', common.mustCall((err) => { | ||
| // The same but with custom settings | ||
| const client2 = http2.connect( | ||
| `http://localhost:${server.address().port}`, { | ||
| settings: { | ||
| // The actual settings values do not matter. | ||
| headerTableSize: 1000, | ||
| customSettings: { | ||
| 0x14: 45 | ||
| } | ||
| }, | ||
| }); | ||
|
|
||
| client2.on('error', common.mustCall(() => { | ||
| server.close(); | ||
| })); | ||
| })); | ||
|
|
||
|
|
||
| })); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔴 The new maxSettings check at line 2436–2439 calls
sendGoAwaythen returns, but thedefer if (\!isACK) this.sendSettingsACK()registered at line 2426 fires on every return whileisACKis false—so the peer receives both aGOAWAY(ENHANCE_YOUR_CALM)and aSETTINGS_ACKfor the same frame. RFC 9113 §6.5 definesSETTINGS_ACKas confirmation that the settings were accepted and applied, which directly contradicts the rejection signaled by GOAWAY. Fix by moving the defer below all frame-validation early-return checks (after line 2440), so it only fires when the frame is fully accepted.Extended reasoning...
What the bug is and how it manifests
In
handleSettingsFrame(line 2418), the very first thing registered after the stream-identifier guard is a deferred call at line 2426:This defer fires on every return from the function as long as
isACKis false. The new maxSettings enforcement added by this PR (lines 2435-2440) callssendGoAwayand then returns immediately, which triggers the defer and sends aSETTINGS_ACKto the peer right after theGOAWAY.The specific code path that triggers it
When a SETTINGS frame arrives with more entries than
maxSettings:isACKis false (if it were true, the check at line 2430 would already have returned—and that early return does NOT trigger the defer becauseisACKis true there).entry_count > this.maxSettings.sendGoAwayis called, writing aGOAWAY(ENHANCE_YOUR_CALM)frame.return data.len(line 2439) unwinds the defer.isACKis false, sosendSettingsACK()is called unconditionally.Why existing code does not prevent it
sendGoAwaywrites the GOAWAY bytes and dispatches JS events but sets no closed flag on the parser.sendSettingsACKhas no guard—it unconditionally writes a SETTINGS ACK frame.write()has no closed-state check either. Nothing prevents the second frame from being enqueued.Impact
The peer receives two contradictory frames for the same SETTINGS:
GOAWAY(ENHANCE_YOUR_CALM)— your settings were rejected, session torn downSETTINGS_ACK— your settings were received and appliedRFC 9113 §6.5 is explicit: a
SETTINGS_ACKMUST only be sent when settings have been applied. Sending it alongside a GOAWAY is a protocol violation that can cause clients to briefly believe their oversized settings were accepted, masking the real error.Step-by-step proof
Server configured with
{ maxSettings: 1 }, client sends SETTINGS with two entries (as in the new test):handleSettingsFramecalled.isACK = false.defer if (\!isACK) this.sendSettingsACK();registered (capturesisACK = false).false or false→ falls through.entry_count = 2. Line 2436:2 > 1→ true.sendGoAway(..., ENHANCE_YOUR_CALM, ...)writes GOAWAY frame to socket.return data.len— defer fires →sendSettingsACK()writes SETTINGS_ACK frame to socket.How to fix it
Move the
deferbelow all frame-validation early-return checks (i.e., place it after line 2440), so it only fires on the successful acceptance path. Alternatively, use a localacceptedboolean flag set totrueonly on the non-error path, and condition the ACK on that flag instead of just\!isACK.Note: the same pre-existing issue exists at the FRAME_SIZE_ERROR path (lines 2430-2433) when
isACK=falseandframe.length % settingByteSize \!= 0, but this PR introduces a new instance via the maxSettings check.