Skip to content
Open
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
159 changes: 101 additions & 58 deletions test/js/node/http2/h2-conformance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
// WINDOW_UPDATE, frame-size and stream-id rules. HPACK/HEADERS cases live in a sibling file.

import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { bunEnv, bunExe, gcTick, normalizeBunSnapshot } from "harness";
import { bunEnv, bunExe, gcTick, isDebug, normalizeBunSnapshot } from "harness";
import { once } from "node:events";
import http2 from "node:http2";
import net from "node:net";
Expand Down Expand Up @@ -1932,6 +1932,12 @@ describe("stream release after a queued END_STREAM", () => {
// Stream resets are rate-limited per connection like nghttp2's stream_reset_ratelim (burst 1000,
// refill 33/s): past the bucket the session dies with GOAWAY, surfaced as ERR_HTTP2_ERROR.
describe("stream-reset floods (CVE-2023-44487 rapid reset, CVE-2025-8671 MadeYouReset)", () => {
// A release build takes 1000 streams in about 30 ms. A debug build needs 1 to 15 s for them.
// The tests that have to send a whole bucket of 1000 resets wait longer there, under a timeout
// that is longer than their waits. Other builds keep the default timeout.
const FULL_BUCKET_WAIT = isDebug ? 20_000 : 10_000;
const FULL_BUCKET_TIMEOUT = isDebug ? 60_000 : undefined;
Comment thread
robobun marked this conversation as resolved.

const CANCEL = Buffer.alloc(4);
CANCEL.writeUInt32BE(ErrorCode.CANCEL, 0);
const rstStream = (sid: number) => encodeFrame(FrameType.RST_STREAM, 0, sid, CANCEL);
Expand Down Expand Up @@ -1971,29 +1977,51 @@ describe("stream-reset floods (CVE-2023-44487 rapid reset, CVE-2025-8671 MadeYou
}
}

async function flood(opts: { options?: Record<string, unknown>; count: number; kill?: (sid: number) => Buffer }) {
// Writes `count` request/kill pairs and returns the GOAWAY. The PING behind the pairs is
// answered when the server took them all. 1200 resets empty a bucket of 1000 unless it
// regained 200 tokens meanwhile. That takes 7 refills of 33, one per second, so only a build
// that needs more than 6 s for the pairs gets them once more.
async function flood(opts: {
options?: Record<string, unknown>;
count: number;
kill?: (sid: number) => Buffer;
wait?: number;
}) {
const { server, state } = respondingServer(opts.options);
return withClient(server, async c => {
c.send(pairs(opts.count, 1, opts.kill ?? rstStream));
const goaway = await c.waitForGoaway(10_000);
return { c, goaway, ...state };
const write = (nth: number) => {
const opaque = Buffer.alloc(8, nth);
const answered = (f: Frame) => f.type === FrameType.PING && f.payload.equals(opaque);
c.send(pairs(opts.count, 1 + 2 * opts.count * nth, opts.kill ?? rstStream));
c.sendFrame(FrameType.PING, 0, 0, opaque);
return c.waitFor(f => f.type === FrameType.GOAWAY || answered(f), opts.wait ?? 10_000);
};
const start = performance.now();
let answer = await write(0);
if (answer.type !== FrameType.GOAWAY && performance.now() - start > 6000) answer = await write(1);
if (answer.type !== FrameType.GOAWAY) throw new Error("the server took the whole flood and sent no GOAWAY");
return { c, goaway: answer, ...state };
});
}

// True when stream `sid` is answered and no GOAWAY came first.
async function servedBeforeGoaway(c: RawH2, sid: number) {
async function servedBeforeGoaway(c: RawH2, sid: number, wait = 10_000) {
const served = (f: Frame) => f.type === FrameType.HEADERS && f.streamId === sid;
return served(await c.waitFor(f => served(f) || f.type === FrameType.GOAWAY, 10_000));
return served(await c.waitFor(f => served(f) || f.type === FrameType.GOAWAY, wait));
}

test("a RST_STREAM flood is answered with GOAWAY(ENHANCE_YOUR_CALM) and a session error", async () => {
const { goaway, handlers, sessionErrorCode } = await flood({ count: 1200 });
expect(goawayErrorCode(goaway)).toBe(ErrorCode.ENHANCE_YOUR_CALM);
expect(goaway.payload.subarray(8).toString()).toBe("too many stream resets");
// Like node, every request up to the bucket's edge still reaches the handler.
expect(handlers).toBeGreaterThan(900);
expect(sessionErrorCode).toBe("ERR_HTTP2_ERROR");
});
test(
"a RST_STREAM flood is answered with GOAWAY(ENHANCE_YOUR_CALM) and a session error",
async () => {
const { goaway, handlers, sessionErrorCode } = await flood({ count: 1200, wait: FULL_BUCKET_WAIT });
expect(goawayErrorCode(goaway)).toBe(ErrorCode.ENHANCE_YOUR_CALM);
expect(goaway.payload.subarray(8).toString()).toBe("too many stream resets");
// Like node, every request up to the bucket's edge still reaches the handler.
expect(handlers).toBeGreaterThan(900);
expect(sessionErrorCode).toBe("ERR_HTTP2_ERROR");
},
FULL_BUCKET_TIMEOUT,
);

test("streamResetBurst sets where the flood is detected", async () => {
const { goaway } = await flood({ options: { streamResetBurst: 50, streamResetRate: 1 }, count: 200 });
Expand All @@ -2006,10 +2034,10 @@ describe("stream-reset floods (CVE-2023-44487 rapid reset, CVE-2025-8671 MadeYou
test("a flood under the burst keeps the session serving requests", async () => {
const { server, state } = respondingServer();
await withClient(server, async c => {
c.send(Buffer.concat([pairs(500, 1, rstStream), request(1001)]));
await c.waitFor(f => f.type === FrameType.HEADERS && f.streamId === 1001, 10_000);
c.send(Buffer.concat([pairs(100, 1, rstStream), request(201)]));
await c.waitFor(f => f.type === FrameType.HEADERS && f.streamId === 201, 10_000);
expect(c.frames.find(f => f.type === FrameType.GOAWAY)).toBeUndefined();
expect(state.handlers).toBe(501);
expect(state.handlers).toBe(101);
});
});

Expand Down Expand Up @@ -2040,17 +2068,22 @@ describe("stream-reset floods (CVE-2023-44487 rapid reset, CVE-2025-8671 MadeYou
};

for (const [name, kill] of Object.entries(madeYouReset)) {
test(`a flood of server-sent resets via ${name} is answered with GOAWAY(ENHANCE_YOUR_CALM)`, async () => {
const { c, goaway, handlers, sessionErrorCode } = await flood({
options: { streamResetBurst: 5, streamResetRate: 1 },
count: 1200,
kill,
});
expect(goawayErrorCode(goaway)).toBe(ErrorCode.ENHANCE_YOUR_CALM);
expect(c.frames.filter(f => f.type === FrameType.RST_STREAM).length).toBeGreaterThanOrEqual(1000);
expect(handlers).toBeGreaterThanOrEqual(1000);
expect(sessionErrorCode).toBe("ERR_HTTP2_ERROR");
});
test(
`a flood of server-sent resets via ${name} is answered with GOAWAY(ENHANCE_YOUR_CALM)`,
async () => {
const { c, goaway, handlers, sessionErrorCode } = await flood({
options: { streamResetBurst: 5, streamResetRate: 1 },
count: 1200,
kill,
wait: FULL_BUCKET_WAIT,
});
expect(goawayErrorCode(goaway)).toBe(ErrorCode.ENHANCE_YOUR_CALM);
expect(c.frames.filter(f => f.type === FrameType.RST_STREAM).length).toBeGreaterThanOrEqual(1000);
expect(handlers).toBeGreaterThanOrEqual(1000);
expect(sessionErrorCode).toBe("ERR_HTTP2_ERROR");
},
FULL_BUCKET_TIMEOUT,
);
}

// A reset is charged only when it cancels a live stream. nghttp2 charges every RST_STREAM, but
Expand Down Expand Up @@ -2093,18 +2126,22 @@ describe("stream-reset floods (CVE-2023-44487 rapid reset, CVE-2025-8671 MadeYou
});
});

test("a header block refused before dispatch is not charged", async () => {
// maxSessionInvalidFrames and maxSessionRejectedStreams already bound these resets.
const { server, state } = respondingServer({ maxSessionInvalidFrames: 5000, maxSessionRejectedStreams: 5000 });
await withClient(server, async c => {
// A literal "connection: close" field makes the block malformed (RFC 9113 8.2.2).
const field = Buffer.concat([Buffer.from([0x00]), hpackLiteral("connection"), hpackLiteral("close")]);
const malformed = (sid: number) => encodeFrame(FrameType.HEADERS, 0x5, sid, requestHeaderBlock("GET", field));
c.send(Buffer.concat([...Array.from({ length: 1200 }, (_, i) => malformed(1 + 2 * i)), request(2401)]));
expect(await servedBeforeGoaway(c, 2401)).toBe(true);
expect(state.handlers).toBe(1);
});
});
test(
"a header block refused before dispatch is not charged",
async () => {
// maxSessionInvalidFrames and maxSessionRejectedStreams already bound these resets.
const { server, state } = respondingServer({ maxSessionInvalidFrames: 5000, maxSessionRejectedStreams: 5000 });
await withClient(server, async c => {
// A literal "connection: close" field makes the block malformed (RFC 9113 8.2.2).
const field = Buffer.concat([Buffer.from([0x00]), hpackLiteral("connection"), hpackLiteral("close")]);
const malformed = (sid: number) => encodeFrame(FrameType.HEADERS, 0x5, sid, requestHeaderBlock("GET", field));
c.send(Buffer.concat([...Array.from({ length: 1200 }, (_, i) => malformed(1 + 2 * i)), request(2401)]));
expect(await servedBeforeGoaway(c, 2401, FULL_BUCKET_WAIT)).toBe(true);
expect(state.handlers).toBe(1);
});
},
FULL_BUCKET_TIMEOUT,
);

test("resets are not charged once the server has sent its own GOAWAY", async () => {
// nghttp2 stops charging as soon as a local GOAWAY is submitted. The requests and their
Expand Down Expand Up @@ -2134,37 +2171,43 @@ describe("stream-reset floods (CVE-2023-44487 rapid reset, CVE-2025-8671 MadeYou

// The client's GOAWAY(NO_ERROR) makes the server close() the session, which sends the server's
// own GOAWAY. A stream the client holds open keeps the session alive after that.
async function serverGoaway(c: RawH2) {
async function serverGoaway(c: RawH2, wait = 10_000) {
c.sendFrame(FrameType.GOAWAY, 0, 0, Buffer.alloc(8));
expect(goawayErrorCode(await c.waitForGoaway(10_000))).toBe(ErrorCode.NO_ERROR);
expect(goawayErrorCode(await c.waitForGoaway(wait))).toBe(ErrorCode.NO_ERROR);
}

test("resets of streams opened after the server's GOAWAY are charged", async () => {
// nghttp2 can exempt every reset after its GOAWAY because it ignores the streams a client
// opens after that frame. This engine still opens them, so only the earlier streams are exempt.
// It also accepts ids below the held stream's, so the GOAWAY's last stream id is no criterion.
const { server, state } = respondingServer();
const { server, state } = respondingServer({ streamResetBurst: 50, streamResetRate: 1 });
await withClient(server, async c => {
c.send(upload(4001));
await serverGoaway(c);
c.send(pairs(1200, 1, rstStream));
c.send(pairs(100, 1, rstStream));
const goaway = await c.waitFor(calm, 10_000);
expect(goaway.payload.subarray(8).toString()).toBe("too many stream resets");
expect(state.sessionErrorCode).toBe("ERR_HTTP2_ERROR");
});
});

test("server-sent resets stay charged after the server has sent its GOAWAY", async () => {
// Every stream here is older than the GOAWAY. The exemption is for the peer's own resets only.
const { server, state } = respondingServer();
await withClient(server, async c => {
const ids = Array.from({ length: 1300 }, (_, i) => 1 + 2 * i);
c.send(Buffer.concat(ids.map(upload)));
await serverGoaway(c);
c.send(Buffer.concat(ids.map(madeYouReset["WINDOW_UPDATE with a 0 increment"])));
const goaway = await c.waitFor(calm, 10_000);
expect(goaway.payload.subarray(8).toString()).toBe("too many stream resets");
expect(state.sessionErrorCode).toBe("ERR_HTTP2_ERROR");
});
});
test(
"server-sent resets stay charged after the server has sent its GOAWAY",
async () => {
// Every stream here is older than the GOAWAY. The exemption is for the peer's own resets only.
const { server, state } = respondingServer();
await withClient(server, async c => {
// The streams have to be older than the GOAWAY, so this flood cannot be written once more.
// On a debug build it carries the 33 tokens per second that the bucket regains in a wait.
const ids = Array.from({ length: isDebug ? 1700 : 1300 }, (_, i) => 1 + 2 * i);
c.send(Buffer.concat(ids.map(upload)));
await serverGoaway(c, FULL_BUCKET_WAIT);
c.send(Buffer.concat(ids.map(madeYouReset["WINDOW_UPDATE with a 0 increment"])));
const goaway = await c.waitFor(calm, FULL_BUCKET_WAIT);
expect(goaway.payload.subarray(8).toString()).toBe("too many stream resets");
expect(state.sessionErrorCode).toBe("ERR_HTTP2_ERROR");
});
},
FULL_BUCKET_TIMEOUT,
);
});
Loading