Files
HealthLog/src/lib/private-origin-policy.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

233 lines
8.1 KiB
TypeScript

import { isIP } from "node:net";
import {
isOperatorGrantableIp,
isPublicUrl,
} from "@/lib/validations/notifications";
/**
* Exact-origin grant grammar shared by every operator allowlist that opens a
* private destination: `NIGHTSCOUT_PRIVATE_ORIGINS` (v1.17.0) and
* `NOTIFICATION_PRIVATE_ORIGINS` (#947). One parser, one verdict, so the two
* lists cannot drift in what they accept.
*
* A grant is one canonical `scheme://host[:port]` trust unit. It is never a
* suffix, wildcard, CIDR, capability URL or prefix: a range grant would let a
* DNS rebinding to any address inside the range pass the connect-time pin,
* which turns the pin into a formality. An operator with several relays lists
* several origins.
*/
export const PRIVATE_ORIGIN_NOT_APPROVED_CODE =
"private_origin_not_approved" as const;
/**
* The target is the unspecified address, link-local or the metadata range:
* no grant can ever open it, so telling the user to ask the operator would
* send them in a circle.
*/
export const PRIVATE_ORIGIN_NOT_GRANTABLE_CODE =
"private_origin_not_grantable" as const;
export const INVALID_ORIGIN = "invalid_origin" as const;
export type OriginReason =
| typeof PRIVATE_ORIGIN_NOT_APPROVED_CODE
| typeof PRIVATE_ORIGIN_NOT_GRANTABLE_CODE
| typeof INVALID_ORIGIN;
/**
* A hostname no grant may name: a literal address outside the grantable
* ranges (unspecified, link-local, metadata). Loopback and `localhost` are
* grantable when listed exactly — a host-networking deployment lists them —
* and DNS names are not judged here: a name that resolves into a refused
* range is dropped at dial time by the pinned dispatcher.
*/
export function isNeverGrantableHost(hostname: string): boolean {
const literal = hostname.toLowerCase().replace(/^\[|\]$/g, "");
return isIP(literal) !== 0 && !isOperatorGrantableIp(literal);
}
/**
* The entry as it may be logged or put in an error message: scheme and host
* only. A malformed entry is malformed precisely when it carries the shapes
* that hold a secret — a query with a token, userinfo, a capability URL
* pasted whole — and this text goes to stdout, not through the wide-event
* redactors.
*/
export function redactGrantEntry(entry: string): string {
return entry
.split(/[?#]/, 1)[0]
.replace(/\/\/[^/@]*@/, "//")
.slice(0, 200);
}
/** Why an entry is not a grant, in operator-readable words. */
export function describeGrantRejection(entry: string): string {
try {
const url = new URL(entry);
if (
(url.protocol === "http:" || url.protocol === "https:") &&
isNeverGrantableHost(url.hostname)
) {
return "the unspecified address, link-local and metadata addresses cannot be granted; list the address the relay actually listens on";
}
} catch {
// fall through to the grammar sentence
}
return "a grant is one exact http(s) origin (scheme://host[:port]) with no path, query, credentials or wildcard";
}
export interface OriginVerdict {
allowed: boolean;
canonicalOrigin: string | null;
privateOriginApproved: boolean;
reasonCode: OriginReason | 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. A literal address in the
* unspecified range, link-local or the metadata range is refused too —
* `isOperatorGrantableIp` says why — and the pinned dispatcher applies the
* same floor when a listed DNS name resolves there. Loopback is grantable
* when listed exactly: an operator on host networking lists it on purpose.
*/
export 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;
// Unspecified, link-local and metadata literals are refused at parse
// time. Loopback, `localhost` and reserved-looking names (`.local` mDNS,
// `.internal`, `.lan`) stay grantable: an explicitly listed origin is
// what the list is for, and the dial-time floor still drops a metadata
// or link-local answer.
if (isNeverGrantableHost(url.hostname)) return null;
return url.origin;
} catch {
return null;
}
}
/**
* The origin of a target URL that may legitimately carry a path (a Gotify
* `/message` endpoint, an ntfy topic), for comparison against the grant set.
*/
export function originOfUrl(value: string): string | null {
try {
const url = new URL(value);
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
if (!url.hostname) 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 a server-only comma-separated exact-origin allowlist.
*
* Every malformed non-empty entry is handed to `onInvalid` with the entry
* already reduced to scheme and host (`redactGrantEntry`) and the reason in
* words (`describeGrantRejection`); the callback decides the failure mode: the Nightscout list throws (a connection form can afford to
* fail loudly), the notification list logs once and skips the entry (a typo
* must not take every public webhook down with it). Either way a malformed
* entry grants nothing — the set only ever shrinks.
*/
export function parsePrivateOrigins(
raw: string | undefined,
onInvalid: (redactedEntry: string, reason: string) => void,
): 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) {
onInvalid(redactGrantEntry(trimmed), describeGrantRejection(trimmed));
continue;
}
origins.add(origin);
}
return origins;
}
/**
* Resolve one canonical origin against the grant set.
*
* 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. `publicCheck`
* defaults to the origin itself; a caller holding the full URL passes it so
* the raw-string guards in `isPublicUrl` (octal and hex address spellings)
* see the text the user typed.
*/
export function evaluateOriginGrant(
origin: string,
privateOrigins: ReadonlySet<string>,
publicCheck: string = origin,
): OriginVerdict {
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 gotify.lan may otherwise look syntactically public here.
if (sharesApprovedPrivateHostname(origin, privateOrigins)) {
return {
allowed: false,
canonicalOrigin: origin,
privateOriginApproved: false,
reasonCode: PRIVATE_ORIGIN_NOT_APPROVED_CODE,
};
}
if (isPublicUrl(publicCheck)) {
return {
allowed: true,
canonicalOrigin: origin,
privateOriginApproved: false,
reasonCode: null,
};
}
// Two refusals that read differently to the person who gets them: a
// private address the operator could list, and one no grant can open.
return {
allowed: false,
canonicalOrigin: origin,
privateOriginApproved: false,
reasonCode: isNeverGrantableHost(new URL(origin).hostname)
? PRIVATE_ORIGIN_NOT_GRANTABLE_CODE
: PRIVATE_ORIGIN_NOT_APPROVED_CODE,
};
}