From 9a8a169e95f362f824a592e84a38bbb62ff08379 Mon Sep 17 00:00:00 2001 From: dotta Date: Mon, 6 Apr 2026 20:17:47 -0500 Subject: [PATCH] Guard dev health JSON parsing Co-Authored-By: Paperclip --- scripts/dev-runner-output.mjs | 42 ++++++++++++++++- scripts/dev-runner-output.ts | 45 ++++++++++++++++++- scripts/dev-runner.mjs | 4 +- scripts/dev-runner.ts | 4 +- .../src/__tests__/dev-runner-output.test.ts | 18 +++++++- .../src/__tests__/dev-server-status.test.ts | 10 +++++ server/src/dev-server-status.ts | 7 ++- 7 files changed, 122 insertions(+), 8 deletions(-) diff --git a/scripts/dev-runner-output.mjs b/scripts/dev-runner-output.mjs index bb0d68df..50f5076d 100644 --- a/scripts/dev-runner-output.mjs +++ b/scripts/dev-runner-output.mjs @@ -1,7 +1,12 @@ const DEFAULT_CAPTURED_OUTPUT_BYTES = 256 * 1024; +const DEFAULT_JSON_RESPONSE_BYTES = 64 * 1024; + +function normalizeByteLimit(maxBytes) { + return Math.max(1, Math.trunc(maxBytes)); +} export function createCapturedOutputBuffer(maxBytes = DEFAULT_CAPTURED_OUTPUT_BYTES) { - const limit = Math.max(1, Math.trunc(maxBytes)); + const limit = normalizeByteLimit(maxBytes); const chunks = []; let bufferedBytes = 0; let totalBytes = 0; @@ -51,3 +56,38 @@ export function createCapturedOutputBuffer(maxBytes = DEFAULT_CAPTURED_OUTPUT_BY }, }; } + +export async function parseJsonResponseWithLimit(response, maxBytes = DEFAULT_JSON_RESPONSE_BYTES) { + const limit = normalizeByteLimit(maxBytes); + const contentLength = Number.parseInt(response.headers.get("content-length") ?? "", 10); + if (Number.isFinite(contentLength) && contentLength > limit) { + throw new Error(`Response exceeds ${limit} bytes`); + } + + if (!response.body) { + return JSON.parse(""); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let text = ""; + let totalBytes = 0; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + totalBytes += value.byteLength; + if (totalBytes > limit) { + await reader.cancel("response too large"); + throw new Error(`Response exceeds ${limit} bytes`); + } + text += decoder.decode(value, { stream: true }); + } + text += decoder.decode(); + } finally { + reader.releaseLock(); + } + + return JSON.parse(text); +} diff --git a/scripts/dev-runner-output.ts b/scripts/dev-runner-output.ts index 6fdc8cbd..213dde41 100644 --- a/scripts/dev-runner-output.ts +++ b/scripts/dev-runner-output.ts @@ -1,4 +1,5 @@ const DEFAULT_CAPTURED_OUTPUT_BYTES = 256 * 1024; +const DEFAULT_JSON_RESPONSE_BYTES = 64 * 1024; export type CapturedOutput = { text: string; @@ -6,8 +7,12 @@ export type CapturedOutput = { totalBytes: number; }; +function normalizeByteLimit(maxBytes: number) { + return Math.max(1, Math.trunc(maxBytes)); +} + export function createCapturedOutputBuffer(maxBytes = DEFAULT_CAPTURED_OUTPUT_BYTES) { - const limit = Math.max(1, Math.trunc(maxBytes)); + const limit = normalizeByteLimit(maxBytes); const chunks: Buffer[] = []; let bufferedBytes = 0; let totalBytes = 0; @@ -57,3 +62,41 @@ export function createCapturedOutputBuffer(maxBytes = DEFAULT_CAPTURED_OUTPUT_BY }, }; } + +export async function parseJsonResponseWithLimit( + response: Response, + maxBytes = DEFAULT_JSON_RESPONSE_BYTES, +): Promise { + const limit = normalizeByteLimit(maxBytes); + const contentLength = Number.parseInt(response.headers.get("content-length") ?? "", 10); + if (Number.isFinite(contentLength) && contentLength > limit) { + throw new Error(`Response exceeds ${limit} bytes`); + } + + if (!response.body) { + return JSON.parse("") as T; + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let text = ""; + let totalBytes = 0; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + totalBytes += value.byteLength; + if (totalBytes > limit) { + await reader.cancel("response too large"); + throw new Error(`Response exceeds ${limit} bytes`); + } + text += decoder.decode(value, { stream: true }); + } + text += decoder.decode(); + } finally { + reader.releaseLock(); + } + + return JSON.parse(text) as T; +} diff --git a/scripts/dev-runner.mjs b/scripts/dev-runner.mjs index 8273a54c..4f4f7c90 100644 --- a/scripts/dev-runner.mjs +++ b/scripts/dev-runner.mjs @@ -5,7 +5,7 @@ import path from "node:path"; import { createInterface } from "node:readline/promises"; import { stdin, stdout } from "node:process"; import { fileURLToPath } from "node:url"; -import { createCapturedOutputBuffer } from "./dev-runner-output.mjs"; +import { createCapturedOutputBuffer, parseJsonResponseWithLimit } from "./dev-runner-output.mjs"; import { shouldTrackDevServerPath } from "./dev-runner-paths.mjs"; const mode = process.argv[2] === "watch" ? "watch" : "dev"; @@ -430,7 +430,7 @@ async function getDevHealthPayload() { if (!response.ok) { throw new Error(`Health request failed (${response.status})`); } - return await response.json(); + return await parseJsonResponseWithLimit(response); } async function waitForChildExit() { diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index fc4165b7..756a6b92 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -4,7 +4,7 @@ import { existsSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } f import path from "node:path"; import { createInterface } from "node:readline/promises"; import { stdin, stdout } from "node:process"; -import { createCapturedOutputBuffer } from "./dev-runner-output.mjs"; +import { createCapturedOutputBuffer, parseJsonResponseWithLimit } from "./dev-runner-output.mjs"; import { shouldTrackDevServerPath } from "./dev-runner-paths.mjs"; import { createDevServiceIdentity, repoRoot } from "./dev-service-profile.ts"; import { @@ -487,7 +487,7 @@ async function getDevHealthPayload() { if (!response.ok) { throw new Error(`Health request failed (${response.status})`); } - return await response.json(); + return await parseJsonResponseWithLimit<{ devServer?: { enabled?: boolean; autoRestartEnabled?: boolean; activeRunCount?: number } }>(response); } async function waitForChildExit() { diff --git a/server/src/__tests__/dev-runner-output.test.ts b/server/src/__tests__/dev-runner-output.test.ts index 9e3f49b7..024317a0 100644 --- a/server/src/__tests__/dev-runner-output.test.ts +++ b/server/src/__tests__/dev-runner-output.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { createCapturedOutputBuffer } from "../../../scripts/dev-runner-output.mjs"; +import { createCapturedOutputBuffer, parseJsonResponseWithLimit } from "../../../scripts/dev-runner-output.mjs"; describe("createCapturedOutputBuffer", () => { it("keeps small output unchanged", () => { @@ -26,4 +26,20 @@ describe("createCapturedOutputBuffer", () => { expect(result.text).toContain("total 12 bytes"); expect(result.text.endsWith("efghijkl")).toBe(true); }); + + it("parses bounded JSON responses", async () => { + const response = new Response(JSON.stringify({ ok: true }), { + headers: { "content-type": "application/json" }, + }); + + await expect(parseJsonResponseWithLimit<{ ok: boolean }>(response, 64)).resolves.toEqual({ ok: true }); + }); + + it("rejects oversized JSON responses before parsing them", async () => { + const response = new Response(JSON.stringify({ payload: "x".repeat(128) }), { + headers: { "content-type": "application/json" }, + }); + + await expect(parseJsonResponseWithLimit(response, 32)).rejects.toThrow("Response exceeds 32 bytes"); + }); }); diff --git a/server/src/__tests__/dev-server-status.test.ts b/server/src/__tests__/dev-server-status.test.ts index d178f941..52eef387 100644 --- a/server/src/__tests__/dev-server-status.test.ts +++ b/server/src/__tests__/dev-server-status.test.ts @@ -63,4 +63,14 @@ describe("dev server status helpers", () => { waitingForIdle: true, }); }); + + it("ignores oversized persisted status files", () => { + const filePath = createTempStatusFile({ + dirty: true, + changedPathsSample: ["x".repeat(70 * 1024)], + pendingMigrations: [], + }); + + expect(readPersistedDevServerStatus({ PAPERCLIP_DEV_SERVER_STATUS_FILE: filePath })).toBeNull(); + }); }); diff --git a/server/src/dev-server-status.ts b/server/src/dev-server-status.ts index aecb0fc9..ec78bfe8 100644 --- a/server/src/dev-server-status.ts +++ b/server/src/dev-server-status.ts @@ -1,4 +1,6 @@ -import { existsSync, readFileSync } from "node:fs"; +import { existsSync, readFileSync, statSync } from "node:fs"; + +const MAX_PERSISTED_DEV_SERVER_STATUS_BYTES = 64 * 1024; export type PersistedDevServerStatus = { dirty: boolean; @@ -44,6 +46,9 @@ export function readPersistedDevServerStatus( if (!filePath || !existsSync(filePath)) return null; try { + if (statSync(filePath).size > MAX_PERSISTED_DEV_SERVER_STATUS_BYTES) { + return null; + } const raw = JSON.parse(readFileSync(filePath, "utf8")) as Record; const changedPathsSample = normalizeStringArray(raw.changedPathsSample).slice(0, 5); const pendingMigrations = normalizeStringArray(raw.pendingMigrations);