From 4aa9d03c4f5bc827c6c516e84241ffa791fceb74 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Fri, 13 Nov 2026 07:03:30 +0000 Subject: [PATCH] Consent bomb test: pin the inflate output cap, not a wall-clock bound The 1500 ms bound measured 3330 ms under parallel load. What the test proves is that a decompression bomb is read under the 16 KB cap; record the inflate options and assert maxOutputLength directly. Mutant (drop the cap) fails the test. Co-Authored-By: Claude Sonnet 5.5 --- tests/joystick-consent.test.ts | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/tests/joystick-consent.test.ts b/tests/joystick-consent.test.ts index 79cc512..4801692 100644 --- a/tests/joystick-consent.test.ts +++ b/tests/joystick-consent.test.ts @@ -11,6 +11,20 @@ import { providerCatalog } from "../src/providers.js"; import { InMemoryStore } from "../src/store/index.js"; import { createTestConfig, setCookieOf, stateOf } from "./oauth-flow.js"; +// Records the options of every inflate the app runs, so the bomb test can pin the output cap +// itself instead of timing a refusal. Behaviour is the real inflate. +const inflateCalls = vi.hoisted(() => [] as Array<{ maxOutputLength: number | undefined }>); +vi.mock("node:zlib", async (importOriginal) => { + const real = await importOriginal(); + return { + ...real, + inflateRawSync: ((buf: Parameters[0], opts?: Parameters[1]) => { + inflateCalls.push({ maxOutputLength: opts?.maxOutputLength }); + return real.inflateRawSync(buf, opts); + }) as typeof real.inflateRawSync, + }; +}); + // The consent cut for managed custody (docs/joystick-consent-flow.md): the ask, // the typed refusal, the receipt, and the signed-in page that lists and revokes. @@ -220,7 +234,7 @@ describe("the ask", () => { const signedAsk = (ctx: ReturnType, jti: string, exp: number, text: Buffer) => signJwt({ typ: "heimdall_consent_ask", iss: ctx.config.issuer, jti, iat: exp - 600, exp, ct: deflateRawSync(text).toString("base64url") }, ctx.keys); - it("is read up to 16 KB and no further: one byte over is refused, and a bomb is refused quickly", async () => { + it("is read up to 16 KB and no further: one byte over is refused, and a bomb is inflated under that cap", async () => { const { app } = await newApp(); const ctx = getHeimdallRuntimeContext(app); const state = await startConnect(app); @@ -228,9 +242,12 @@ describe("the ask", () => { const answerWith = (text: Buffer, decision: "decline" | "continue" = "decline") => answer(app, state, decision, `__Host-heimdall-ask-${jti}=${signedAsk(ctx, jti, exp, text)}`, "application/json"); expect((await answerWith(Buffer.alloc(16 * 1024 + 1, 97))).json()).toEqual({ error: "state_not_bound" }); - const t0 = Date.now(); - expect((await answerWith(Buffer.alloc(300 * 1024 * 1024))).json()).toEqual({ error: "state_not_bound" }); - expect(Date.now() - t0).toBeLessThan(1500); + // A bomb: far past the cap once inflated. It is refused, and the inflate that refused it was + // handed the cap, so it never produced more than 16 KB (the property, not a wall-clock proxy). + inflateCalls.length = 0; + expect((await answerWith(Buffer.alloc(4 * 1024 * 1024))).json()).toEqual({ error: "state_not_bound" }); + expect(inflateCalls.length).toBeGreaterThan(0); + expect(inflateCalls.every((c) => c.maxOutputLength !== undefined && c.maxOutputLength <= 16 * 1024)).toBe(true); // At the cap it is read. expect((await answerWith(Buffer.alloc(16 * 1024, 97))).json()).toMatchObject({ error: "consent_refused" }); }); -- 2.47.3