From c777819b337ad4fdf9608299f9e12e01679a9894 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 16 Oct 2026 05:28:07 +0200 Subject: [PATCH] fix(integrations): bound the Dawarich and Nightscout answers while reading --- .../__tests__/client-body-cap.test.ts | 42 +++++++++++++++++++ src/lib/dawarich/client.ts | 19 ++++++++- src/lib/nightscout/__tests__/client.test.ts | 30 +++++++++++-- src/lib/nightscout/client.ts | 10 ++++- 4 files changed, 94 insertions(+), 7 deletions(-) create mode 100644 src/lib/dawarich/__tests__/client-body-cap.test.ts diff --git a/src/lib/dawarich/__tests__/client-body-cap.test.ts b/src/lib/dawarich/__tests__/client-body-cap.test.ts new file mode 100644 index 000000000..19c9fe2e4 --- /dev/null +++ b/src/lib/dawarich/__tests__/client-body-cap.test.ts @@ -0,0 +1,42 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const safeFetchMock = vi.hoisted(() => vi.fn()); +vi.mock("@/lib/safe-fetch", async (importOriginal) => ({ + ...(await importOriginal()), + safeFetch: safeFetchMock, +})); + +import { DawarichApiError, probeDawarich } from "../client"; + +const CONN = { baseUrl: "https://dawarich.example.com", apiKey: "key" }; + +beforeEach(() => { + safeFetchMock.mockReset(); +}); + +describe("reading a Dawarich answer", () => { + it("parses an honest answer", async () => { + safeFetchMock.mockResolvedValue( + new Response(JSON.stringify({ user: { id: 1 } }), { status: 200 }), + ); + await expect(probeDawarich(CONN)).resolves.toBeUndefined(); + }); + + it("stops reading an answer that runs past the cap", async () => { + // Streamed without a length, so only the running count can catch it. + const chunk = new Uint8Array(1024 * 1024).fill(32); + let sent = 0; + const body = new ReadableStream({ + pull(controller) { + sent += 1; + if (sent > 64) controller.close(); + else controller.enqueue(chunk); + }, + }); + safeFetchMock.mockResolvedValue(new Response(body, { status: 200 })); + const failure = await probeDawarich(CONN).catch((e: unknown) => e); + expect(failure).toBeInstanceOf(DawarichApiError); + expect((failure as Error).message).toMatch(/oversized/); + expect(sent).toBeLessThan(64); + }); +}); diff --git a/src/lib/dawarich/client.ts b/src/lib/dawarich/client.ts index e90f5d06b..68fe4e62b 100644 --- a/src/lib/dawarich/client.ts +++ b/src/lib/dawarich/client.ts @@ -5,7 +5,9 @@ * reads four endpoints, all with `Authorization: Bearer `: * * GET /api/v1/timeline?start_at&end_at&distance_unit=km - * one entry per local day (the instance's own zone) with a `summary`: + * one entry per day in the Dawarich account's own zone (its timezone + * setting, the instance's `TIME_ZONE` by default; the request cannot + * name one), summing only what lies inside the window, with a `summary`: * `total_distance`, `places_visited`, `mode_distances.{driving,train}` * in km to one decimal. At most 31 days per request. * GET /api/v1/visits?start_at&end_at @@ -26,6 +28,7 @@ * `DAWARICH_PRIVATE_ORIGINS`. The policy is re-read on every call, so taking * an origin off the list stops the next request. */ +import { readBodyCapped } from "@/lib/http/read-capped"; import { SafeFetchError, safeFetch } from "@/lib/safe-fetch"; import { @@ -35,6 +38,14 @@ import { const DEFAULT_TIMEOUT_MS = 20_000; +/** + * The most any one answer may hold. A month of timeline with its entries and + * a year of residency stay far below it; the cap is what keeps an instance + * that streams without end from holding the worker's memory until the + * timeout. + */ +const MAX_BODY_BYTES = 16 * 1024 * 1024; + /** Dawarich refuses a timeline window longer than 31 days. */ export const TIMELINE_MAX_DAYS = 31; @@ -118,8 +129,12 @@ async function dawarichGet( if (res.status < 200 || res.status >= 300) { throw new DawarichApiError(`Dawarich responded ${res.status}`, res.status); } + const read = await readBodyCapped(res, MAX_BODY_BYTES); + if (!read.ok) { + throw new DawarichApiError("Dawarich returned an oversized body", null); + } try { - return await res.json(); + return JSON.parse(read.text) as unknown; } catch { throw new DawarichApiError("Dawarich returned a non-JSON body", null); } diff --git a/src/lib/nightscout/__tests__/client.test.ts b/src/lib/nightscout/__tests__/client.test.ts index 7005a56e3..1a08b9004 100644 --- a/src/lib/nightscout/__tests__/client.test.ts +++ b/src/lib/nightscout/__tests__/client.test.ts @@ -18,11 +18,10 @@ import { } from "../client"; function jsonResponse(status: number, body: unknown): Response { - return { + return new Response(JSON.stringify(body), { status, - ok: status >= 200 && status < 300, - json: async () => body, - } as unknown as Response; + headers: { "content-type": "application/json" }, + }); } const SAMPLE: NightscoutSgvEntry[] = [ @@ -295,4 +294,27 @@ describe("fetchSgvEntries — SSRF gating", () => { expect(entries).toHaveLength(2); expect(entries[0]!.sgv).toBe(112); }); + + it("stops reading an answer that runs past the cap", async () => { + // Streamed without a length, so only the running count can catch it. + const chunk = new Uint8Array(1024 * 1024).fill(32); + let sent = 0; + const body = new ReadableStream({ + pull(controller) { + sent += 1; + if (sent > 64) controller.close(); + else controller.enqueue(chunk); + }, + }); + safeFetchMock.mockResolvedValue(new Response(body, { status: 200 })); + await expect( + fetchSgvEntries({ + baseUrl: "https://ns.example.com", + token: "", + count: 10, + allowPrivateHost: false, + }), + ).rejects.toThrow(/oversized/); + expect(sent).toBeLessThan(64); + }); }); diff --git a/src/lib/nightscout/client.ts b/src/lib/nightscout/client.ts index e41be4bcd..acaf4454b 100644 --- a/src/lib/nightscout/client.ts +++ b/src/lib/nightscout/client.ts @@ -23,6 +23,7 @@ import { createHash } from "node:crypto"; import type { MeasurementType } from "@/generated/prisma/client"; +import { readBodyCapped } from "@/lib/http/read-capped"; import { safeFetch } from "@/lib/safe-fetch"; import { configuredNightscoutPrivateOrigins, @@ -33,6 +34,7 @@ import { SafeFetchError } from "@/lib/safe-fetch"; /** Default per-request timeout for a Nightscout fetch. */ const NIGHTSCOUT_TIMEOUT_MS = 15_000; +const NIGHTSCOUT_MAX_BODY_BYTES = 8 * 1024 * 1024; /** Canonical glucose storage unit. Nightscout SGV is always mg/dL on the wire. */ export const NIGHTSCOUT_GLUCOSE_UNIT = "mg/dL" as const; @@ -259,9 +261,15 @@ export async function fetchSgvEntries( ); } + // Bounded while reading: `count` entries are a few hundred bytes each, so + // the cap is far above any honest answer and stops one that never ends. + const read = await readBodyCapped(res, NIGHTSCOUT_MAX_BODY_BYTES); + if (!read.ok) { + throw new NightscoutApiError("Nightscout returned an oversized body", null); + } let body: unknown; try { - body = await res.json(); + body = JSON.parse(read.text) as unknown; } catch { throw new NightscoutApiError("Nightscout returned a non-JSON body", null); }