diff --git a/src/adapters/browser-transfer/image-cdn/browser-image-probe.ts b/src/adapters/browser-transfer/image-cdn/browser-image-probe.ts index 5b91920..ffe972b 100644 --- a/src/adapters/browser-transfer/image-cdn/browser-image-probe.ts +++ b/src/adapters/browser-transfer/image-cdn/browser-image-probe.ts @@ -310,9 +310,13 @@ function validResponseHeaders( ); if (!directives) return false; if (request.delivery === "PRIVATE_SIGNED") { - return ( - directives.get("no-store") === true && - !directives.has("public") + // TR-RR-09. The recorded BT-IMG-02 contract is a fail-closed matrix, not a + // pair of checks: a private response must carry `no-store` and nothing else + // that describes cacheability. Only a syntactically valid unknown extension + // is ignored, so a contradictory pairing can never read as acceptable. + if (directives.get("no-store") !== true) return false; + return !PRIVATE_FORBIDDEN_DIRECTIVES.some((name) => + directives.has(name), ); } const maxAge = directives.get("max-age"); @@ -336,6 +340,21 @@ function validResponseHeaders( ); } +/** + * TR-RR-09. Every cacheability directive a `PRIVATE_SIGNED` response may not + * carry alongside `no-store`. + */ +const PRIVATE_FORBIDDEN_DIRECTIVES: readonly string[] = Object.freeze([ + "public", + "private", + "immutable", + "max-age", + "s-maxage", + "no-cache", + "must-revalidate", + "proxy-revalidate", +]); + /** * BT-IMG-02. Quote- and escape-aware Cache-Control tokenizer. * diff --git a/tests/unit/image-cdn-runtime.test.ts b/tests/unit/image-cdn-runtime.test.ts index 5728c39..89229d5 100644 --- a/tests/unit/image-cdn-runtime.test.ts +++ b/tests/unit/image-cdn-runtime.test.ts @@ -1658,7 +1658,9 @@ describe("browser image probe", () => { responseAt(imageUrl, png, { status: 200, headers: { - "cache-control": "private, no-store", + // TR-RR-09. A private response carries `no-store` and nothing else + // that describes cacheability. + "cache-control": "no-store", "content-type": "image/png", }, }), @@ -1748,6 +1750,67 @@ describe("browser image probe", () => { }); }); + /** + * TR-RR-09. The recorded BT-IMG-02 contract for a private response is a + * fail-closed matrix. Accepting `no-store` next to a directive that describes + * cacheability lets a self-contradictory policy read as acceptable. + */ + it("applies the full private Cache-Control matrix", async () => { + const png = pngBytes(640, 360); + const probeWith = async (cacheControl: string) => { + const probe = createBrowserImageProbe({ + fetcher: (async () => + responseAt(imageUrl, png, { + status: 200, + headers: { + "cache-control": cacheControl, + "content-type": "image/png", + }, + })) as typeof fetch, + createBitmap: async () => ({ + width: 640, + height: 360, + close: vi.fn(), + }), + }); + return await probe.probe( + request({ + delivery: "PRIVATE_SIGNED", + minimumPublicMaxAgeSeconds: 0, + }), + ); + }; + + // Only `no-store`, plus a syntactically valid unknown extension. + expect(await probeWith("no-store")).toMatchObject({ ok: true }); + expect(await probeWith('no-store, x-vendor="a,b"')).toMatchObject({ + ok: true, + }); + + for (const companion of [ + "public", + "private", + "immutable", + "max-age=60", + "s-maxage=60", + "no-cache", + "must-revalidate", + "proxy-revalidate", + ]) { + expect(await probeWith(`no-store, ${companion}`)).toMatchObject({ + ok: false, + error: { code: "POLICY_REJECTED" }, + }); + } + + for (const withoutNoStore of ["private", "no-cache", "max-age=0"]) { + expect(await probeWith(withoutNoStore)).toMatchObject({ + ok: false, + error: { code: "POLICY_REJECTED" }, + }); + } + }); + it("times out a stalled body, aborts the composed signal and cancels its reader", async () => { const manual = manualImageProbeScheduler(); const cancel = vi.fn(async () => undefined);