diff --git a/src/lib/nightscout/__tests__/sync.test.ts b/src/lib/nightscout/__tests__/sync.test.ts index f65bedd04..764c0fb71 100644 --- a/src/lib/nightscout/__tests__/sync.test.ts +++ b/src/lib/nightscout/__tests__/sync.test.ts @@ -149,6 +149,34 @@ describe("syncUserNightscout", () => { expect(recordFailureMock.mock.calls[0]![0].kind).toBe("transient"); }); + it("redacts the token from the recorded lastError message", async () => { + // A SafeFetchError embeds the full target URL — including `?token=` + // — in its message; the recorded lastError must never leak it. + fetchSgvEntriesMock.mockRejectedValue( + new Error( + "safeFetch network error: https://ns.example.com/api/v1/entries.json?count=576&type=sgv&token=ns-secret-abc123", + ), + ); + await expect(syncUserNightscout("u1")).rejects.toThrow(); + const message = recordFailureMock.mock.calls[0]![0].message as string; + expect(message).not.toContain("ns-secret-abc123"); + expect(message).toContain("token=REDACTED"); + // The rest of the diagnostic is preserved. + expect(message).toContain("ns.example.com"); + }); + + it("also redacts a classic api-secret query param", async () => { + fetchSgvEntriesMock.mockRejectedValue( + new Error( + "safeFetch refused private or non-public host: https://ns.lan/api/v1/entries.json?api-secret=deadbeef", + ), + ); + await expect(syncUserNightscout("u1")).rejects.toThrow(); + const message = recordFailureMock.mock.calls[0]![0].message as string; + expect(message).not.toContain("deadbeef"); + expect(message).toContain("api-secret=REDACTED"); + }); + it("recomputes rollup buckets for the imported readings", async () => { fetchSgvEntriesMock.mockResolvedValue([ENTRY_A, ENTRY_B]); await syncUserNightscout("u1"); diff --git a/src/lib/nightscout/sync.ts b/src/lib/nightscout/sync.ts index df29b9989..06e5e39c9 100644 --- a/src/lib/nightscout/sync.ts +++ b/src/lib/nightscout/sync.ts @@ -48,6 +48,28 @@ import { getUserNightscoutCredentials } from "./credentials"; */ export const NIGHTSCOUT_SYNC_COUNT = 576; +/** + * Redact the Nightscout auth secret from an error message before it reaches the + * integration ledger / the `/api/nightscout/status` response. + * + * The entries URL carries the access token as a `?token=` query param + * (and a classic instance may carry `api-secret`). A `SafeFetchError` embeds the + * full target URL verbatim in its message — a private-host refusal, a timeout, + * or a network failure would otherwise persist the token into `lastError` and + * echo it back through the status endpoint. Rewrite any URL in the message so + * the secret-bearing params read `=REDACTED`; everything else is preserved so + * the operator still sees a useful diagnostic. + */ +export function redactNightscoutSecret(message: string): string { + // Match `token=...` / `api-secret=...` up to the next param / whitespace / + // quote boundary. Case-insensitive on the key; the value is whatever a URL + // query value can hold short of a delimiter. + return message.replace( + /\b(token|api-secret)=[^\s&"']+/gi, + "$1=REDACTED", + ); +} + /** Map a Nightscout HTTP status / network error onto the shared ledger kind. */ export function classifyNightscoutFailure(err: unknown): FailureKind { if (err instanceof NightscoutApiError && err.status != null) { @@ -85,7 +107,9 @@ export async function syncUserNightscout( userId, integration: "nightscout", kind: classifyNightscoutFailure(err), - message: err instanceof Error ? err.message : String(err), + message: redactNightscoutSecret( + err instanceof Error ? err.message : String(err), + ), errorCode: err instanceof NightscoutApiError && err.status != null ? String(err.status)