-
Notifications
You must be signed in to change notification settings - Fork 4.4k
fix(http2): reject non-zero content-length with END_STREAM on HEADERS per RFC 9113 8.6 #29059
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
base: main
Are you sure you want to change the base?
Changes from all commits
f9a1727
7c4db20
f148ea3
003e9dc
f1e21ff
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -768,6 +768,7 @@ pub const H2FrameParser = struct { | |
| waitForTrailers: bool = false, | ||
| closeAfterDrain: bool = false, | ||
| endAfterHeaders: bool = false, | ||
| contentLength: i64 = -1, | ||
| isWaitingMoreHeaders: bool = false, | ||
| padding: ?u8 = null, | ||
| paddingStrategy: PaddingStrategy = .none, | ||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Extended reasoning...What the bug is and how it manifests The new check at line 1989 guards on 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 The specific code path that triggers it The attacker sends a single HEADERS frame with END_STREAM set, containing two
When the loop exits, A second variant works with an unparseable value: Why existing code doesn't prevent it There is no deduplication or conflict detection for duplicate Impact A remote client can reliably bypass the new RFC 9113 §8.6 protection by simply adding a second How to fix it RFC 9113 §8.3 (citing RFC 7230 §3.3.2) requires the server to treat a request with multiple differing Step-by-step proof
|
||
|
|
||
| // 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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: Extended reasoning...What the bug is and how it manifests The RFC 9113 §8.6 check added by this PR (lines 1986–1992 of The specific code path that triggers it All HEADERS frames—both initial request headers and trailers—go through the same dispatch path into Why existing code doesn't prevent it
Step-by-step proof
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. |
||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 After the new content-length check calls Extended reasoning...Root CauseThe new check added in 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 Why This Is Wrong
Because Double-Dispatch TraceBack in
Concrete ExampleA client sends a POST with
The application sees both an error event and an end event on a stream that was already rejected. FixChange the new block to return 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 |
||
|
|
@@ -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(); | ||
|
|
@@ -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; | ||
| }; | ||
|
|
||
| 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"); | ||
| } | ||
| }); |
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.
Restrict this check to the initial request headers, not later trailer HEADERS.
stream.contentLengthnow lives for the whole stream, and Line 1989 consults it on every later HEADERS block. That makes a valid request likecontent-length: 10→ 10 bytes of DATA → trailing HEADERS with END_STREAM get reset asPROTOCOL_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