From 6f2b4450df13078048003304f4622a3945239be9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Tue, 26 May 2026 00:48:45 +0200 Subject: [PATCH] fix(planner/brouter): cap upstream response size to 10MB MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- apps/planner/app/lib/brouter.test.ts | 30 +++++++++++++++++ apps/planner/app/lib/brouter.ts | 48 ++++++++++++++++++++++++++-- 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/apps/planner/app/lib/brouter.test.ts b/apps/planner/app/lib/brouter.test.ts index a902491..9f3564c 100644 --- a/apps/planner/app/lib/brouter.test.ts +++ b/apps/planner/app/lib/brouter.test.ts @@ -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({ + 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/); + }); +}); diff --git a/apps/planner/app/lib/brouter.ts b/apps/planner/app/lib/brouter.ts index 13aa65d..76b7450 100644 --- a/apps/planner/app/lib/brouter.ts +++ b/apps/planner/app/lib/brouter.ts @@ -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 { + // `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> { 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>; + const raw = await readBodyWithCap(response, MAX_BROUTER_RESPONSE_BYTES); + try { + return JSON.parse(raw) as Record; + } 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("