fix(planner/brouter): cap upstream response size to 10MB
Planner-audit #9. \`fetchSegment\` previously \`await response.json()\` on any body BRouter returned. A misbehaving or compromised upstream could OOM the planner process by returning gigabytes of JSON. New \`readBodyWithCap()\`: - Reject upfront when \`content-length\` declares over the cap. - Stream the body and abort the reader once received bytes exceed the cap (handles the case where upstream lies about content-length or omits it). Cap chosen at 10 MB — real per-segment GeoJSON is <100 KB; even the longest realistic multi-day route stays well under 2 MB. Above 10 MB is upstream bug or abuse; we'd rather error. Applied to both \`fetchSegment\` (GeoJSON path) and \`computeSegmentGpx\` (GPX path). Tests: 3 cases (within cap, content-length over cap, streamed body mid-cap abort).
This commit is contained in:
parent
11edcbccb3
commit
6f2b4450df
2 changed files with 76 additions and 2 deletions
|
|
@ -315,3 +315,33 @@ describe("waypoint to BRouter segments", () => {
|
|||
expect(pairs).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readBodyWithCap", () => {
|
||||
// We import lazily so the rest of brouter.ts's module-level env
|
||||
// checks don't interfere with the other test files.
|
||||
it("returns the body when within the cap", async () => {
|
||||
const { readBodyWithCap } = await import("./brouter.ts");
|
||||
const resp = new Response("hello");
|
||||
expect(await readBodyWithCap(resp, 1000)).toBe("hello");
|
||||
});
|
||||
|
||||
it("rejects upfront when content-length declares over the cap", async () => {
|
||||
const { readBodyWithCap } = await import("./brouter.ts");
|
||||
const resp = new Response("x", { headers: { "content-length": "99999" } });
|
||||
await expect(readBodyWithCap(resp, 100)).rejects.toThrow(/response too large/);
|
||||
});
|
||||
|
||||
it("aborts mid-stream when bytes exceed the cap (no content-length lie)", async () => {
|
||||
const { readBodyWithCap } = await import("./brouter.ts");
|
||||
// Build a streaming response that emits two chunks; cap between them.
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode("a".repeat(60)));
|
||||
controller.enqueue(new TextEncoder().encode("b".repeat(60)));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const resp = new Response(body);
|
||||
await expect(readBodyWithCap(resp, 100)).rejects.toThrow(/exceeded/);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -63,13 +63,55 @@ export class BRouterError extends Error {
|
|||
}
|
||||
}
|
||||
|
||||
// Refuse to consume responses larger than this from BRouter. Real
|
||||
// per-segment GeoJSON is typically <100 KB; even a long pan-Europe
|
||||
// segment with surface metadata stays under 2 MB. 10 MB is the
|
||||
// "definitely abuse or upstream bug" threshold — beyond that we'd
|
||||
// rather error than OOM.
|
||||
const MAX_BROUTER_RESPONSE_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
// Exported only for tests — see brouter.test.ts.
|
||||
export async function readBodyWithCap(response: Response, maxBytes: number): Promise<string> {
|
||||
// `content-length` is advisory (BRouter sets it; a misbehaving
|
||||
// upstream might not). Reject up front if the declared length is
|
||||
// already over the cap.
|
||||
const declared = Number(response.headers.get("content-length"));
|
||||
if (Number.isFinite(declared) && declared > maxBytes) {
|
||||
throw new BRouterError(`response too large (${declared} bytes)`, 502);
|
||||
}
|
||||
// Stream the body, abort once we've seen `maxBytes`.
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) return await response.text();
|
||||
const decoder = new TextDecoder();
|
||||
let received = 0;
|
||||
let out = "";
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
received += value.byteLength;
|
||||
if (received > maxBytes) {
|
||||
try { await reader.cancel(); } catch { /* ignore */ }
|
||||
throw new BRouterError(`response exceeded ${maxBytes} bytes`, 502);
|
||||
}
|
||||
out += decoder.decode(value, { stream: true });
|
||||
}
|
||||
out += decoder.decode();
|
||||
return out;
|
||||
}
|
||||
|
||||
async function fetchSegment(url: string): Promise<Record<string, unknown>> {
|
||||
const response = await fetchWithTimeout(url, { headers: authHeaders() });
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
throw new BRouterError(body.trim(), response.status);
|
||||
}
|
||||
return response.json() as Promise<Record<string, unknown>>;
|
||||
const raw = await readBodyWithCap(response, MAX_BROUTER_RESPONSE_BYTES);
|
||||
try {
|
||||
return JSON.parse(raw) as Record<string, unknown>;
|
||||
} catch {
|
||||
throw new BRouterError("invalid JSON from upstream", 502);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -152,7 +194,9 @@ export async function computeSegmentGpx(request: {
|
|||
const body = await resp.text();
|
||||
throw new BRouterError(body.trim(), resp.status);
|
||||
}
|
||||
const gpx = await resp.text();
|
||||
// Same size cap as the GeoJSON path — GPX for a multi-day route
|
||||
// stays well under 10 MB.
|
||||
const gpx = await readBodyWithCap(resp, MAX_BROUTER_RESPONSE_BYTES);
|
||||
if (!gpx.includes("<trkpt")) throw new BRouterError("no route found", 422);
|
||||
return gpx;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue