fix(egress): share the origin-grant grammar and floor its ranges

Lift the Nightscout allowlist parser and verdict into
src/lib/private-origin-policy.ts so a second operator grant can reuse
one grammar instead of a second parser. The Nightscout validator keeps
its exported names and behaviour; its tests are unchanged.

Add the floor both precedents lacked: an entry whose host is a literal
address in 127/8, 0/8, 169.254/16, ::, ::1 or fe80::/10 is not a grant,
and the operator-approved pinned dispatcher drops an answer in those
ranges even when the name is listed. RFC1918, CGNAT and IPv6 unique-
local stay grantable; that is what the list is for. Loopback inside the
container is the app itself, and on host networking every admin port of
the host; link-local is the cloud-metadata endpoint. No relay lives on
either.

The pinned Nightscout transport test resolves its listed name to a
loopback server on purpose, so it stubs the grantable-address predicate
for that harness only.

Refs #947
This commit is contained in:
Marc-André Bombeck committed 2026-09-10 16:34:04 +02:00
1 parent 036720bcfb
commit ea3a8deed2
7 files changed
+604 -114

No files matched your search

+25 -106
View File
@@ -1,89 +1,44 @@
import { z } from "zod/v4";
import { isPublicUrl } from "@/lib/validations/notifications";
import {
canonicalOrigin,
evaluateOriginGrant,
INVALID_ORIGIN,
parsePrivateOrigins,
PRIVATE_ORIGIN_NOT_APPROVED,
type OriginReason,
type OriginVerdict,
} from "@/lib/private-origin-policy";
export const NIGHTSCOUT_PRIVATE_ORIGIN_REASON =
"private_origin_not_approved" as const;
export const NIGHTSCOUT_INVALID_ORIGIN_REASON = "invalid_origin" as const;
export const NIGHTSCOUT_PRIVATE_ORIGIN_REASON = PRIVATE_ORIGIN_NOT_APPROVED;
export const NIGHTSCOUT_INVALID_ORIGIN_REASON = INVALID_ORIGIN;
export type NightscoutOriginReason =
| typeof NIGHTSCOUT_PRIVATE_ORIGIN_REASON
| typeof NIGHTSCOUT_INVALID_ORIGIN_REASON;
export type NightscoutOriginReason = OriginReason;
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;
}
export type NightscoutOriginVerdict = OriginVerdict;
/**
* 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.
* 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> {
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;
return parsePrivateOrigins(raw, () => {
throw new Error("Invalid NIGHTSCOUT_PRIVATE_ORIGINS entry");
});
}
/**
* 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.
* 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,
@@ -98,43 +53,7 @@ export function evaluateNightscoutOrigin(
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,
};
return evaluateOriginGrant(origin, privateOrigins);
}
export function configuredNightscoutPrivateOrigins(): ReadonlySet<string> {
+56
View File
@@ -228,6 +228,62 @@ export function isPublicIp(ip: string): boolean {
return false;
}
/**
* Decide whether a resolved address may be dialled under an operator's
* exact-origin grant (`NOTIFICATION_PRIVATE_ORIGINS`,
* `NIGHTSCOUT_PRIVATE_ORIGINS`).
*
* A grant exists so a relay on the operator's own network can be reached, so
* RFC1918, CGNAT (a Tailscale address) and IPv6 unique-local stay grantable.
* Four ranges are refused even when the operator lists a name that resolves
* there, because no notification relay lives on them and each one is a
* different admin surface: loopback is the app itself inside the container
* (and on host networking, every admin port of the host), the unspecified
* address is a resolver misfire, link-local is the cloud-metadata endpoint.
* The transition formats (`::ffff:`, 6to4, NAT64) are unwrapped first so an
* IPv6 spelling of loopback does not slip past the IPv4 verdict.
*/
export function isOperatorGrantableIp(ip: string): boolean {
if (!ip) return false;
const lower = ip.toLowerCase();
const family = isIP(lower);
if (family === 4) {
const parsed = parseIpv4Strict(lower);
if (!parsed) return false;
return !isNeverGrantableIpv4(parsed);
}
if (family === 6) {
const bytes = parseIpv6Bytes(lower);
if (!bytes) return false;
// Unspecified, loopback and link-local IPv6. Unique-local (fc00::/7)
// deliberately stays out of this list: it is the IPv6 analogue of
// RFC1918 and exactly what an operator lists.
if (bytesEqual(bytes, 0, Array(16).fill(0))) return false;
if (bytesEqual(bytes, 0, Array(15).fill(0)) && bytes[15] === 1) {
return false;
}
if (bytes[0] === 0xfe && (bytes[1] & 0xc0) === 0x80) return false;
const embedded = embeddedIpv4(bytes);
if (embedded && isNeverGrantableIpv4(embedded)) return false;
return true;
}
return false;
}
/** 127/8, 0/8 and 169.254/16: the ranges no operator grant may open. */
function isNeverGrantableIpv4(ip: [number, number, number, number]): boolean {
const [a, b] = ip;
if (a === 127) return true;
if (a === 0) return true;
if (a === 169 && b === 254) return true;
return false;
}
export function isPublicUrl(url: string): boolean {
try {
// Pre-URL guard #1: the WHATWG URL parser interprets leading-zero IPv4