mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-18 19:50:38 +09:00
Guard dev health JSON parsing
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
bfa60338cc
commit
9a8a169e95
7 changed files with 122 additions and 8 deletions
|
|
@ -1,7 +1,12 @@
|
||||||
const DEFAULT_CAPTURED_OUTPUT_BYTES = 256 * 1024;
|
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) {
|
export function createCapturedOutputBuffer(maxBytes = DEFAULT_CAPTURED_OUTPUT_BYTES) {
|
||||||
const limit = Math.max(1, Math.trunc(maxBytes));
|
const limit = normalizeByteLimit(maxBytes);
|
||||||
const chunks = [];
|
const chunks = [];
|
||||||
let bufferedBytes = 0;
|
let bufferedBytes = 0;
|
||||||
let totalBytes = 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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
const DEFAULT_CAPTURED_OUTPUT_BYTES = 256 * 1024;
|
const DEFAULT_CAPTURED_OUTPUT_BYTES = 256 * 1024;
|
||||||
|
const DEFAULT_JSON_RESPONSE_BYTES = 64 * 1024;
|
||||||
|
|
||||||
export type CapturedOutput = {
|
export type CapturedOutput = {
|
||||||
text: string;
|
text: string;
|
||||||
|
|
@ -6,8 +7,12 @@ export type CapturedOutput = {
|
||||||
totalBytes: number;
|
totalBytes: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function normalizeByteLimit(maxBytes: number) {
|
||||||
|
return Math.max(1, Math.trunc(maxBytes));
|
||||||
|
}
|
||||||
|
|
||||||
export function createCapturedOutputBuffer(maxBytes = DEFAULT_CAPTURED_OUTPUT_BYTES) {
|
export function createCapturedOutputBuffer(maxBytes = DEFAULT_CAPTURED_OUTPUT_BYTES) {
|
||||||
const limit = Math.max(1, Math.trunc(maxBytes));
|
const limit = normalizeByteLimit(maxBytes);
|
||||||
const chunks: Buffer[] = [];
|
const chunks: Buffer[] = [];
|
||||||
let bufferedBytes = 0;
|
let bufferedBytes = 0;
|
||||||
let totalBytes = 0;
|
let totalBytes = 0;
|
||||||
|
|
@ -57,3 +62,41 @@ export function createCapturedOutputBuffer(maxBytes = DEFAULT_CAPTURED_OUTPUT_BY
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function parseJsonResponseWithLimit<T>(
|
||||||
|
response: Response,
|
||||||
|
maxBytes = DEFAULT_JSON_RESPONSE_BYTES,
|
||||||
|
): Promise<T> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import path from "node:path";
|
||||||
import { createInterface } from "node:readline/promises";
|
import { createInterface } from "node:readline/promises";
|
||||||
import { stdin, stdout } from "node:process";
|
import { stdin, stdout } from "node:process";
|
||||||
import { fileURLToPath } from "node:url";
|
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";
|
import { shouldTrackDevServerPath } from "./dev-runner-paths.mjs";
|
||||||
|
|
||||||
const mode = process.argv[2] === "watch" ? "watch" : "dev";
|
const mode = process.argv[2] === "watch" ? "watch" : "dev";
|
||||||
|
|
@ -430,7 +430,7 @@ async function getDevHealthPayload() {
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Health request failed (${response.status})`);
|
throw new Error(`Health request failed (${response.status})`);
|
||||||
}
|
}
|
||||||
return await response.json();
|
return await parseJsonResponseWithLimit(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function waitForChildExit() {
|
async function waitForChildExit() {
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { existsSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } f
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { createInterface } from "node:readline/promises";
|
import { createInterface } from "node:readline/promises";
|
||||||
import { stdin, stdout } from "node:process";
|
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 { shouldTrackDevServerPath } from "./dev-runner-paths.mjs";
|
||||||
import { createDevServiceIdentity, repoRoot } from "./dev-service-profile.ts";
|
import { createDevServiceIdentity, repoRoot } from "./dev-service-profile.ts";
|
||||||
import {
|
import {
|
||||||
|
|
@ -487,7 +487,7 @@ async function getDevHealthPayload() {
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Health request failed (${response.status})`);
|
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() {
|
async function waitForChildExit() {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, expect, it } from "vitest";
|
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", () => {
|
describe("createCapturedOutputBuffer", () => {
|
||||||
it("keeps small output unchanged", () => {
|
it("keeps small output unchanged", () => {
|
||||||
|
|
@ -26,4 +26,20 @@ describe("createCapturedOutputBuffer", () => {
|
||||||
expect(result.text).toContain("total 12 bytes");
|
expect(result.text).toContain("total 12 bytes");
|
||||||
expect(result.text.endsWith("efghijkl")).toBe(true);
|
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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -63,4 +63,14 @@ describe("dev server status helpers", () => {
|
||||||
waitingForIdle: true,
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
export type PersistedDevServerStatus = {
|
||||||
dirty: boolean;
|
dirty: boolean;
|
||||||
|
|
@ -44,6 +46,9 @@ export function readPersistedDevServerStatus(
|
||||||
if (!filePath || !existsSync(filePath)) return null;
|
if (!filePath || !existsSync(filePath)) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (statSync(filePath).size > MAX_PERSISTED_DEV_SERVER_STATUS_BYTES) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const raw = JSON.parse(readFileSync(filePath, "utf8")) as Record<string, unknown>;
|
const raw = JSON.parse(readFileSync(filePath, "utf8")) as Record<string, unknown>;
|
||||||
const changedPathsSample = normalizeStringArray(raw.changedPathsSample).slice(0, 5);
|
const changedPathsSample = normalizeStringArray(raw.changedPathsSample).slice(0, 5);
|
||||||
const pendingMigrations = normalizeStringArray(raw.pendingMigrations);
|
const pendingMigrations = normalizeStringArray(raw.pendingMigrations);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue