mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
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:
1 parent
036720bcfb
commit
ea3a8deed2
7 files changed
+604
-114
No files matched your search
@@ -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> {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user