From f95da50ba07880ce2836e770830e13bc35c5683d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 13:57:07 +0200 Subject: [PATCH] fix(01-07): enforce exact Nightscout origins - move private-host authority to the server-owned exact-origin policy - preserve stable redacted policy failures through connect and sync --- src/app/api/nightscout/connect/route.ts | 43 +++++-- src/app/api/nightscout/sync/route.ts | 14 +- src/lib/nightscout/client.ts | 75 ++++++++--- src/lib/nightscout/credentials.ts | 1 + src/lib/nightscout/sync.ts | 31 ++++- src/lib/validations/nightscout.ts | 162 ++++++++++++++++++++++-- 6 files changed, 270 insertions(+), 56 deletions(-) diff --git a/src/app/api/nightscout/connect/route.ts b/src/app/api/nightscout/connect/route.ts index 6108e05cc..57a221e2d 100644 --- a/src/app/api/nightscout/connect/route.ts +++ b/src/app/api/nightscout/connect/route.ts @@ -11,10 +11,16 @@ import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { markReconnected } from "@/lib/integrations/status"; import { NIGHTSCOUT_SYNC_QUEUE } from "@/lib/jobs/integration-poll-queues"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; -import { SafeFetchError } from "@/lib/safe-fetch"; -import { fetchSgvEntries, NightscoutApiError } from "@/lib/nightscout/client"; -import { nightscoutConnectSchema } from "@/lib/validations/nightscout"; -import { isPublicUrl } from "@/lib/validations/notifications"; +import { + fetchSgvEntries, + NightscoutApiError, + NightscoutPolicyError, +} from "@/lib/nightscout/client"; +import { + configuredNightscoutPrivateOrigins, + evaluateNightscoutOrigin, + nightscoutConnectSchema, +} from "@/lib/validations/nightscout"; /** * Connect (or update) the user's Nightscout instance (v1.17.0). @@ -50,7 +56,11 @@ export const POST = apiHandler(async (request: NextRequest) => { return apiError("A valid Nightscout URL is required", 422); } - const { url, token, allowPrivateHost } = result.data; + const { url, token } = result.data; + const policy = evaluateNightscoutOrigin( + url, + configuredNightscoutPrivateOrigins(), + ); // SSRF floor at INPUT time, not merely as a side effect of the live probe // below. `fetchSgvEntries` does route through `safeFetch` with the same @@ -61,13 +71,13 @@ export const POST = apiHandler(async (request: NextRequest) => { // Assert it here so the thing that gets encrypted onto the row is the // thing that was validated. // - // Gated on the same explicit opt-in the fetch path uses: a self-hoster who - // deliberately points at a LAN instance has asked for the private host and - // must still be able to connect. - if (!allowPrivateHost && !isPublicUrl(url)) { + // The legacy request boolean is intentionally ignored. Only the server-owned + // exact canonical origin set may authorize private egress. + if (!policy.allowed || !policy.canonicalOrigin) { return apiError( - "That instance is on a private network. Enable the private-network option to connect to it.", + "Private network Nightscout access requires server operator approval", 422, + { errorCode: "private_origin_not_approved" }, ); } @@ -78,13 +88,16 @@ export const POST = apiHandler(async (request: NextRequest) => { baseUrl: url, token, count: 1, - allowPrivateHost, + // Compatibility shape for older callers/tests. The client deliberately + // ignores this field and re-evaluates server policy on every fetch. + allowPrivateHost: false, }); } catch (err) { - if (err instanceof SafeFetchError && err.kind === "private_host") { + if (err instanceof NightscoutPolicyError) { return apiError( - "That instance is on a private network. Enable the private-network option to connect to it.", + "Private network Nightscout access requires server operator approval", 422, + { errorCode: err.reasonCode }, ); } if (err instanceof NightscoutApiError) { @@ -111,7 +124,9 @@ export const POST = apiHandler(async (request: NextRequest) => { // Build the data object field-by-field — never spread the parsed body. nightscoutUrlEncrypted: encrypt(url), nightscoutTokenEncrypted: token ? encrypt(token) : null, - nightscoutAllowPrivateHost: allowPrivateHost, + // Compatibility/display metadata only. Server policy is rechecked from + // NIGHTSCOUT_PRIVATE_ORIGINS before every request. + nightscoutAllowPrivateHost: policy.privateOriginApproved, }, }); diff --git a/src/app/api/nightscout/sync/route.ts b/src/app/api/nightscout/sync/route.ts index 99504bb4d..09179805b 100644 --- a/src/app/api/nightscout/sync/route.ts +++ b/src/app/api/nightscout/sync/route.ts @@ -39,7 +39,19 @@ export const POST = apiHandler(async () => { try { const result = await syncUserNightscout(user.id); return apiSuccess(resolveSyncOutcome(result)); - } catch { + } catch (error) { + if ( + typeof error === "object" && + error !== null && + "reasonCode" in error && + error.reasonCode === "private_origin_not_approved" + ) { + return apiError( + "Private Nightscout access requires server operator approval", + 422, + { errorCode: "private_origin_not_approved" }, + ); + } return apiError("Nightscout sync failed", 502); } }); diff --git a/src/lib/nightscout/client.ts b/src/lib/nightscout/client.ts index e40f8b785..a504ba127 100644 --- a/src/lib/nightscout/client.ts +++ b/src/lib/nightscout/client.ts @@ -8,13 +8,11 @@ * the entry→Measurement mapping. No SDK — hand-rolled fetch over `safeFetch`, * mirroring the Withings / WHOOP / Fitbit clients. * - * SSRF (critical): every outbound call routes through `safeFetch` with - * `requirePublicHost: true` BY DEFAULT — public instances are the norm, and a - * malicious URL pointing at `169.254.169.254` / an RFC1918 admin panel must be - * refused. Only when the user has flipped `nightscoutAllowPrivateHost` (the - * explicit self-hoster opt-in for a LAN instance) does the public-host pin - * come off. Redirects are pinned to `manual` and a timeout is always composed - * so a tar-pit instance can't hold a worker. + * SSRF (critical): every outbound call re-evaluates the exact server-owned + * origin policy. Public instances use safeFetch's input and DNS-pinned public + * verdict. A private origin is reachable only when its complete canonical + * scheme/host/port is in NIGHTSCOUT_PRIVATE_ORIGINS. The legacy user boolean + * is accepted as compatibility input but never grants egress. * * Auth: Nightscout accepts the API secret two ways — the `api-secret` HTTP * header carrying the SHA1 hex of the secret, OR a `token` query param (a @@ -26,6 +24,12 @@ import { createHash } from "node:crypto"; import type { MeasurementType } from "@/generated/prisma/client"; import { safeFetch } from "@/lib/safe-fetch"; +import { + configuredNightscoutPrivateOrigins, + evaluateNightscoutOrigin, + NIGHTSCOUT_PRIVATE_ORIGIN_REASON, +} from "@/lib/validations/nightscout"; +import { SafeFetchError } from "@/lib/safe-fetch"; /** Default per-request timeout for a Nightscout fetch. */ const NIGHTSCOUT_TIMEOUT_MS = 15_000; @@ -71,7 +75,8 @@ export interface FetchSgvOptions { baseUrl: string; token: string; count: number; - allowPrivateHost: boolean; + /** @deprecated Compatibility metadata only; never egress authority. */ + allowPrivateHost?: boolean; /** Defaults to `token` (query param). `header` uses the api-secret SHA1. */ authMode?: NightscoutAuthMode; /** Override the default timeout (tests). */ @@ -176,6 +181,15 @@ export class NightscoutApiError extends Error { } } +export class NightscoutPolicyError extends Error { + readonly reasonCode = NIGHTSCOUT_PRIVATE_ORIGIN_REASON; + + constructor() { + super(NIGHTSCOUT_PRIVATE_ORIGIN_REASON); + this.name = "NightscoutPolicyError"; + } +} + /** * Fetch the most recent `count` SGV entries from the user's instance. * @@ -188,6 +202,14 @@ export class NightscoutApiError extends Error { export async function fetchSgvEntries( opts: FetchSgvOptions, ): Promise { + const policy = evaluateNightscoutOrigin( + opts.baseUrl, + configuredNightscoutPrivateOrigins(), + ); + if (!policy.allowed || !policy.canonicalOrigin) { + throw new NightscoutPolicyError(); + } + const authMode: NightscoutAuthMode = opts.authMode ?? "token"; const headers: Record = { Accept: "application/json" }; let url: string; @@ -195,22 +217,35 @@ export async function fetchSgvEntries( if (authMode === "header" && opts.token) { // Classic `API_SECRET` instances: the SHA1 hex in the api-secret header. headers["api-secret"] = sha1Hex(opts.token); - url = buildEntriesUrl(opts.baseUrl, opts.count); + url = buildEntriesUrl(policy.canonicalOrigin, opts.count); } else { // Token query-param path (role-scoped access token; the default). - url = buildEntriesUrl(opts.baseUrl, opts.count, opts.token || undefined); + url = buildEntriesUrl( + policy.canonicalOrigin, + opts.count, + opts.token || undefined, + ); } - const res = await safeFetch( - url, - { method: "GET", headers }, - { - // Default-on public-host pin; relaxed ONLY when the user opted a - // private/LAN instance in. `followRedirects` defaults false (manual). - requirePublicHost: !opts.allowPrivateHost, - timeoutMs: opts.timeoutMs ?? NIGHTSCOUT_TIMEOUT_MS, - }, - ); + let res: Response; + try { + res = await safeFetch( + url, + { method: "GET", headers }, + { + // Public origins receive the full literal + DNS-pinned classifier. + // An exact operator origin may resolve privately; redirects remain + // manual, so no response can move the request outside that grant. + requirePublicHost: !policy.privateOriginApproved, + timeoutMs: opts.timeoutMs ?? NIGHTSCOUT_TIMEOUT_MS, + }, + ); + } catch (error) { + if (error instanceof SafeFetchError && error.kind === "private_host") { + throw new NightscoutPolicyError(); + } + throw error; + } if (res.status < 200 || res.status >= 300) { throw new NightscoutApiError( diff --git a/src/lib/nightscout/credentials.ts b/src/lib/nightscout/credentials.ts index 4e704a455..7a9c59411 100644 --- a/src/lib/nightscout/credentials.ts +++ b/src/lib/nightscout/credentials.ts @@ -14,6 +14,7 @@ import { decrypt } from "@/lib/crypto"; export interface NightscoutCredentials { baseUrl: string; token: string; + /** @deprecated Compatibility/display metadata; never egress authority. */ allowPrivateHost: boolean; } diff --git a/src/lib/nightscout/sync.ts b/src/lib/nightscout/sync.ts index 724ccf46b..adef2de55 100644 --- a/src/lib/nightscout/sync.ts +++ b/src/lib/nightscout/sync.ts @@ -44,6 +44,7 @@ import { fetchSgvEntries, mapSgvEntryToMeasurement, NightscoutApiError, + NightscoutPolicyError, type ParsedSgvEntry, } from "./client"; import { getUserNightscoutCredentials } from "./credentials"; @@ -92,15 +93,24 @@ export async function recordNightscoutSyncFailure( userId: string, err: unknown, ): Promise { + const policyDenied = + err instanceof NightscoutPolicyError || + (typeof err === "object" && + err !== null && + "reasonCode" in err && + err.reasonCode === "private_origin_not_approved"); await recordSyncFailure({ userId, integration: "nightscout", kind: classifyNightscoutFailure(err), - message: redactNightscoutSecret( - err instanceof Error ? err.message : String(err), - ), - errorCode: - err instanceof NightscoutApiError && err.status != null + message: policyDenied + ? "private_origin_not_approved" + : redactNightscoutSecret( + err instanceof Error ? err.message : String(err), + ), + errorCode: policyDenied + ? "private_origin_not_approved" + : err instanceof NightscoutApiError && err.status != null ? String(err.status) : undefined, }); @@ -109,7 +119,15 @@ export async function recordNightscoutSyncFailure( /** Map a Nightscout HTTP status / network error onto the shared ledger kind. */ export function classifyNightscoutFailure(err: unknown): FailureKind { let classification: IntegrationClassification = "transient"; - if (err instanceof NightscoutApiError && err.status != null) { + if ( + err instanceof NightscoutPolicyError || + (typeof err === "object" && + err !== null && + "reasonCode" in err && + err.reasonCode === "private_origin_not_approved") + ) { + classification = "persistent"; + } else if (err instanceof NightscoutApiError && err.status != null) { // Wrong / missing token, or a token whose role can't read SGV. if (err.status === 401 || err.status === 403) { classification = "reauth_required"; @@ -152,7 +170,6 @@ export async function syncUserNightscout( baseUrl: creds.baseUrl, token: creds.token, count: opts.count ?? NIGHTSCOUT_SYNC_COUNT, - allowPrivateHost: creds.allowPrivateHost, }); } catch (err) { // Recorded here (token-redacted), then marked so the poll-cohort boundary diff --git a/src/lib/validations/nightscout.ts b/src/lib/validations/nightscout.ts index 010f2d753..12d289a17 100644 --- a/src/lib/validations/nightscout.ts +++ b/src/lib/validations/nightscout.ts @@ -1,23 +1,157 @@ import { z } from "zod/v4"; +import { isPublicUrl } from "@/lib/validations/notifications"; + +export const NIGHTSCOUT_PRIVATE_ORIGIN_REASON = + "private_origin_not_approved" as const; +export const NIGHTSCOUT_INVALID_ORIGIN_REASON = "invalid_origin" as const; + +export type NightscoutOriginReason = + | typeof NIGHTSCOUT_PRIVATE_ORIGIN_REASON + | typeof NIGHTSCOUT_INVALID_ORIGIN_REASON; + +export interface NightscoutOriginVerdict { + allowed: boolean; + canonicalOrigin: string | null; + privateOriginApproved: boolean; + reasonCode: NightscoutOriginReason | null; +} + +/** + * Parse one exact http(s) origin. + * + * Credentials, paths, queries, and fragments are deliberately forbidden: the + * operator grants a complete canonical scheme/host/port trust unit, never a + * suffix, wildcard, capability URL, or prefix. + */ +function canonicalOrigin(value: string): string | null { + try { + const url = new URL(value); + if (url.protocol !== "http:" && url.protocol !== "https:") return null; + if (url.username || url.password) return null; + if (url.pathname !== "/" || url.search || url.hash) return null; + if (!url.hostname || url.hostname.includes("*")) return null; + return url.origin; + } catch { + return null; + } +} + +function sharesApprovedPrivateHostname( + origin: string, + privateOrigins: ReadonlySet, +): boolean { + const hostname = new URL(origin).hostname; + + for (const approvedOrigin of privateOrigins) { + const approvedHostname = new URL(approvedOrigin).hostname; + if ( + hostname === approvedHostname || + hostname.endsWith(`.${approvedHostname}`) + ) { + return true; + } + } + + return false; +} + +/** + * Parse the server-only comma-separated exact-origin allowlist. + * + * Invalid non-empty entries fail startup/call-site evaluation loudly rather + * than being skipped: partial acceptance makes an operator believe a private + * integration is protected while silently changing which origin is trusted. + */ +export function parseNightscoutPrivateOrigins( + raw: string | undefined, +): ReadonlySet { + const origins = new Set(); + for (const entry of (raw ?? "").split(",")) { + const trimmed = entry.trim(); + if (!trimmed) continue; + const origin = canonicalOrigin(trimmed); + if (!origin) { + throw new Error("Invalid NIGHTSCOUT_PRIVATE_ORIGINS entry"); + } + origins.add(origin); + } + return origins; +} + +/** + * Resolve one user-supplied Nightscout base URL against server policy. + * + * Exact operator membership wins before the ordinary public-host verdict so a + * private DNS name can be trusted without granting its suffix or sibling + * ports. Public origins remain supported without configuration. + */ +export function evaluateNightscoutOrigin( + value: string, + privateOrigins: ReadonlySet, +): NightscoutOriginVerdict { + const origin = canonicalOrigin(value); + if (!origin) { + return { + allowed: false, + canonicalOrigin: null, + privateOriginApproved: false, + reasonCode: NIGHTSCOUT_INVALID_ORIGIN_REASON, + }; + } + + if (privateOrigins.has(origin)) { + return { + allowed: true, + canonicalOrigin: origin, + privateOriginApproved: true, + reasonCode: null, + }; + } + + // An exact grant must not accidentally become a hostname suffix grant. + // Deny related hosts before the generic public-host fallback: private DNS + // names such as cgm.lan may otherwise look syntactically public here. + if (sharesApprovedPrivateHostname(origin, privateOrigins)) { + return { + allowed: false, + canonicalOrigin: origin, + privateOriginApproved: false, + reasonCode: NIGHTSCOUT_PRIVATE_ORIGIN_REASON, + }; + } + + if (isPublicUrl(origin)) { + return { + allowed: true, + canonicalOrigin: origin, + privateOriginApproved: false, + reasonCode: null, + }; + } + + return { + allowed: false, + canonicalOrigin: origin, + privateOriginApproved: false, + reasonCode: NIGHTSCOUT_PRIVATE_ORIGIN_REASON, + }; +} + +export function configuredNightscoutPrivateOrigins(): ReadonlySet { + return parseNightscoutPrivateOrigins(process.env.NIGHTSCOUT_PRIVATE_ORIGINS); +} /** * Per-user Nightscout connection input (v1.17.0). The self-hoster points * HealthLog at their own Nightscout instance (Railway / Heroku / Fly / a LAN * box) and pastes the instance's API token. Both fields are stored encrypted * on `User` (`nightscoutUrlEncrypted` / `nightscoutTokenEncrypted`); the - * private-host opt-in maps to `nightscoutAllowPrivateHost`. + * legacy private-host boolean is accepted for compatibility but never grants + * network authority. * - * The URL is validated to be a parseable http(s) origin here. The SSRF floor - * (public-host requirement, unless `allowPrivateHost` is set) is asserted - * twice on purpose: the connect route runs `isPublicUrl` before it persists - * anything, and `safeFetch` re-checks plus pins the resolved IP at connect - * time. The shape check here only rejects garbage early so the route can - * return a clear 422 instead of letting a malformed string reach either. - * - * The floor is NOT expressed as a Zod refinement because it depends on the - * sibling `allowPrivateHost` field and needs its own error message; folding - * it in here would collapse "malformed URL" and "private host, opt in to - * reach it" into one opaque 422. + * The URL is validated to be parseable here. Exact server policy is evaluated + * by the route and again by the client so the request body cannot confer + * private-network authority. */ export const nightscoutConnectSchema = z.object({ // Trimmed: a trailing space / newline from a copy button reaches the @@ -39,8 +173,8 @@ export const nightscoutConnectSchema = z.object({ // the raw `API_SECRET`). Optional: a fully public instance with // `AUTH_DEFAULT_ROLES=readable` serves SGV entries without a token. token: z.string().trim().max(512).optional().default(""), - // The explicit self-hoster opt-in to allow a private / LAN Nightscout host - // past the public-host SSRF floor. Defaults false (public instances). + // Deprecated request compatibility only. This value is deliberately + // ignored by the route and client. allowPrivateHost: z.boolean().optional().default(false), });