From 7aca070723231bd30d1eb9c36d0fa35c0013f98a Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 29 May 2026 20:41:07 +0200 Subject: [PATCH] fix(scripts): cap gh-read json bodies --- scripts/gh-read.ts | 17 +++++++++++- test/scripts/gh-read.test.ts | 54 ++++++++++++++++++++++++++++++++---- 2 files changed, 65 insertions(+), 6 deletions(-) diff --git a/scripts/gh-read.ts b/scripts/gh-read.ts index 6b6952888c1..f8c17091da5 100644 --- a/scripts/gh-read.ts +++ b/scripts/gh-read.ts @@ -2,6 +2,7 @@ import { execFileSync, spawnSync } from "node:child_process"; import { createPrivateKey, createSign } from "node:crypto"; import { readFileSync } from "node:fs"; import { pathToFileURL } from "node:url"; +import { readBoundedResponseText } from "./lib/bounded-response.ts"; import { parseStrictIntegerOption } from "./lib/dev-tooling-safety.ts"; const APP_ID_ENV = "OPENCLAW_GH_READ_APP_ID"; @@ -11,6 +12,7 @@ const PERMISSIONS_ENV = "OPENCLAW_GH_READ_PERMISSIONS"; const API_VERSION = "2022-11-28"; const DEFAULT_GITHUB_FETCH_TIMEOUT_MS = 30_000; const GITHUB_ERROR_BODY_MAX_CHARS = 4096; +const GITHUB_JSON_BODY_MAX_BYTES = 1024 * 1024; const DEFAULT_READ_PERMISSION_KEYS = [ "actions", "checks", @@ -230,6 +232,19 @@ export async function readBoundedGitHubErrorText( return truncated ? `${text}\n[truncated]` : text; } +export async function readBoundedGitHubJson( + response: Response, + maxBytes = GITHUB_JSON_BODY_MAX_BYTES, +): Promise { + const text = await readBoundedResponseText(response, "GitHub API", maxBytes, { + createTooLargeError: (message) => + Object.assign(new Error(message), { + code: "ETOOBIG", + }), + }); + return JSON.parse(text) as T; +} + export async function githubJson( path: string, bearerToken: string, @@ -263,7 +278,7 @@ export async function githubJson( fail(`${init?.method ?? "GET"} ${path} failed (${response.status}): ${text}`); } - return (await response.json()) as T; + return await readBoundedGitHubJson(response); }, ); } diff --git a/test/scripts/gh-read.test.ts b/test/scripts/gh-read.test.ts index 6876c62826c..42e1b8a1c81 100644 --- a/test/scripts/gh-read.test.ts +++ b/test/scripts/gh-read.test.ts @@ -6,6 +6,7 @@ import { parsePermissionKeys, parseRepoArg, readBoundedGitHubErrorText, + readBoundedGitHubJson, resolveGitHubFetchTimeoutMs, } from "../../scripts/gh-read.js"; @@ -68,11 +69,7 @@ describe("gh-read helpers", () => { }); it("times out stalled GitHub API response body reads", async () => { - const response = { - ok: true, - status: 200, - json: () => new Promise(() => {}), - } as Response; + const response = new Response(new ReadableStream({}), { status: 200 }); const request = githubJson("/app/installations", "token", undefined, { timeoutMs: 5, fetchImpl: (() => Promise.resolve(response)) as typeof fetch, @@ -94,6 +91,53 @@ describe("gh-read helpers", () => { expect(text.length).toBeLessThan(4200); }); + it("reads bounded GitHub API JSON responses", async () => { + await expect(readBoundedGitHubJson(new Response('{"id":123}'), 1024)).resolves.toEqual({ + id: 123, + }); + }); + + it("rejects oversized GitHub API JSON responses by content length", async () => { + let canceled = false; + const response = new Response( + new ReadableStream({ + cancel() { + canceled = true; + }, + }), + { + headers: { + "content-length": "1025", + }, + }, + ); + + await expect(readBoundedGitHubJson(response, 1024)).rejects.toMatchObject({ + code: "ETOOBIG", + message: "GitHub API response body exceeded 1024 bytes", + }); + expect(canceled).toBe(true); + }); + + it("rejects oversized streamed GitHub API JSON responses", async () => { + const encoder = new TextEncoder(); + const response = new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('{"body":"')); + controller.enqueue(encoder.encode("x".repeat(1024))); + controller.enqueue(encoder.encode('"}')); + controller.close(); + }, + }), + ); + + await expect(readBoundedGitHubJson(response, 1024)).rejects.toMatchObject({ + code: "ETOOBIG", + message: "GitHub API response body exceeded 1024 bytes", + }); + }); + it("rejects invalid GitHub API timeout values", () => { expect(resolveGitHubFetchTimeoutMs("1000")).toBe(1000); expect(() => resolveGitHubFetchTimeoutMs("1s")).toThrow(