diff --git a/scripts/github/dependency-guard.mjs b/scripts/github/dependency-guard.mjs index f91e08de608..dcdbcbd7df1 100644 --- a/scripts/github/dependency-guard.mjs +++ b/scripts/github/dependency-guard.mjs @@ -6,6 +6,7 @@ export const dependencyChangeMarker = ""; export const dependencyGraphGuardMarker = ""; export const dependencyChangedLabel = "dependencies-changed"; export const allowDependenciesCommand = "/allow-dependencies-change"; +export const GITHUB_ERROR_BODY_MAX_BYTES = 64 * 1024; const maxListedFiles = 25; const securityTeamSlug = process.env.OPENCLAW_SECURITY_TEAM_SLUG ?? "openclaw-secops"; @@ -312,7 +313,55 @@ export function renderBlockedDependencyComment({ ].join("\n"); } -function githubApi(token) { +function githubErrorBodyTooLarge(maxBytes) { + return new Error(`GitHub error response body exceeded ${maxBytes} bytes`); +} + +export async function readBoundedGitHubErrorText(response, maxBytes = GITHUB_ERROR_BODY_MAX_BYTES) { + const contentLength = Number(response.headers.get("content-length") ?? ""); + if (Number.isSafeInteger(contentLength) && contentLength > maxBytes) { + await response.body?.cancel().catch(() => undefined); + throw githubErrorBodyTooLarge(maxBytes); + } + if (!response.body) { + return ""; + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + const chunks = []; + let totalBytes = 0; + let canceled = false; + + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) { + const tail = decoder.decode(); + if (tail) { + chunks.push(tail); + } + break; + } + + totalBytes += value.byteLength; + if (totalBytes > maxBytes) { + canceled = true; + await reader.cancel().catch(() => undefined); + throw githubErrorBodyTooLarge(maxBytes); + } + chunks.push(decoder.decode(value, { stream: true })); + } + } finally { + if (!canceled) { + reader.releaseLock(); + } + } + + return chunks.join(""); +} + +export function githubApi(token) { const baseHeaders = { accept: "application/vnd.github+json", authorization: `Bearer ${token}`, @@ -328,9 +377,13 @@ function githubApi(token) { return null; } if (!response.ok) { - const error = new Error( - `${response.status} ${response.statusText}: ${await response.text()}`, - ); + let errorText; + try { + errorText = await readBoundedGitHubErrorText(response); + } catch (bodyError) { + errorText = bodyError instanceof Error ? bodyError.message : String(bodyError); + } + const error = new Error(`${response.status} ${response.statusText}: ${errorText}`); error.status = response.status; throw error; } diff --git a/test/scripts/dependency-guard-script.test.ts b/test/scripts/dependency-guard-script.test.ts index cb6d0f606e8..8750c8043ee 100644 --- a/test/scripts/dependency-guard-script.test.ts +++ b/test/scripts/dependency-guard-script.test.ts @@ -1,14 +1,17 @@ import { describe, expect, it } from "vitest"; import { + GITHUB_ERROR_BODY_MAX_BYTES, dependencyGuardCommentHeadSha, dependencyFieldChanges, dependencyOverrideExpectedSha, findDependencyOverrideCommand, findDependencyOverrideCommandAsync, + githubApi, isDependencyGuardAuthorizedForHead, isDependencyFile, isDependencyManifest, isPackageLockfile, + readBoundedGitHubErrorText, renderAuthorizedDependencyComment, renderBlockedDependencyComment, renderClearedDependencyGuardComment, @@ -269,4 +272,54 @@ describe("dependency guard script", () => { expect(sanitizeDisplayValue("abc\u0000def")).toBe("abc?def"); expect(sanitizeDisplayValue("x".repeat(300))).toHaveLength(240); }); + + it("bounds GitHub error bodies by content-length", async () => { + const response = new Response("ignored", { + headers: { "content-length": String(GITHUB_ERROR_BODY_MAX_BYTES + 1) }, + }); + + await expect(readBoundedGitHubErrorText(response)).rejects.toThrow( + `GitHub error response body exceeded ${GITHUB_ERROR_BODY_MAX_BYTES} bytes`, + ); + }); + + it("bounds GitHub error bodies by streamed bytes", async () => { + const response = new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(GITHUB_ERROR_BODY_MAX_BYTES + 1)); + controller.close(); + }, + }), + ); + + await expect(readBoundedGitHubErrorText(response)).rejects.toThrow( + `GitHub error response body exceeded ${GITHUB_ERROR_BODY_MAX_BYTES} bytes`, + ); + }); + + it("preserves GitHub status when an error body exceeds the cap", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (() => + Promise.resolve( + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(GITHUB_ERROR_BODY_MAX_BYTES + 1)); + controller.close(); + }, + }), + { status: 403, statusText: "Forbidden" }, + ), + )) as typeof fetch; + + try { + await expect(githubApi("token").request("/repos/openclaw/openclaw")).rejects.toMatchObject({ + message: `403 Forbidden: GitHub error response body exceeded ${GITHUB_ERROR_BODY_MAX_BYTES} bytes`, + status: 403, + }); + } finally { + globalThis.fetch = originalFetch; + } + }); });