mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
1 parent
b7b18a6da2
commit
f95da50ba0
6 files changed
+270
-56
No files matched your search
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user