mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +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
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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