diff --git a/src/lib/__tests__/private-origin-policy.test.ts b/src/lib/__tests__/private-origin-policy.test.ts new file mode 100644 index 000000000..88540e7fd --- /dev/null +++ b/src/lib/__tests__/private-origin-policy.test.ts @@ -0,0 +1,172 @@ +/** + * The exact-origin grant grammar shared by every operator allowlist that opens + * a private destination (`NIGHTSCOUT_PRIVATE_ORIGINS`, + * `NOTIFICATION_PRIVATE_ORIGINS`). + * + * Two properties are pinned here and nowhere else: + * + * 1. A grant names one canonical `scheme://host[:port]` and nothing wider — + * no path, credentials, wildcard, suffix, sibling port or sub-host. + * 2. A grant can never name loopback, the unspecified address, link-local or + * the cloud-metadata range. Those are refused at parse time when written + * as a literal, and the pinned dispatcher refuses them again when a + * listed DNS name resolves there (`safe-fetch-dispatcher.test.ts`). + */ +import { describe, expect, it, vi } from "vitest"; + +import { + canonicalOrigin, + evaluateOriginGrant, + parsePrivateOrigins, + PRIVATE_ORIGIN_NOT_APPROVED, +} from "../private-origin-policy"; + +describe("canonicalOrigin", () => { + it("normalises case and drops a default port", () => { + expect(canonicalOrigin("HTTPS://Gotify.Example.COM:443")).toBe( + "https://gotify.example.com", + ); + expect(canonicalOrigin("http://ntfy.lan:8080")).toBe( + "http://ntfy.lan:8080", + ); + expect(canonicalOrigin("https://[fd00::4]:8443")).toBe( + "https://[fd00::4]:8443", + ); + }); + + it.each([ + ["credentials", "http://operator:secret@10.0.0.4:1337"], + ["non-root path", "https://gotify.example.com/message"], + ["query", "http://10.0.0.4:1337?token=secret"], + ["fragment", "http://10.0.0.4:1337#admin"], + ["wrong scheme", "ftp://10.0.0.4:1337"], + ["wildcard", "https://*.gotify.lan"], + ["suffix", ".gotify.lan"], + ["malformed", "not an origin"], + ])("refuses a %s as a grant", (_label, value) => { + expect(canonicalOrigin(value)).toBeNull(); + }); + + it.each([ + ["IPv4 loopback", "http://127.0.0.1:8080"], + ["IPv4 loopback, non-canonical", "http://127.10.20.30"], + ["IPv4 unspecified", "http://0.0.0.0:8080"], + ["link-local / metadata", "http://169.254.169.254"], + ["IPv6 loopback", "http://[::1]:8080"], + ["IPv6 unspecified", "http://[::]:8080"], + ["IPv6 link-local", "http://[fe80::1]:8080"], + ["IPv4-mapped loopback", "http://[::ffff:127.0.0.1]:8080"], + ["IPv4-mapped metadata", "http://[::ffff:169.254.169.254]"], + ])("refuses %s as a grant", (_label, value) => { + expect(canonicalOrigin(value)).toBeNull(); + }); + + it("keeps RFC1918, CGNAT and ULA grantable — that is the point of the list", () => { + expect(canonicalOrigin("http://10.0.0.4:1337")).toBe( + "http://10.0.0.4:1337", + ); + expect(canonicalOrigin("http://192.168.1.20")).toBe("http://192.168.1.20"); + expect(canonicalOrigin("http://172.16.0.9:8080")).toBe( + "http://172.16.0.9:8080", + ); + expect(canonicalOrigin("http://100.64.0.7")).toBe("http://100.64.0.7"); + expect(canonicalOrigin("http://[fd12:3456::1]:8080")).toBe( + "http://[fd12:3456::1]:8080", + ); + }); +}); + +describe("parsePrivateOrigins", () => { + it("parses a comma-separated setting into canonical exact origins", () => { + const onInvalid = vi.fn(); + expect( + parsePrivateOrigins( + " http://10.0.0.4:1337, HTTPS://GOTIFY.LAN:443 ,https://[fd00::4]:8443 ", + onInvalid, + ), + ).toEqual( + new Set([ + "http://10.0.0.4:1337", + "https://gotify.lan", + "https://[fd00::4]:8443", + ]), + ); + expect(onInvalid).not.toHaveBeenCalled(); + }); + + it("grants nothing for an empty setting", () => { + const onInvalid = vi.fn(); + expect(parsePrivateOrigins(undefined, onInvalid)).toEqual(new Set()); + expect(parsePrivateOrigins(" , ", onInvalid)).toEqual(new Set()); + expect(onInvalid).not.toHaveBeenCalled(); + }); + + it("hands every malformed entry to the caller and keeps the valid ones", () => { + const onInvalid = vi.fn(); + expect( + parsePrivateOrigins( + "https://gotify.lan, https://*.lan, http://127.0.0.1:8080, not an origin", + onInvalid, + ), + ).toEqual(new Set(["https://gotify.lan"])); + expect(onInvalid.mock.calls.map(([entry]) => entry)).toEqual([ + "https://*.lan", + "http://127.0.0.1:8080", + "not an origin", + ]); + }); + + it("lets the caller turn a malformed entry into a throw", () => { + expect(() => + parsePrivateOrigins("https://gotify.lan, junk", (entry) => { + throw new Error(`bad entry: ${entry}`); + }), + ).toThrow("bad entry: junk"); + }); +}); + +describe("evaluateOriginGrant", () => { + const grants = new Set(["http://10.0.0.4:1337", "https://gotify.lan:8443"]); + + it("approves exactly the listed scheme, host and port", () => { + expect(evaluateOriginGrant("http://10.0.0.4:1337", grants)).toEqual({ + allowed: true, + canonicalOrigin: "http://10.0.0.4:1337", + privateOriginApproved: true, + reasonCode: null, + }); + }); + + it.each([ + ["scheme", "https://10.0.0.4:1337"], + ["port", "http://10.0.0.4:8080"], + ["neighbouring address", "http://10.0.0.40:1337"], + ["sub-host", "https://sub.gotify.lan:8443"], + ["sibling port on a listed name", "https://gotify.lan"], + ])("refuses a %s that differs from the grant", (_label, origin) => { + expect(evaluateOriginGrant(origin, grants)).toEqual({ + allowed: false, + canonicalOrigin: origin, + privateOriginApproved: false, + reasonCode: PRIVATE_ORIGIN_NOT_APPROVED, + }); + }); + + it("keeps a public origin functional without any grant", () => { + expect(evaluateOriginGrant("https://ntfy.sh", new Set())).toEqual({ + allowed: true, + canonicalOrigin: "https://ntfy.sh", + privateOriginApproved: false, + reasonCode: null, + }); + }); + + it("refuses an unlisted private origin", () => { + expect(evaluateOriginGrant("http://192.168.1.9:8080", grants)).toEqual({ + allowed: false, + canonicalOrigin: "http://192.168.1.9:8080", + privateOriginApproved: false, + reasonCode: PRIVATE_ORIGIN_NOT_APPROVED, + }); + }); +}); diff --git a/src/lib/__tests__/safe-fetch-dispatcher.test.ts b/src/lib/__tests__/safe-fetch-dispatcher.test.ts index aa1d2d639..aa1446ab7 100644 --- a/src/lib/__tests__/safe-fetch-dispatcher.test.ts +++ b/src/lib/__tests__/safe-fetch-dispatcher.test.ts @@ -1,8 +1,14 @@ import { describe, it, expect, afterEach, vi } from "vitest"; import dns from "node:dns"; +import http from "node:http"; +import type { AddressInfo } from "node:net"; import { fetch as undiciFetch } from "undici"; -import { isPublicIp } from "@/lib/validations/notifications"; import { + isOperatorGrantableIp, + isPublicIp, +} from "@/lib/validations/notifications"; +import { + getPinnedOperatorApprovedDispatcher, getPinnedPublicDispatcher, _resetPinnedDispatcherForTests, } from "../safe-fetch-dispatcher"; @@ -221,3 +227,146 @@ describe("pinnedPublicDispatcher", () => { 5_000, ); }); + +describe("isOperatorGrantableIp", () => { + it("keeps the private ranges an operator may deliberately list", () => { + expect(isOperatorGrantableIp("10.0.0.5")).toBe(true); + expect(isOperatorGrantableIp("172.16.0.9")).toBe(true); + expect(isOperatorGrantableIp("192.168.1.20")).toBe(true); + expect(isOperatorGrantableIp("100.64.0.7")).toBe(true); + expect(isOperatorGrantableIp("fd12:3456:789a::1")).toBe(true); + expect(isOperatorGrantableIp("::ffff:10.0.0.5")).toBe(true); + }); + + it("keeps public addresses grantable (a listed public origin is harmless)", () => { + expect(isOperatorGrantableIp("203.0.113.5")).toBe(true); + expect(isOperatorGrantableIp("2001:db8::1")).toBe(true); + }); + + it.each([ + ["IPv4 loopback", "127.0.0.1"], + ["IPv4 loopback, high octet", "127.255.0.1"], + ["IPv4 unspecified", "0.0.0.0"], + ["IPv4 link-local / metadata", "169.254.169.254"], + ["IPv6 loopback", "::1"], + ["IPv6 unspecified", "::"], + ["IPv6 link-local", "fe80::1"], + ["IPv4-mapped loopback", "::ffff:127.0.0.1"], + ["IPv4-mapped metadata", "::ffff:169.254.169.254"], + ["6to4 metadata", "2002:a9fe:a9fe::"], + ["NAT64 loopback", "64:ff9b::7f00:1"], + ["not an address", "gotify.lan"], + ["empty", ""], + ])("never grants %s, listed or not", (_label, ip) => { + expect(isOperatorGrantableIp(ip)).toBe(false); + }); +}); + +describe("pinnedOperatorApprovedDispatcher", () => { + afterEach(() => { + vi.restoreAllMocks(); + _resetPinnedDispatcherForTests(); + }); + + function resolveTo(addresses: dns.LookupAddress[]): void { + vi.spyOn(dns, "lookup").mockImplementation((( + _hostname: string, + _opts: dns.LookupAllOptions, + callback: ( + err: NodeJS.ErrnoException | null, + addrs: dns.LookupAddress[], + ) => void, + ) => { + callback(null, addresses); + }) as unknown as typeof dns.lookup); + } + + it.each([ + ["loopback", { address: "127.0.0.1", family: 4 }], + ["link-local / metadata", { address: "169.254.169.254", family: 4 }], + ["unspecified", { address: "0.0.0.0", family: 4 }], + ["IPv6 loopback", { address: "::1", family: 6 }], + ["IPv6 link-local", { address: "fe80::1", family: 6 }], + ["IPv4-mapped metadata", { address: "::ffff:169.254.169.254", family: 6 }], + ])( + "refuses a listed name that resolves to %s even under the approved policy", + async (_label, answer) => { + resolveTo([answer]); + + const dispatcher = getPinnedOperatorApprovedDispatcher(); + let caught: unknown; + try { + await undiciFetch("http://gotify-listed.example.test:8089/message", { + dispatcher, + signal: AbortSignal.timeout(750), + }); + } catch (error) { + caught = error; + } + + expect(caught).toBeDefined(); + expect(errorCodes(caught)).toContain("ENOTFOUND"); + }, + 5_000, + ); + + it("forwards an RFC1918 answer to the connector (the grant does its job)", async () => { + // 10.255.255.1 is private and, on this harness, unroutable: the pinned + // lookup must ACCEPT it (no ENOTFOUND) and the failure, if any, comes from + // the socket below. + resolveTo([{ address: "10.255.255.1", family: 4 }]); + + const dispatcher = getPinnedOperatorApprovedDispatcher(); + let caught: unknown; + try { + await undiciFetch("http://gotify-listed.example.test:8089/message", { + dispatcher, + signal: AbortSignal.timeout(500), + }); + } catch (error) { + caught = error; + } + + expect(errorCodes(caught)).not.toContain("ENOTFOUND"); + }, 20_000); + + it("drops the loopback answer from a mixed set and dials only the private one", async () => { + // A real server on loopback proves the point: the listed name resolves to + // loopback AND to an unroutable private address; the pinned lookup must + // hand undici only the private survivor, so the loopback server never + // sees a request and the dial fails at the socket, not at the resolver. + let requests = 0; + const server = http.createServer((_req, res) => { + requests += 1; + res.writeHead(200); + res.end(); + }); + await new Promise((resolve) => + server.listen(0, "127.0.0.1", () => resolve()), + ); + const port = (server.address() as AddressInfo).port; + try { + resolveTo([ + { address: "127.0.0.1", family: 4 }, + { address: "10.255.255.1", family: 4 }, + ]); + + const dispatcher = getPinnedOperatorApprovedDispatcher(); + let caught: unknown; + try { + await undiciFetch(`http://gotify-listed.example.test:${port}/message`, { + dispatcher, + signal: AbortSignal.timeout(500), + }); + } catch (error) { + caught = error; + } + + expect(caught).toBeDefined(); + expect(errorCodes(caught)).not.toContain("ENOTFOUND"); + expect(requests).toBe(0); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }, 20_000); +}); diff --git a/src/lib/nightscout/__tests__/client-pinned.test.ts b/src/lib/nightscout/__tests__/client-pinned.test.ts index c132bdaf7..d9db5a7f3 100644 --- a/src/lib/nightscout/__tests__/client-pinned.test.ts +++ b/src/lib/nightscout/__tests__/client-pinned.test.ts @@ -5,7 +5,11 @@ * an operator-approved private origin reaches the pinned Undici connector. * This test keeps the Nightscout client, SafeFetch, dispatcher, socket, and * HTTP server real. Only DNS is deterministic: the approved test hostname is - * resolved to the loopback server from inside the connector. + * resolved to the loopback server from inside the connector. Because the + * operator-approved dispatcher refuses a loopback answer by policy (the grant + * opens the operator's network, never the container), the grantable-address + * predicate is stubbed to admit loopback for this harness only; the policy + * itself is proven in `safe-fetch-dispatcher.test.ts`. */ import dns from "node:dns"; import http from "node:http"; @@ -20,6 +24,16 @@ import { vi, } from "vitest"; +vi.mock("@/lib/validations/notifications", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + isOperatorGrantableIp: (ip: string) => + ip === "127.0.0.1" || actual.isOperatorGrantableIp(ip), + }; +}); + import { _resetPinnedDispatcherForTests } from "@/lib/safe-fetch-dispatcher"; import { fetchSgvEntries } from "../client"; diff --git a/src/lib/private-origin-policy.ts b/src/lib/private-origin-policy.ts new file mode 100644 index 000000000..794d08a34 --- /dev/null +++ b/src/lib/private-origin-policy.ts @@ -0,0 +1,171 @@ +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 = + "private_origin_not_approved" as const; +export const INVALID_ORIGIN = "invalid_origin" as const; + +export type OriginReason = + typeof PRIVATE_ORIGIN_NOT_APPROVED | typeof INVALID_ORIGIN; + +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 loopback, + * 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. + */ +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; + const literal = url.hostname.replace(/^\[|\]$/g, ""); + if (isIP(literal) !== 0 && !isOperatorGrantableIp(literal)) 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, +): 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`, which 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: (entry: string) => void, +): ReadonlySet { + const origins = new Set(); + for (const entry of (raw ?? "").split(",")) { + const trimmed = entry.trim(); + if (!trimmed) continue; + const origin = canonicalOrigin(trimmed); + if (!origin) { + onInvalid(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, + 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, + }; + } + + if (isPublicUrl(publicCheck)) { + return { + allowed: true, + canonicalOrigin: origin, + privateOriginApproved: false, + reasonCode: null, + }; + } + + return { + allowed: false, + canonicalOrigin: origin, + privateOriginApproved: false, + reasonCode: PRIVATE_ORIGIN_NOT_APPROVED, + }; +} diff --git a/src/lib/safe-fetch-dispatcher.ts b/src/lib/safe-fetch-dispatcher.ts index 4ced48b20..739ea1348 100644 --- a/src/lib/safe-fetch-dispatcher.ts +++ b/src/lib/safe-fetch-dispatcher.ts @@ -1,7 +1,9 @@ import dns from "node:dns"; -import { isIP } from "node:net"; import { Agent } from "undici"; -import { isPublicIp } from "@/lib/validations/notifications"; +import { + isOperatorGrantableIp, + isPublicIp, +} from "@/lib/validations/notifications"; /** * Custom `undici.Agent` that resolves the request hostname literally, @@ -68,10 +70,14 @@ function pinnedLookupWithPolicy( callback(err, ""); return; } + // The operator-approved policy is not "anything that parses": a listed + // name that resolves to loopback, link-local or the metadata range is + // refused here exactly as an unlisted one would be. The grant opens the + // operator's own network, never the container or the cloud host. const allowed = addresses.filter((address) => policy === "public" ? isPublicIp(address.address) - : isIP(address.address) !== 0, + : isOperatorGrantableIp(address.address), ); if (allowed.length === 0) { const refused = new Error( @@ -165,10 +171,13 @@ export function getPinnedPublicDispatcher(): Agent { /** * Pinned dispatcher for an exact operator-approved origin. * - * Private addresses are permitted only after the Nightscout origin policy has - * matched the complete canonical scheme/host/port trust grant. Resolution is - * still performed once inside Undici's connector and the vetted answer set is + * Private addresses are permitted only after an exact-origin policy + * (`NIGHTSCOUT_PRIVATE_ORIGINS`, `NOTIFICATION_PRIVATE_ORIGINS`) has matched + * the complete canonical scheme/host/port trust grant. Resolution is still + * performed once inside Undici's connector and the vetted answer set is * pinned to the socket, so a later DNS answer cannot replace the destination. + * Loopback, unspecified, link-local and metadata answers are dropped + * regardless of the grant (`isOperatorGrantableIp`). */ export function getPinnedOperatorApprovedDispatcher(): Agent { cachedOperatorApproved ??= createPinnedDispatcher("operator-approved"); diff --git a/src/lib/validations/nightscout.ts b/src/lib/validations/nightscout.ts index 12d289a17..c7df58694 100644 --- a/src/lib/validations/nightscout.ts +++ b/src/lib/validations/nightscout.ts @@ -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, -): 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 { - const origins = new Set(); - 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 { diff --git a/src/lib/validations/notifications.ts b/src/lib/validations/notifications.ts index f7d1e0c36..1cc8e12e6 100644 --- a/src/lib/validations/notifications.ts +++ b/src/lib/validations/notifications.ts @@ -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