Skip to content

node:http2: count the Pad Length octet in the padded frame length - #43554

Open
robobun wants to merge 3 commits into
mainfrom
robobun/5c433499/http2-aligned-padding-length
Open

robobun wants to merge 3 commits into
mainfrom
robobun/5c433499/http2-aligned-padding-length

Conversation

@robobun

@robobun robobun commented Sep 19, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • With PADDING_STRATEGY_ALIGNED, every padded HEADERS and DATA frame is one byte too long: 17 bytes, node v26.3.0 sends 16.
  • Stream::get_padding (src/runtime/api/bun/h2_frame_parser.rs:1445) returned the padding that aligns the payload. Each caller added the Pad Length octet. Zero meant "not padded", so a frame one byte short had no answer.
  • flush_queue capped the padded size at the frame's own length, so queued DATA frames got no padding. request() aligned without the priority fields and wrote Pad Length after them. RFC 9113 6.2 puts it first.

Fix

  • get_padding returns Option<u8>: the Pad Length, or None for no padding. As in nghttp2, the Pad Length octet counts toward the padded length. Some(0) is a PADDED frame that grows by that octet alone.
  • flush_queue bounds the payload by flow control and the frame size. request() aligns the whole payload, writes Pad Length first, and stops padding at 16384 bytes like nghttp2.
  • Verified: frames match node v26.3.0 byte for byte (Notes). test/js/node/http2/h2-conformance.test.ts: 12 new tests, 11 fail on bun 1.4.3. Also test/js/node/http2/ and node's 261 test-http2-*.js files.
  • Self-reviewed: 11 concerns raised, 9 addressed. Rejected: two more boundary tests (Notes).

Background

  • A PADDED frame (RFC 9113 6.1) starts with a one-byte Pad Length and ends with that many zeros. ALIGNED makes frame header plus payload a multiple of 8.
  • In node, OnDWordAlignedPadding returns the padded payload length. nghttp2 sets Pad Length to that length minus the data minus one.
  • bun writes DATA in send_data, or from a queue in Stream::flush_queue when flow control or backpressure blocks.
Notes

Repro: an http2.createServer({ paddingStrategy: PADDING_STRATEGY_ALIGNED }) that answers stream.respond({ ":status": 200 }, { sendDate: false }); stream.end("hello"). A raw socket client prints the frames of stream 1.

node v26.3.0   HEADERS flags=0xc length=7 (total 16)  DATA flags=0x9 length=7 (total 16)
bun 1.4.3      HEADERS flags=0xc length=8 (total 17)  DATA flags=0x9 length=8 (total 17)
this branch    HEADERS flags=0xc length=7 (total 16)  DATA flags=0x9 length=7 (total 16)

Probes where this branch and node v26.3.0 now print the same frames:

  • ALIGNED and MAX, response bodies of 1 to 9, 14 to 16, 250 and 255 to 257 bytes. A body of 6 bytes gives length=7, Pad Length 0. A body of 7 bytes gives an unpadded frame.
  • A peer that opens with SETTINGS_INITIAL_WINDOW_SIZE=0 and then sends WINDOW_UPDATE, so the body goes through the queue. For a 5-byte body: window 1000 gives length=7 pad=1 (ALIGNED) and length=261 pad=255 (MAX). Window 100 gives length=100 pad=94 (MAX). Window 6 gives length=6 pad=0. Window 5 gives an unpadded frame. Window 3 then 1000 gives an unpadded 3-byte frame, then length=7 pad=4. bun 1.4.3 sends every one of these unpadded.
  • node's test/parallel/test-http2-padding-aligned.js expects 74 bytes from the client and 98 from the server. bun 1.4.3 writes 75 and 100. This branch writes 74 and 98. That test still cannot run in bun as it is, because it also asserts the size of each socket write and bun writes the same bytes in fewer chunks.

PADDED + PRIORITY HEADERS, a bun client with request(headers, { exclusive: true }) against a raw server, payload in hex:

bun 1.4.3    80 00 00 00 00 | 04 | block | padding     (priority fields, then Pad Length)
this branch  06 | 80 00 00 00 00 | block | padding     (Pad Length, then priority fields)

Closed PR #32995 had the same layout fix. It was closed as stale, not rejected. #41520 states that the layout fix stays separate from it. node v26.3.0 no longer sets the PRIORITY flag at all, so RFC 9113 6.2 is the reference for this frame.

flush_queue detail: the chunk branch (the window is smaller than the frame) sends exactly max_size bytes, so padding can never fit there. nghttp2 does the same (frame->hd.length >= max_payloadlen means no padding). That branch no longer calls get_padding.

request() detail: the Pad Length octet, the priority fields, the header block and the padding now go to the writer as separate writes. The padding comes from a static zero array. The old code grew the header block Vec and moved the block by one byte, which needed an allocation check before the frame header went out. That check and its Failed to allocate padding buffer error are gone, because nothing allocates any more.

Found while working on this, not fixed here, and not tracked anywhere yet:

  • bun does not pad trailers HEADERS, PUSH_PROMISE, or zero-length DATA frames at all. node v26.3.0 pads all three (under ALIGNED: PUSH_PROMISE length=23 pad=1, the empty END_STREAM DATA frame length=7 pad=6, trailers length=15 pad=2). Those frames never reach get_padding: send_trailers, push_promise, and the zero-length branches of send_data and Stream::flush_queue have no padding logic.
  • client.request(headers, { parent: 0 }) fails with NGHTTP2_INTERNAL_ERROR, and the next request on the session dies with GOAWAY COMPRESSION_ERROR. node v26.3.0 answers both with 200. The check is parent <= 0 in request(), and it runs after the header block went into the shared HPACK encoder. Closed node:http2: fix request() option validation ordering, parent: 0, and the PADDED+PRIORITY HEADERS layout #32995 had the fix. node:http2: encode each header block in one call, all fields or none #41520 moves the option checks before the encode and leaves parent: 0 out.

DATA frames split at 16374 bytes in bun and at 16384 in node. That limit is not part of this change, so the sizes at which a large body stops being padded differ by 10 bytes.

One of the 12 new tests (PADDING_STRATEGY_MAX keeps padded DATA frames inside the flow-control window) passes on bun 1.4.3 too. MAX sizes on the direct write path were already correct. The test pins them, because the same lines changed.

A response of three 5-byte writes, one after the other, to a peer with a small stream window and no WINDOW_UPDATE. The lengths match node v26.3.0: ALIGNED with a window of 20 gives 7 (pad 1), 7 (pad 1), 6 (pad 0). MAX with a window of 600 gives 261, 261, 78 (pad 72). bun 1.4.3 under ALIGNED sends 8, 8 and then an unpadded 4-byte piece.

HEADERS and a peer that allows large frames. nghttp2 bounds the padded length of a HEADERS frame by NGHTTP2_MAX_PAYLOADLEN (16384), not by the peer's SETTINGS_MAX_FRAME_SIZE (session_headers_add_pad). A review comment on this PR found that request() used the peer's setting. With a raw server that advertises a max frame size of 32768, a MAX client and a 16200-byte header block:

node v26.3.0         HEADERS length=16384 Pad Length 183
this PR before it    HEADERS length=16456 Pad Length 255
this PR now          HEADERS length=16384 Pad Length 183

For a block of 16420 bytes node sends no padding (it also splits the block at 16384 into HEADERS plus CONTINUATION). The first commit of this PR padded it. It now goes out unpadded in one frame, because the peer allows 32768.

Code comments are one line each. The links to the node and nghttp2 sources are in this description.

Self-review, the two rejected concerns:

  • An exact-size HEADERS test at the frame size limit. The bound is the same get_padding clamp that the DATA tests pin with windows of 6 and 5 bytes, and the MAX header sweep in node-http2-continuation.test.ts sends header blocks of 16128 to 16400 bytes through it.
  • A queued-frame test where the connection window is the binding limit. It needs about 64 KB of traffic to drain that window first, and the limit is one min() over the two windows.

Suites run on the debug build of the final commit: all of test/js/node/http2/*.test.*, and every test/js/node/test/{parallel,sequential}/test-http2-*.js (261 files, all pass). None of those 261 files sets a padding strategy, so they cover the default path, which sends the same bytes as before.


[human-review] gate passed · iteration 0 · 2 files touched

fails on main (without fix)
ASAN without fix: 11 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" "test/js/node/http2/h2-conformance.test.ts"
bun test v1.4.3 (367d939d9)

test/js/node/http2/h2-conformance.test.ts:
(pass) connection preface & SETTINGS handshake (checklist §1) > server sends a SETTINGS frame first (§1.4) [343.51ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > server ACKs the client's SETTINGS frame (§3.5) [101.78ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS frame with a non-zero stream id is a PROTOCOL_ERROR (§3.5) [76.37ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS frame whose length is not a multiple of 6 is a FRAME_SIZE_ERROR (§3.5) [42.82ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS ACK that carries a payload is a FRAME_SIZE_ERROR (§3.5) [33.88ms]
(pass) PING (checklist §3.7) > server replies to PING with a PING ACK echoing the payload [30.24ms]
(pass) PING (checklist §3.7) > a PING with length != 8 is a FRAME_SIZE_ERROR [35.25ms]
(pass) PING (checklist §3.7) > a PING on a non-zero str
... (truncated)

release without fix: 11 FAILED
bun test v1.4.3-canary.1 (367d939d9)

test/js/node/http2/h2-conformance.test.ts:
(pass) connection preface & SETTINGS handshake (checklist §1) > server sends a SETTINGS frame first (§1.4) [6.85ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > server ACKs the client's SETTINGS frame (§3.5) [2.13ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS frame with a non-zero stream id is a PROTOCOL_ERROR (§3.5) [1.61ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS frame whose length is not a multiple of 6 is a FRAME_SIZE_ERROR (§3.5) [0.84ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS ACK that carries a payload is a FRAME_SIZE_ERROR (§3.5) [0.74ms]
(pass) PING (checklist §3.7) > server replies to PING with a PING ACK echoing the payload [0.63ms]
(pass) PING (checklist §3.7) > a PING with length != 8 is a FRAME_SIZE_ERROR [0.74ms]
(pass) PING (checklist §3.7) > a PING on a non-zero stream id is a PROTOCOL_ERROR [0.62ms]
(pass) WINDOW_UPDATE (checklist §6) > a connection-level WINDOW_UPDATE with a 0 increment is a PROTOCOL_ERROR [0.69ms]
(pass) WINDOW_UPDATE
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" "test/js/node/http2/h2-conformance.test.ts"
bun test v1.4.3 (367d939d9)

test/js/node/http2/h2-conformance.test.ts:
(pass) connection preface & SETTINGS handshake (checklist §1) > server sends a SETTINGS frame first (§1.4) [339.48ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > server ACKs the client's SETTINGS frame (§3.5) [104.28ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS frame with a non-zero stream id is a PROTOCOL_ERROR (§3.5) [76.49ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS frame whose length is not a multiple of 6 is a FRAME_SIZE_ERROR (§3.5) [39.96ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS ACK that carries a payload is a FRAME_SIZE_ERROR (§3.5) [33.76ms]
(pass) PING (checklist §3.7) > server replies to PING with a PING ACK echoing the payload [30.86ms]
(pass) PING (checklist §3.7) > a PING with length != 8 is a FRAME_SIZE_ERROR [34.16ms]
(pass) PING (checklist §3.7) > a PING on a non-zero str
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 713ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/128] gen ErrorCode+*.h
[2/128] gen generated_host_exports.rs
generated_host_exports.rs: 121 exports (host=5, lazy=10, generic=106, rust=0); 245 extern-C blocks audited
[3/128] gen cpp.rs (cppbind)
[4/128] gen JS modules (bundle-modules)
Preprocess modules (7994ms)
Bundle modules (59ms)
Postprocesss modules (177ms)
Bundle Functions (501ms)
Generate Code (35ms)

[8.77s] Bundled "src/js" for production
  2607 kb
  197 internal modules
  13 native modules
  50 internal functions across 16 files
[4/12] cargo bun_runtime → libbun_runtime.a
�[1m�[33mwarning�[0m�[1m: binary `bun_shim_impl` should have a kebab-case name�[0m
   �[1m�[94m|�[0m
�[1m�[94m 1�[0m �[1m�[94m|�[0m /workspace/bun/build/release/rust-target/.../bun_shim_impl
   �[1m�[94m|�[0m                                              �[1m�[33m^^^^^^^^^^^^^�[0m
   �[1m�[94m|�[0m
   �[1m�[94m= �[0m�[1mnote�[0m: `cargo::non_kebab_case_bins` is set to `warn` by default
�[1m�[96mhelp�[0m: to change the binary name to `bun-shim-impl`, convert `bin.name`
  �[
... (truncated)
diff hotspot
src/runtime/api/bun/h2_frame_parser.rs    | 170 ++++++---------
 test/js/node/http2/h2-conformance.test.ts | 332 ++++++++++++++++++++++++++++++
 2 files changed, 395 insertions(+), 107 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                       reads  edits  tests
src/runtime/api/bun/h2_frame_parser.rs        21     28     40
test/js/node/http2/h2-conformance.test.ts      8      8     40

get_padding() returned the aligned padding and every caller then added
one more byte for the Pad Length octet. So PADDING_STRATEGY_ALIGNED
frames were one byte past a multiple of 8. get_padding() now returns the
Pad Length as an Option, sized like nghttp2's padded payload length.
Some(0) is a PADDED frame that grows by the Pad Length octet alone.

The callers pass the real payload bound. Queued DATA frames were capped
at their own length and never padded. A HEADERS frame with priority
fields left them out of the aligned length and wrote Pad Length after
them. RFC 9113 6.2 puts Pad Length first.
@coderabbitai

coderabbitai Bot commented Sep 19, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review paused — included plan limit reached

Keep your review moving with free on-demand reviews.

  • Run this review for free

On-demand reviews are free for one more day.

  • Ask an admin to make reviews automatic

Open in CodeRabbit

Reviews can continue after your included limit without a manual trigger. An admin must approve usage-based billing.

Promotion and pricing details

On-demand reviews are free for one more day. After that, they cost $0.25 per reviewed file.

Review limit details

Or wait 15 minutes for your next included review.

Check out review usage here.

Limit details: You’ve used all 10 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Repository: oven-sh/bun/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 662b53df-140b-4615-989d-3b6de1e8e862

📥 Commits

Reviewing files that changed from the base of the PR and between 9b7c982 and fd7e884.

📒 Files selected for processing (2)
  • src/runtime/api/bun/h2_frame_parser.rs
  • test/js/node/http2/h2-conformance.test.ts

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Sep 19, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

  • Reproduced: an http2.createServer({ paddingStrategy: PADDING_STRATEGY_ALIGNED }) response, read frame by frame over a raw socket. bun 1.4.3 sends HEADERS and DATA frames of 17 bytes. node v26.3.0 sends 16.
  • Fix and tests: this PR (node:http2: count the Pad Length octet in the padded frame length #43554). test/js/node/http2/h2-conformance.test.ts has 12 new tests. 11 fail on bun 1.4.3 and all pass on this branch.
  • Ready for a maintainer. All review threads are resolved. In CI build 118536 the new tests pass on every lane that has finished (179 of 181 jobs). The two macOS x64 test jobs are still queued. The tests that needed a retry are all listed in test/flaky-tests.txt, and none of them sets a padding strategy.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline finding, I also checked two things in src/runtime/api/bun/h2_frame_parser.rs and ruled them out: the (padded_len - frame_len - 1) as u8 cast in get_padding (line ~1473) cannot underflow or truncate, since frame_len < max_payload_len guarantees padded_len >= frame_len + 1 and max_padded_len caps it at frame_len + 256; and write_padded's 16384-byte rare-data buffer (line ~4241) cannot be overrun, since both DATA callers now pass a max_payload_len already clamped to MAX_PAYLOAD_SIZE_WITHOUT_FRAME (16374).

Extended reasoning...

Findings were reported inline, so this body only records what else was examined. The two concerns above were traced through the new get_padding bounds and both write_padded call sites (flush_queue and the direct-send path at ~5212), where max_size is MAX_PAYLOAD_SIZE_WITHOUT_FRAME.min(...) before being handed to get_padding; the padded payload therefore never exceeds 16374 bytes and the u8 cast stays in range on every path.

Comment thread src/runtime/api/bun/h2_frame_parser.rs Resolved
nghttp2 bounds the padded length of a HEADERS frame by
NGHTTP2_MAX_PAYLOADLEN (16384), not by the peer's
SETTINGS_MAX_FRAME_SIZE. For a peer that allows larger frames, a header
block near 16 KB was padded past 16384 bytes under PADDING_STRATEGY_MAX,
and a larger block was padded at all. node does neither.
Comment thread src/runtime/api/bun/h2_frame_parser.rs Resolved
Comment thread src/runtime/api/bun/h2_frame_parser.rs Resolved
Comment thread src/runtime/api/bun/h2_frame_parser.rs Resolved
Comment thread src/runtime/api/bun/h2_frame_parser.rs Resolved
@robobun

robobun commented Sep 19, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 3:37 PM PT - Sep 19th, 2026

✅ @robobun, your commit fd7e884b8a5dff4752a9d90449f067ea5a7bb60a passed in Build #118536! 🎉


🧪   To try this PR locally:

bunx bun-pr 43554

That installs a local version of the PR into your bun-43554 executable, so you can run:

bun-43554 --bun

Comment thread src/runtime/api/bun/h2_frame_parser.rs Resolved

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I re-reviewed after bc4951c and found no bugs; the 16384 HEADERS bound and its boundary test cover the point I raised earlier. Since this changes the bytes node:http2 puts on the wire in three separate paths (queued DATA, direct DATA, request HEADERS), a human look is still worthwhile before merge.

What was reviewed:

  • get_padding arithmetic: with frame_len < max_payload_len the result is always in 0..=255 and the padded payload never exceeds max_payload_len, so write_padded's 16384-byte scratch buffer (payload capped at 16374) and the flow-control window (padding is charged to remote_used_window_size) both hold.
  • The HEADERS path: a Some padding implies payload_size <= actual_max_frame_size, so the CONTINUATION branch never sees a padded block and first_chunk_size stays in range; Pad Length now precedes the priority fields per RFC 9113 6.2.
  • flush_queue: dropping frame_remaining from max_size is what lets a whole queued frame pad into leftover window room; the chunk branch sends exactly max_size bytes so it correctly no longer pads.
  • I could not run the new tests here (no debug build in this checkout), so the test expectations are checked by reading against the code, not by execution.
Extended reasoning...

Overview

The PR reworks outbound HTTP/2 frame padding in /home/claude/bun/src/runtime/api/bun/h2_frame_parser.rs so that the Pad Length octet counts toward the padded payload length, matching nghttp2's select_padding contract. Stream::get_padding now returns Option<u8>, a padding_overhead helper replaces three inline computations, flush_queue no longer caps the padded size at the frame's own length, the direct DATA path passes max_size instead of max_size - 1, and the HEADERS path in request() writes Pad Length before the priority fields and pads from a static zero array instead of shifting the encoded header block in place. The second commit adds MAX_PADDED_HEADERS_PAYLOAD (16384) as an upper bound on padded HEADERS regardless of the peer's SETTINGS_MAX_FRAME_SIZE, which addresses the inline comment from the previous review round. The test file gains a ~330-line describe block with raw-socket frame captures pinned to node v26.3.0 output.

Security risks

None of consequence. Padding bytes come from a static zero array or a zero-filled scratch buffer, so no uninitialized memory reaches the wire. All lengths are derived from local state (window sizes, local encoded size) rather than untrusted peer input, and get_padding returns early when frame_len >= max_payload_len, so the padded_len - frame_len - 1 subtraction cannot underflow and the as u8 cast cannot truncate. The removed try_reserve guard is no longer needed because nothing allocates on that path anymore; the remaining .expect("int cast") on payload_size is bounded by the frame size setting (at most 2^24-1) and was pre-existing.

Level of scrutiny

Moderate-to-high. This is protocol framing code where an off-by-one changes what every peer sees, and the change spans three independent write paths plus a helper semantics change (0 previously meant "unpadded"; now Some(0) is a legitimately padded frame). I traced each call site: the direct DATA path reproduces the old MAX sizes exactly (the tests confirm the ninth case passes on the unfixed build) and fixes the one-byte overshoot for ALIGNED; the queued path now pads into leftover window room and charges padding to both stream and connection windows before writing; the HEADERS path preserves the CONTINUATION decision at the exact-fit boundary (encoded_size + priority_overhead == actual_max_frame_size still takes the single-frame branch as before). The Some(pad_length @ 1..) pattern is already used elsewhere in the tree on the pinned nightly.

Other factors

The bug-hunting pass ran dry with no findings, and the previous round's only inline concern is resolved by code and a dedicated boundary test in bc4951c. No CODEOWNERS entry covers the changed paths. I did not execute the new tests because no debug build exists in this checkout and a from-scratch build is not feasible in a review run; the PR description claims the full http2 suite and node's 261 test-http2-* files pass, which I could not independently confirm. Given the wire-visible nature of the change and the untested-here status, I am not comfortable asserting that no human needs to look, hence defer rather than approve.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This automated review re-ran on the latest push and found no bugs; the HEADERS 16384-byte cap raised last time is now in place (MAX_PADDED_HEADERS_PAYLOAD). Since this changes the bytes node:http2 puts on the wire across four serialization paths, a human look is still worthwhile before merging.

What was reviewed:

  • get_padding arithmetic: padded_len - frame_len - 1 cannot underflow (the frame_len >= max_payload_len early return plus the frame_len + 1 + 255 cap keep padded_len in [frame_len + 1, frame_len + 256]), so the as u8 is exact.
  • Flow control: flush_queue and send_data now pass the full window as max_payload_len, and the padded payload (Pad Length octet included) never exceeds it; the write_padded scratch buffer (16384) covers the largest padded DATA payload (16374).
  • request() single-frame decision: a padded payload is bounded by min(peer max_frame_size, 16384) so it always fits one HEADERS frame; the CONTINUATION branch is unchanged and only reached unpadded.
  • Traced the queued-frame test cases (windows 3/5/6/1000) through the new branches by hand; expected lengths and Pad Length values match the code.
Extended reasoning...

Overview

The PR reworks outbound HTTP/2 frame padding in src/runtime/api/bun/h2_frame_parser.rs: Stream::get_padding now returns Option<u8> with nghttp2 select_padding semantics (the Pad Length octet counts toward the padded length, Some(0) is a PADDED frame with no trailing bytes), a padding_overhead helper replaces four copies of the same inline arithmetic, flush_queue bounds the payload by flow control and the frame-size limit instead of the queued frame's own length, the chunk branch of flush_queue no longer pads (the chunk already fills the window), and request() writes Pad Length before the priority fields and streams the padding from a static zero array instead of growing and shifting the encoded header block. The test file adds nine raw-socket tests in test/js/node/http2/h2-conformance.test.ts that pin frame lengths and Pad Length values, for both DATA and HEADERS, ALIGNED and MAX, direct and queued paths, and the 16384 HEADERS cap against a peer that advertises a larger SETTINGS_MAX_FRAME_SIZE.

Security risks

Padding is purely outbound and computed from local sizes and the peer's advertised window / frame size, both of which are already clamped elsewhere. I checked the two casts the reviewer notes flagged: (padded_len - frame_len - 1) as u8 is provably in range given the early return and the + 1 + 255 cap, and u32::try_from(payload_size).expect(...) operates on values at most 16384. The write_padded scratch buffer is a fixed 16384-byte array and the largest padded DATA payload is MAX_PAYLOAD_SIZE_WITHOUT_FRAME (16374), so there is no out-of-bounds write. The batch.resize in the batched send_data path adds exactly pad_length zeros. No parsing of untrusted input changes here.

Level of scrutiny

Moderate to high. This is wire-format serialization in node:http2, so a mistake would show up as peer-side FRAME_SIZE_ERROR or flow-control violations rather than a crash, and it would only affect users who opt into a non-default paddingStrategy (none of node's 261 test-http2-* files set one). The new tests assert exact byte layouts and were reported by the author to fail on the previous release, which is the right shape of evidence. The removal of padding on the flush_queue chunk branch is a behaviour change, but it matches nghttp2 (frame->hd.length >= max_payloadlen means no padding) and it fixes a real window overrun in the old code. Still, four separate serialization sites changed and the arithmetic is easy to get off by one, which is why a human pass is worth it rather than an outright approval.

Other factors

My prior inline finding (HEADERS padding must be capped at 16384 regardless of the peer's SETTINGS_MAX_FRAME_SIZE) was addressed in a follow-up commit with MAX_PADDED_HEADERS_PAYLOAD and a dedicated test; the thread was resolved by this bot's own session, but the code change is verifiable in the diff. The github-actions[bot] inline comments that followed all point at comment lines, and a later commit tidied those comments, so nothing there reads as an outstanding human objection. No CODEOWNERS entry covers the changed files. The bug hunt exited on a dry streak rather than a budget bound.

@robobun

robobun commented Sep 21, 2026

Copy link
Copy Markdown
Collaborator Author

Overlap note. #43544 now removes the PRIORITY field from the HEADERS writer in H2FrameParser::request(). A maintainer asked there to match node v26.3.0, which never sends the field. That change deletes has_priority, priority_overhead and the priority write that this PR moves behind the Pad Length octet. The padding length fix in this PR does not depend on it. The second of the two PRs to land needs a rebase in request(), and after #43544 the order of Pad Length and the priority fields has no effect.

@robobun

robobun commented Sep 22, 2026

Copy link
Copy Markdown
Collaborator Author

A review on #43599 asked that bun match node v26.3.0, which never sets the PRIORITY flag on a HEADERS frame (nghttp2 1.69.0 discards the priority spec): #43599 (comment)

#43599 now removes the priority fields from the native HEADERS writer. That overlaps with this PR in two places:

  • The Pad Length order ahead of the priority fields, and encoded_size + priority_overhead in request(). With no priority fields, neither is needed. The two PRs conflict in that part of request().
  • The test %s pads a HEADERS frame that carries priority fields expects the PRIORITY flag for { exclusive: true }. It fails once node:http2: send no priority fields on HEADERS, like node #43599 lands.

The padding length fix itself (get_padding, the DATA writers, flush_queue) is not affected.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant