Files
HealthLog/src/lib/validations/nightscout.ts
T
Marc-André Bombeck 94bed12edc fix(egress): keep loopback grantable under an exact operator origin
A Nightscout self-host on host networking with
NIGHTSCOUT_PRIVATE_ORIGINS=http://localhost:1337 worked before and must
keep working unchanged; an operator who lists an exact loopback origin
with its port has made the same deliberate decision as one who lists a
LAN origin. The never-grantable floor is now the unspecified address,
link-local and the metadata endpoint only. Loopback (127/8, ::1,
localhost) is grantable when listed exactly, still resolved and pinned
at connect time with redirects forbidden, and still refused when not
listed.

::1 is answered before the embedded-IPv4 unwrap, which read it as the
IPv4-compatible spelling of 0.0.0.1 and refused it as unspecified. The
pinned Nightscout transport test runs the real floor again. Each refused
range keeps its own test; loopback moves to the grantable-when-listed
set with a test that an unlisted loopback is still refused, and a
Nightscout grant of http://localhost:1337 is asserted to parse.

Refs #947
2026-09-10 19:08:04 +02:00

119 lines
4.2 KiB
TypeScript

import { z } from "zod/v4";
import {
canonicalOrigin,
evaluateOriginGrant,
INVALID_ORIGIN,
parsePrivateOrigins,
PRIVATE_ORIGIN_NOT_APPROVED_CODE,
type OriginReason,
type OriginVerdict,
} from "@/lib/private-origin-policy";
export const NIGHTSCOUT_PRIVATE_ORIGIN_REASON =
PRIVATE_ORIGIN_NOT_APPROVED_CODE;
export const NIGHTSCOUT_INVALID_ORIGIN_REASON = INVALID_ORIGIN;
/**
* Thrown when `NIGHTSCOUT_PRIVATE_ORIGINS` holds an entry that is not a
* grant. The message names the entry (scheme and host only) and the reason,
* so the sync ledger and the connect form show the operator what to edit
* instead of a bare "invalid entry". Loopback and `localhost` entries stay
* valid grants exactly as before #947; the shared floor refuses only the
* unspecified address, link-local and the metadata range.
*/
export class NightscoutOriginConfigError extends Error {
constructor(redactedEntry: string, reason: string) {
super(
`Invalid NIGHTSCOUT_PRIVATE_ORIGINS entry "${redactedEntry}": ${reason}`,
);
this.name = "NightscoutOriginConfigError";
}
}
export type NightscoutOriginReason = OriginReason;
export type NightscoutOriginVerdict = OriginVerdict;
/**
* Parse the server-only comma-separated exact-origin allowlist.
*
* The grammar lives in `@/lib/private-origin-policy` and is shared with
* `NOTIFICATION_PRIVATE_ORIGINS`. 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> {
return parsePrivateOrigins(raw, (redactedEntry, reason) => {
throw new NightscoutOriginConfigError(redactedEntry, reason);
});
}
/**
* Resolve one user-supplied Nightscout base URL against server policy.
*
* A Nightscout base URL is itself a bare origin, so the strict grant grammar
* applies to the input as well: a path, query, fragment or credential part
* is `invalid_origin`, not merely unapproved.
*/
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,
};
}
return evaluateOriginGrant(origin, privateOrigins);
}
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
* legacy private-host boolean is accepted for compatibility but never grants
* network authority.
*
* 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
// instance verbatim and produces an opaque DNS failure.
url: z
.string()
.trim()
.min(1)
.max(2048)
.refine((value) => {
try {
const u = new URL(value);
return u.protocol === "http:" || u.protocol === "https:";
} catch {
return false;
}
}, "Must be a valid http(s) URL"),
// Nightscout API tokens are opaque strings (a role-scoped access token or
// 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(""),
// Deprecated request compatibility only. This value is deliberately
// ignored by the route and client.
allowPrivateHost: z.boolean().optional().default(false),
});
export type NightscoutConnectInput = z.infer<typeof nightscoutConnectSchema>;