fix(integrations): bound the Dawarich and Nightscout answers while reading

This commit is contained in:
Marc-André Bombeck committed 2026-10-16 05:28:07 +02:00
1 parent 1a7050d95d
commit c777819b33
4 files changed
+94 -7

No files matched your search

@@ -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<typeof import("@/lib/safe-fetch")>()),
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<Uint8Array>({
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);
});
});
+17 -2
View File
@@ -5,7 +5,9 @@
* reads four endpoints, all with `Authorization: Bearer <api key>`:
*
* 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);
}
+26 -4
View File
@@ -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<Uint8Array>({
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);
});
});
+9 -1
View File
@@ -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);
}