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

@@ -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,
});
});
});
+150 -1
View File
@@ -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<void>((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<void>((resolve) => server.close(() => resolve()));
}
}, 20_000);
});
@@ -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<typeof import("@/lib/validations/notifications")>();
return {
...actual,
isOperatorGrantableIp: (ip: string) =>
ip === "127.0.0.1" || actual.isOperatorGrantableIp(ip),
};
});
import { _resetPinnedDispatcherForTests } from "@/lib/safe-fetch-dispatcher";
import { fetchSgvEntries } from "../client";
+171
View File
@@ -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<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`, 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<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(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,
};
}
if (isPublicUrl(publicCheck)) {
return {
allowed: true,
canonicalOrigin: origin,
privateOriginApproved: false,
reasonCode: null,
};
}
return {
allowed: false,
canonicalOrigin: origin,
privateOriginApproved: false,
reasonCode: PRIVATE_ORIGIN_NOT_APPROVED,
};
}
+15 -6
View File
@@ -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");
+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