diff --git a/scripts/private-vulnerability-reporting-audit.mjs b/scripts/private-vulnerability-reporting-audit.mjs old mode 100755 new mode 100644 index 428b0c68e..0468ada9d --- a/scripts/private-vulnerability-reporting-audit.mjs +++ b/scripts/private-vulnerability-reporting-audit.mjs @@ -29,14 +29,25 @@ function bound(value, limit = MAX_ERROR_CHARS) { /** * Read a GitHub JSON response without buffering an unbounded remote body. * + * The response must authenticate itself as JSON before any body bytes are read. * The declared length is rejected before reading when it already exceeds the * acquisition-evidence budget. The stream is then counted by received bytes so * a missing or dishonest Content-Length cannot bypass the same limit. * - * @param {Response} response GitHub response whose body is untrusted input. + * @param {Response} response GitHub response whose headers and body are untrusted input. * @returns {Promise} Parsed JSON contained within the response budget. */ export async function readBoundedJson(response) { + const mediaType = (response.headers.get("content-type") ?? "") + .split(";", 1)[0] + .trim() + .toLowerCase(); + if (mediaType !== "application/json" && mediaType !== "application/vnd.github+json") { + throw new Error( + "GitHub private vulnerability reporting response Content-Type must be application/json.", + ); + } + const declaredLengthHeader = response.headers.get("content-length"); const declaredLength = declaredLengthHeader === null ? null : Number(declaredLengthHeader); if ( diff --git a/test/private-vulnerability-reporting-adapter.test.ts b/test/private-vulnerability-reporting-adapter.test.ts index d468a4b6a..04425c6fe 100644 --- a/test/private-vulnerability-reporting-adapter.test.ts +++ b/test/private-vulnerability-reporting-adapter.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it } from "vitest"; import { readBoundedJson } from "../scripts/private-vulnerability-reporting-audit.mjs"; -function streamedResponse(chunks: Uint8Array[], contentLength?: string): Response { +function streamedResponse( + chunks: Uint8Array[], + contentLength?: string, + contentType = "application/json; charset=utf-8", +): Response { const body = new ReadableStream({ start(controller) { for (const chunk of chunks) { @@ -10,7 +14,7 @@ function streamedResponse(chunks: Uint8Array[], contentLength?: string): Respons controller.close(); }, }); - const headers = new Headers(); + const headers = new Headers({ "content-type": contentType }); if (contentLength !== undefined) { headers.set("content-length", contentLength); } @@ -23,8 +27,14 @@ function streamedResponse(chunks: Uint8Array[], contentLength?: string): Respons } as unknown as Response; } -function responseWhoseBodyMustNotBeRead(contentLength: string): Response { - const headers = new Headers({ "content-length": contentLength }); +function responseWhoseBodyMustNotBeRead( + contentLength: string, + contentType = "application/json", +): Response { + const headers = new Headers({ + "content-length": contentLength, + "content-type": contentType, + }); return Object.defineProperties({}, { headers: { value: headers, enumerable: true }, body: { @@ -43,6 +53,35 @@ describe("private vulnerability reporting GitHub response adapter", () => { await expect(readBoundedJson(streamedResponse([bytes]))).resolves.toEqual({ enabled: true }); }); + it("accepts GitHub JSON media types case-insensitively with ordinary parameters", async () => { + const bytes = new TextEncoder().encode('{"enabled":true}'); + + await expect( + readBoundedJson(streamedResponse([bytes], undefined, "Application/Vnd.Github+Json; charset=utf-8")), + ).resolves.toEqual({ enabled: true }); + }); + + it("rejects JSON-looking bytes under a misleading non-JSON media type before reading the body", async () => { + const response = responseWhoseBodyMustNotBeRead( + "16", + "text/plain; profile=application/json", + ); + + await expect(readBoundedJson(response)).rejects.toThrow( + "GitHub private vulnerability reporting response Content-Type must be application/json.", + ); + }); + + it("rejects a missing response media type before accepting JSON-looking bytes", async () => { + const bytes = new TextEncoder().encode('{"enabled":true}'); + const response = streamedResponse([bytes]); + response.headers.delete("content-type"); + + await expect(readBoundedJson(response)).rejects.toThrow( + "GitHub private vulnerability reporting response Content-Type must be application/json.", + ); + }); + it("rejects oversized streamed responses before accepting their payload", async () => { const firstChunk = new Uint8Array(9_000).fill(0x20); const secondChunk = new Uint8Array(9_000).fill(0x20);