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
This commit is contained in:
Marc-André Bombeck committed 2026-07-29 13:57:07 +02:00
1 parent b7b18a6da2
commit f95da50ba0
6 files changed
+270 -56

No files matched your search

+29 -14
View File
@@ -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,
},
});
+13 -1
View File
@@ -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);
}
});
+55 -20
View File
@@ -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<ParsedSgvEntry[]> {
const policy = evaluateNightscoutOrigin(
opts.baseUrl,
configuredNightscoutPrivateOrigins(),
);
if (!policy.allowed || !policy.canonicalOrigin) {
throw new NightscoutPolicyError();
}
const authMode: NightscoutAuthMode = opts.authMode ?? "token";
const headers: Record<string, string> = { 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(
+1
View File
@@ -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;
}
+24 -7
View File
@@ -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<void> {
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
+148 -14
View File
@@ -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<string>,
): 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<string> {
const origins = new Set<string>();
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<string>,
): 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<string> {
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),
});