Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 29 additions & 2 deletions src/bun.js/api/bun/h2_frame_parser.zig
Original file line number Diff line number Diff line change
Expand Up @@ -768,6 +768,7 @@ pub const H2FrameParser = struct {
waitForTrailers: bool = false,
closeAfterDrain: bool = false,
endAfterHeaders: bool = false,
contentLength: i64 = -1,
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.

⚠️ Potential issue | 🟠 Major

Restrict this check to the initial request headers, not later trailer HEADERS.

stream.contentLength now lives for the whole stream, and Line 1989 consults it on every later HEADERS block. That makes a valid request like content-length: 10 → 10 bytes of DATA → trailing HEADERS with END_STREAM get reset as PROTOCOL_ERROR, even though the declared body was already sent. Track whether you're still in the opening header block (or clear this state once body/trailers begin) before applying the RFC 9113 §8.6 check.

Also applies to: 1923-1925, 1989-1993

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/bun.js/api/bun/h2_frame_parser.zig` at line 771, The RFC 9113 §8.6
content-length validation is being applied to every HEADERS frame because
stream.contentLength is persistent; restrict that validation to the initial
request header block only. Add or use a boolean “initial headers” state (e.g., a
flag on the stream like stream.initialHeaders or a local isInitialHeaders) that
is true while parsing the opening header block and becomes false when the first
DATA frame is seen or when trailers begin, or alternatively clear
stream.contentLength once body/trailers start; then update the checks that
reference contentLength (the places around the declaration contentLength: i64 =
-1 and the validation sites that consult stream.contentLength) so they only run
when in the initial headers state (or when contentLength was set in that initial
block), leaving trailing HEADERS and later blocks exempt.

isWaitingMoreHeaders: bool = false,
padding: ?u8 = null,
paddingStrategy: PaddingStrategy = .none,
Expand Down Expand Up @@ -1919,6 +1920,17 @@ pub const H2FrameParser = struct {
return null;
}

if (this.isServer and strings.eqlComptime(header.name, "content-length")) {
// RFC 9113 §8.1.1: malformed if content-length is not a valid non-negative integer,
// or if multiple content-length fields with differing values are present.
const parsed = std.fmt.parseInt(i64, header.value, 10) catch -1;
if (parsed < 0 or (stream.contentLength != -1 and stream.contentLength != parsed)) {
this.endStream(stream, ErrorCode.PROTOCOL_ERROR);
return null;
}
stream.contentLength = parsed;
}
Comment on lines 1920 to +1932
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.

🔴 The RFC 9113 §8.6 enforcement introduced by this PR can be bypassed by sending duplicate content-length headers in the HEADERS frame: the second header unconditionally overwrites stream.contentLength, so a client that sends content-length: 10 followed by content-length: 0 (or an unparseable value like content-length: abc) reduces contentLength to 0 or -1, causing the contentLength > 0 guard to be false and allowing the malformed request through. RFC 9113 §8.3 / RFC 7230 §3.3.2 already require rejecting requests with conflicting content-length field values as malformed; the fix should detect and reject the stream on the second content-length header with a different value.

Extended reasoning...

What the bug is and how it manifests

The new check at line 1989 guards on stream.contentLength > 0, but stream.contentLength is set inside the header-iteration loop (lines 1923–1925) with a simple assignment that unconditionally overwrites on every content-length header encountered:

if (this.isServer and strings.eqlComptime(header.name, "content-length")) {
    stream.contentLength = std.fmt.parseInt(i64, header.value, 10) catch -1;
}

Because HPACK (lshpack) does no HTTP semantic validation, it happily delivers multiple decoded content-length headers to the application layer. The loop will overwrite contentLength as many times as the client sends the header.

The specific code path that triggers it

The attacker sends a single HEADERS frame with END_STREAM set, containing two content-length headers. The loop processes them in order:

  1. content-length: 10stream.contentLength = 10
  2. content-length: 0stream.contentLength = 0 ← overwrites

When the loop exits, stream.contentLength == 0. The guard at line 1989 evaluates 0 > 0 → false; endStream is never called, and the request is dispatched to the application stream handler unchanged.

A second variant works with an unparseable value: content-length: abc causes parseInt to return the catch -1 fallback. -1 > 0 is also false, so that path likewise bypasses enforcement.

Why existing code doesn't prevent it

There is no deduplication or conflict detection for duplicate content-length headers anywhere in h2_frame_parser.zig. The HPACK decoder is a pure bytes-to-name/value decoder and intentionally knows nothing about HTTP semantics. Nor does any other layer in the call stack reject a second content-length header.

Impact

A remote client can reliably bypass the new RFC 9113 §8.6 protection by simply adding a second content-length: 0 to every request. The server will accept and dispatch a HEADERS+END_STREAM request that declares a non-zero body, which can lead to desync in the request-processing pipeline or other protocol-level confusion. The attack requires only crafting a HEADERS frame directly (e.g. with nghttp2 CLI, raw h2spec, or any HTTP/2 library that allows full header control).

How to fix it

RFC 9113 §8.3 (citing RFC 7230 §3.3.2) requires the server to treat a request with multiple differing content-length values as malformed and respond with a stream error of type PROTOCOL_ERROR. The simplest compliant fix: if stream.contentLength has already been set (i.e. \!= -1) and the new value differs, call endStream with PROTOCOL_ERROR immediately. Alternatively, take the maximum over all observed values; that is slightly less conservative but still catches the 0-overwrite attack path.

Step-by-step proof

  1. Client opens an HTTP/2 connection to a Bun server.
  2. Client sends a HEADERS frame with END_STREAM set containing: content-length: 10, content-length: 0.
  3. decodeHeaderBlock iterates the decoded headers. After iteration: stream.contentLength == 0.
  4. Post-loop guard: this.isServer → true; stream.contentLength > 00 > 0false; guard does not fire.
  5. dispatchWith3Extra(.onStreamHeaders, ...)} is called — the stream reaches the application handler.
  6. streamCount in the test would be incremented to 1, and req.rstCode would not be NGHTTP2_PROTOCOL_ERROR, demonstrating the bypass.


// RFC 7540 Section 6.5.2: Calculate header list size
// Size = name length + value length + HPACK entry overhead per header
headerListSize += header.name.len + header.value.len + HPACK_ENTRY_OVERHEAD;
Comment on lines 1920 to 1936
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.

🔴 The new RFC 9113 §8.6 check incorrectly RSTs valid POST requests that include trailer headers: stream.contentLength is set to the value from the initial HEADERS frame and never reset, so when trailer HEADERS arrive with END_STREAM (but no repeated content-length header), stream.contentLength is still non-zero, triggering the RST with PROTOCOL_ERROR on a perfectly valid request. The fix should either guard the check to only apply on the initial HEADERS frame (e.g. when stream.state indicates no data has been received yet) or reset stream.contentLength to -1 at the start of each decodeHeaderBlock call.

Extended reasoning...

What the bug is and how it manifests

The RFC 9113 §8.6 check added by this PR (lines 1986–1992 of h2_frame_parser.zig) correctly rejects a malformed HEADERS frame that simultaneously declares a non-zero content-length and sets END_STREAM. However, stream.contentLength is a field on the Stream struct that persists for the entire stream lifetime and is never cleared between the initial HEADERS frame and a subsequent trailer HEADERS frame. This causes a false positive: a valid POST request that sends trailers is rejected with PROTOCOL_ERROR.

The specific code path that triggers it

All HEADERS frames—both initial request headers and trailers—go through the same dispatch path into decodeHeaderBlock. Inside decodeHeaderBlock, the per-header loop at line ~1920 sets stream.contentLength whenever a content-length header is present. After the loop, the new check at line 1986 tests stream.contentLength > 0 AND (flags & END_STREAM) \!= 0.

Why existing code doesn't prevent it

handleHeadersFrame makes no distinction between initial HEADERS and trailer HEADERS before calling decodeHeaderBlock; both receive the same flags. stream.contentLength is initialized to -1 and written once (on the initial HEADERS), but there is no code that resets it before or during the trailer HEADERS call.

Step-by-step proof

  1. Client sends initial HEADERS: content-length: 10, END_STREAM not set.
    stream.contentLength = 10. Check does not fire (END_STREAM is absent). Stream state → OPEN.
  2. Client sends DATA frame(s): 10 bytes, END_STREAM not set.
    → Data is processed normally. stream.contentLength is untouched (still 10).
  3. Client sends trailer HEADERS: END_STREAM set, no content-length header.
    decodeHeaderBlock is called. The per-header loop finds no content-length entry, so stream.contentLength stays at 10.
    → Check at line 1986: 10 > 0 ✓ AND (flags & END_STREAM) \!= 0 ✓ → this.endStream(stream, PROTOCOL_ERROR) fires.
    → A fully valid HTTP/2 request is rejected with RST_STREAM PROTOCOL_ERROR.

Impact

Any server-side HTTP/2 handler that expects to receive requests with trailers (common in gRPC, multipart streaming, etc.) will fail for all such requests. This is a regression introduced by this PR.

How to fix it

Option A (simplest): In the check at line 1986, additionally guard that the stream is processing its initial HEADERS, e.g. by checking that no DATA has been received yet or that the stream is in the IDLE/OPEN-before-data state.
Option B: Use a local variable for contentLength within decodeHeaderBlock instead of reading from stream.contentLength. Only persist to stream.contentLength (and then run the check) if this is the initial HEADERS frame.

Expand Down Expand Up @@ -1981,6 +1993,17 @@ pub const H2FrameParser = struct {
}
}

// RFC 9113 §8.6: a request is malformed if the value of a content-length header field
// does not equal the sum of the DATA frame payload lengths. A non-zero content-length
// with END_STREAM on the header block (no DATA frames possible) is therefore malformed.
// Only check once the full header block is assembled (END_HEADERS set); use
// stream.endAfterHeaders so the END_STREAM flag from the original HEADERS frame is
// honored even when CONTINUATION frames carry part of the block.
if (this.isServer and stream.contentLength > 0 and stream.endAfterHeaders and (flags & @intFromEnum(HeadersFrameFlags.END_HEADERS)) != 0) {
this.endStream(stream, ErrorCode.PROTOCOL_ERROR);
return null;
}

this.dispatchWith3Extra(.onStreamHeaders, stream.getIdentifier(), headers, sensitiveHeaders, jsc.JSValue.jsNumber(flags));
// callbacks can change the Stream ptr in this case we always return the new one
if (this.streams.getEntry(stream_id)) |entry| return entry.value_ptr;
Comment on lines 1993 to 2009
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.

🔴 After the new content-length check calls endStream(stream, PROTOCOL_ERROR), decodeHeaderBlock re-looks up the stream in this.streams (which freeResources never removes it from) and returns it as non-null, so handleHeadersFrame continues executing the endAfterHeaders block — dispatching onStreamEnd after onStreamError was already dispatched by endStream, and incorrectly reverting stream.state from .CLOSED back to .HALF_CLOSED_REMOTE. The fix is to return null unconditionally after calling endStream in the new check, rather than re-fetching the stream from the map.

Extended reasoning...

Root Cause

The new check added in decodeHeaderBlock (lines 1986-1997) correctly calls this.endStream(stream, ErrorCode.PROTOCOL_ERROR) to RST the stream when a non-zero content-length accompanies an END_STREAM flag. However, the code immediately after does:

this.endStream(stream, ErrorCode.PROTOCOL_ERROR);
if (this.streams.getEntry(stream_id)) |entry| return entry.value_ptr;
return null;

This re-fetches the stream from this.streams and returns it as a non-null pointer.

Why This Is Wrong

endStream (line 1330-1363) does the following:

  1. Sets stream.state = .CLOSED (line 1352)
  2. Calls stream.freeResources(this, false) (line 1355) — clears jsContext/queue but does not remove the stream from this.streams
  3. Dispatches onStreamError (line 1359) — first dispatch

Because freeResources does not call this.streams.remove(stream_id), this.streams.getEntry(stream_id) succeeds and decodeHeaderBlock returns the now-closed stream pointer (non-null).

Double-Dispatch Trace

Back in handleHeadersFrame:

  1. Line 2397 (before decodeHeaderBlock): stream.endAfterHeaders = true (END_STREAM was set)
  2. Line 2398: decodeHeaderBlock returns non-null (stream still in map)
  3. The orelse { return content.end; } branch is not taken
  4. Line 2402: if (stream.endAfterHeaders)true (set before call, never cleared by endStream)
  5. Line 2410: if (stream.state == .HALF_CLOSED_LOCAL)false (state is .CLOSED)
  6. Line 2414: stream.state = .HALF_CLOSED_REMOTEincorrectly overwrites .CLOSED
  7. Line 2417: dispatchWithExtra(.onStreamEnd, ...)second dispatch

Concrete Example

A client sends a POST with content-length: 10 and END_STREAM set (no body). The server:

  • Dispatches onStreamError (PROTOCOL_ERROR) from endStream
  • Sets stream.state = .HALF_CLOSED_REMOTE (was .CLOSED) — WRONG
  • Dispatches onStreamEnd with the now-cleared jsContext (numeric identifier only) — WRONG

The application sees both an error event and an end event on a stream that was already rejected.

Fix

Change the new block to return null unconditionally after endStream:

if (this.isServer and stream.contentLength > 0 and (flags & @intFromEnum(HeadersFrameFlags.END_STREAM)) \!= 0) {
    this.endStream(stream, ErrorCode.PROTOCOL_ERROR);
    return null; // stream is closed; do not re-fetch from map
}

This causes handleHeadersFrame to take the orelse { return content.end; } path, stopping all further processing for the rejected stream.

Expand Down Expand Up @@ -2318,11 +2341,12 @@ pub const H2FrameParser = struct {
if (handleIncommingPayload(this, data, frame.streamIdentifier)) |content| {
const payload = content.data;
this.readBuffer.reset();
stream.endAfterHeaders = frame.flags & @intFromEnum(HeadersFrameFlags.END_STREAM) != 0;
// CONTINUATION frames never carry END_STREAM; preserve stream.endAfterHeaders
// from the originating HEADERS frame so decodeHeaderBlock can validate against it.
stream = (try this.decodeHeaderBlock(payload[0..payload.len], stream, frame.flags)) orelse {
return content.end;
};
if (stream.endAfterHeaders) {
if (stream.endAfterHeaders and frame.flags & @intFromEnum(HeadersFrameFlags.END_HEADERS) != 0) {
stream.isWaitingMoreHeaders = false;
if (frame.flags & @intFromEnum(HeadersFrameFlags.END_STREAM) != 0) {
const identifier = stream.getIdentifier();
Expand Down Expand Up @@ -2384,6 +2408,9 @@ pub const H2FrameParser = struct {
return content.end;
}
stream.endAfterHeaders = frame.flags & @intFromEnum(HeadersFrameFlags.END_STREAM) != 0;
// Reset for each HEADERS frame so a content-length from the initial request
// headers does not leak into a later trailer HEADERS block.
stream.contentLength = -1;
stream = (try this.decodeHeaderBlock(payload[offset..end], stream, frame.flags)) orelse {
return content.end;
};
Expand Down
71 changes: 71 additions & 0 deletions test/js/node/http2/h2-content-length-mismatch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { expect, test } from "bun:test";
import { once } from "node:events";
import http2 from "node:http2";

test("server rejects request with non-zero content-length and END_STREAM per RFC 9113 8.6", async () => {
const server = http2.createServer();
let streamCount = 0;
server.on("stream", stream => {
streamCount++;
stream.respond({ ":status": 200 });
stream.end();
});
server.listen(0);
await once(server, "listening");
const port = (server.address() as any).port;

const client = http2.connect("http://127.0.0.1:" + port);
try {
const req = client.request({ ":method": "POST", "content-length": "10" }, { endStream: true });
const { promise: closed, resolve } = Promise.withResolvers<void>();
req.on("error", () => {});
req.on("close", () => resolve());
req.end();
await closed;

expect(streamCount).toBe(0);
expect(req.rstCode).toBe(http2.constants.NGHTTP2_PROTOCOL_ERROR);
} finally {
client.close();
server.close();
await once(server, "close");
}
});

test("server accepts request with content-length, body, and trailers", async () => {
const server = http2.createServer();
const { promise: gotTrailers, resolve: resolveTrailers } = Promise.withResolvers<Record<string, string>>();
server.on("stream", stream => {
stream.on("trailers", headers => resolveTrailers(headers as Record<string, string>));
stream.on("error", () => {});
stream.respond({ ":status": 200 });
stream.resume();
stream.end();
});
server.listen(0);
await once(server, "listening");
const port = (server.address() as any).port;

const client = http2.connect("http://127.0.0.1:" + port);
try {
const req = client.request(
{ ":method": "POST", "content-length": "10" },
{ endStream: false, waitForTrailers: true },
);
req.on("error", () => {});
req.on("wantTrailers", () => req.sendTrailers({ "x-trailer": "ok" }));
const { promise: closed, resolve } = Promise.withResolvers<void>();
req.on("close", () => resolve());
req.write("0123456789");
req.end();
const trailers = await gotTrailers;
await closed;

expect(trailers["x-trailer"]).toBe("ok");
expect(req.rstCode).toBe(http2.constants.NGHTTP2_NO_ERROR);
} finally {
client.close();
server.close();
await once(server, "close");
}
});
Loading