From ea3a8deed2c6aa516580a9fc9a65465d61d2e855 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 16:34:04 +0200 Subject: [PATCH 01/11] 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 --- .../__tests__/private-origin-policy.test.ts | 172 ++++++++++++++++++ .../__tests__/safe-fetch-dispatcher.test.ts | 151 ++++++++++++++- .../__tests__/client-pinned.test.ts | 16 +- src/lib/private-origin-policy.ts | 171 +++++++++++++++++ src/lib/safe-fetch-dispatcher.ts | 21 ++- src/lib/validations/nightscout.ts | 131 +++---------- src/lib/validations/notifications.ts | 56 ++++++ 7 files changed, 604 insertions(+), 114 deletions(-) create mode 100644 src/lib/__tests__/private-origin-policy.test.ts create mode 100644 src/lib/private-origin-policy.ts 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 From 6c75c4f39f5a269a7dde5c1f5caa180ec9e006b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 16:39:24 +0200 Subject: [PATCH 02/11] feat(notifications): honour NOTIFICATION_PRIVATE_ORIGINS on send A self-hoster's Gotify or ntfy on a private network was refused at dial time with no operator override: both senders pinned the public resolver, and the two grants that exist for other surfaces were never read here. Add the third grant, NOTIFICATION_PRIVATE_ORIGINS, a comma- separated list of exact origins read from the server environment. A listed origin runs under the operator-approved pin: still resolved and pinned inside the connector, redirects forbidden, loopback and metadata answers refused. An origin not listed keeps the public pin it always had. A malformed entry is logged once and grants nothing, and the valid entries beside it keep working, so a typo in one relay's origin cannot take every public webhook down with it. There is no `=true`, CIDR, wildcard or suffix form. One function, evaluateNotificationTarget, gives the verdict for both senders, so the test button and the scheduled reminder cannot disagree. A refusal now carries its own reason (`*_private_origin_refused`) and an errorCode instead of collapsing into a network error, and a send that used the grant marks the wide event with `notification.egress.private_origin` and the channel plus origin. The pin inventory keeps both senders in the conditional shape and refuses a bare true or false in either. Refs #947 --- src/lib/__tests__/ssrf-pin-inventory.test.ts | 32 ++- .../__tests__/egress-policy.test.ts | 168 ++++++++++++++ src/lib/notifications/egress-policy.ts | 105 +++++++++ src/lib/notifications/retry-policy.ts | 7 + .../senders/__tests__/ntfy.test.ts | 214 ++++++++++++++++++ .../senders/__tests__/webhook.test.ts | 125 +++++++++- src/lib/notifications/senders/ntfy.ts | 50 +++- src/lib/notifications/senders/webhook.ts | 53 ++++- 8 files changed, 725 insertions(+), 29 deletions(-) create mode 100644 src/lib/notifications/__tests__/egress-policy.test.ts create mode 100644 src/lib/notifications/egress-policy.ts create mode 100644 src/lib/notifications/senders/__tests__/ntfy.test.ts diff --git a/src/lib/__tests__/ssrf-pin-inventory.test.ts b/src/lib/__tests__/ssrf-pin-inventory.test.ts index b5b7d67c5..21e4886db 100644 --- a/src/lib/__tests__/ssrf-pin-inventory.test.ts +++ b/src/lib/__tests__/ssrf-pin-inventory.test.ts @@ -58,14 +58,30 @@ describe("SSRF requirePublicHost pin inventory", () => { ); }); - // v1.11.2 product-lead M2 (optional) — lock the PRE-EXISTING user/operator - // webhook pins too, so the full inventory is CI-guarded and a future edit - // that drops one of these reds here rather than shipping silently. - it("ntfy sender pins the user/operator-supplied server outbound", () => { - expect(read("notifications/senders/ntfy.ts")).toMatch( - /requirePublicHost:\s*true/, - ); - }); + // v1.11.2 locked the ntfy pin as an unconditional `true`. Since #947 both + // user-supplied notification targets carry the same conditional shape as + // Nightscout: the public pin unless the operator listed the exact origin + // in NOTIFICATION_PRIVATE_ORIGINS, in which case the operator-approved pin + // takes over. Neither a bare `true` (the grant would be dead) nor a bare + // `false` (the pin would be gone) may reappear. + it.each([ + ["webhook", "notifications/senders/webhook.ts"], + ["ntfy", "notifications/senders/ntfy.ts"], + ])( + "%s sender routes an exact operator-approved private origin through the private pin", + (_channel, rel) => { + const src = read(rel); + expect(src).toMatch( + /requirePublicHost:\s*!policy\.privateOriginApproved/, + ); + expect(src).toMatch( + /operatorApprovedPrivateOrigin:\s*policy\.canonicalOrigin/, + ); + expect(src).not.toMatch(/requirePublicHost:\s*(?:true|false)\b/); + // The verdict must come from the shared policy, never a local check. + expect(src).toMatch(/evaluateNotificationTarget\(/); + }, + ); it("local AI client keeps the pin CONDITIONAL (LAN escape hatch)", () => { const src = read("ai/local-client.ts"); diff --git a/src/lib/notifications/__tests__/egress-policy.test.ts b/src/lib/notifications/__tests__/egress-policy.test.ts new file mode 100644 index 000000000..1818abd7b --- /dev/null +++ b/src/lib/notifications/__tests__/egress-policy.test.ts @@ -0,0 +1,168 @@ +/** + * `NOTIFICATION_PRIVATE_ORIGINS` (#947): the operator grant for a webhook or + * ntfy target on a private network. + * + * What is pinned here: the variable is read from the server environment and + * nowhere else, a malformed entry is logged once and grants nothing, a listed + * origin approves exactly itself, and a target that is not listed keeps the + * public floor it always had. The dial-time behaviour of an approved origin + * (pinned resolver, redirect ban, loopback floor) is proven in + * `safe-fetch.test.ts` and `safe-fetch-dispatcher.test.ts`; the senders' + * use of the verdict in their own suites. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + configuredNotificationPrivateOrigins, + evaluateNotificationTarget, + isAllowedNotificationTarget, +} from "../egress-policy"; + +const ORIGINAL = process.env.NOTIFICATION_PRIVATE_ORIGINS; + +beforeEach(() => { + delete process.env.NOTIFICATION_PRIVATE_ORIGINS; +}); + +afterEach(() => { + vi.restoreAllMocks(); + if (ORIGINAL === undefined) { + delete process.env.NOTIFICATION_PRIVATE_ORIGINS; + } else { + process.env.NOTIFICATION_PRIVATE_ORIGINS = ORIGINAL; + } +}); + +describe("configuredNotificationPrivateOrigins", () => { + it("grants nothing when the variable is unset or blank", () => { + expect(configuredNotificationPrivateOrigins()).toEqual(new Set()); + process.env.NOTIFICATION_PRIVATE_ORIGINS = " , "; + expect(configuredNotificationPrivateOrigins()).toEqual(new Set()); + }); + + it("parses exact origins and follows a changed value", () => { + process.env.NOTIFICATION_PRIVATE_ORIGINS = + "https://gotify.example.com, http://ntfy.lan:8080"; + expect(configuredNotificationPrivateOrigins()).toEqual( + new Set(["https://gotify.example.com", "http://ntfy.lan:8080"]), + ); + + process.env.NOTIFICATION_PRIVATE_ORIGINS = "http://10.0.0.5:8080"; + expect(configuredNotificationPrivateOrigins()).toEqual( + new Set(["http://10.0.0.5:8080"]), + ); + }); + + it("logs a malformed entry once, skips it, and keeps the valid ones", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + process.env.NOTIFICATION_PRIVATE_ORIGINS = + "https://gotify.lan, https://*.lan, http://127.0.0.1:8080"; + + expect(configuredNotificationPrivateOrigins()).toEqual( + new Set(["https://gotify.lan"]), + ); + expect(configuredNotificationPrivateOrigins()).toEqual( + new Set(["https://gotify.lan"]), + ); + + // One line per malformed entry, written on the first parse only. + expect(warn).toHaveBeenCalledTimes(2); + const lines = warn.mock.calls.map((call) => String(call[0])); + expect(lines[0]).toContain("NOTIFICATION_PRIVATE_ORIGINS"); + expect(lines[0]).toContain("https://*.lan"); + expect(lines[1]).toContain("http://127.0.0.1:8080"); + }); + + it("never widens on a wholly malformed value", () => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + process.env.NOTIFICATION_PRIVATE_ORIGINS = "10.0.0.0/8, *.lan, true"; + expect(configuredNotificationPrivateOrigins()).toEqual(new Set()); + }); +}); + +describe("evaluateNotificationTarget", () => { + it("approves a target under a listed origin, path and all", () => { + process.env.NOTIFICATION_PRIVATE_ORIGINS = "https://gotify.example.com"; + expect( + evaluateNotificationTarget("https://gotify.example.com/message?token=x"), + ).toEqual({ + allowed: true, + canonicalOrigin: "https://gotify.example.com", + privateOriginApproved: true, + reasonCode: null, + }); + }); + + it("approves a listed literal private address the input-time floor would refuse", () => { + process.env.NOTIFICATION_PRIVATE_ORIGINS = "http://10.0.0.5:8080"; + expect(evaluateNotificationTarget("http://10.0.0.5:8080/message")).toEqual({ + allowed: true, + canonicalOrigin: "http://10.0.0.5:8080", + privateOriginApproved: true, + reasonCode: null, + }); + expect(isAllowedNotificationTarget("http://10.0.0.5:8080/message")).toBe( + true, + ); + }); + + it.each([ + ["sibling port", "http://10.0.0.5:9000/message"], + ["other scheme", "https://10.0.0.5:8080/message"], + ["neighbouring address", "http://10.0.0.6:8080/message"], + ["sub-host of a listed name", "https://push.gotify.example.com/message"], + ["unlisted private literal", "http://192.168.1.9/message"], + ])("refuses a %s: a grant names one origin only", (_label, url) => { + process.env.NOTIFICATION_PRIVATE_ORIGINS = + "http://10.0.0.5:8080, https://gotify.example.com"; + expect(evaluateNotificationTarget(url)).toMatchObject({ + allowed: false, + privateOriginApproved: false, + reasonCode: "private_origin_not_approved", + }); + expect(isAllowedNotificationTarget(url)).toBe(false); + }); + + it("keeps a public target functional and unapproved (the pin stays on)", () => { + process.env.NOTIFICATION_PRIVATE_ORIGINS = "https://gotify.example.com"; + expect(evaluateNotificationTarget("https://ntfy.sh/topic")).toEqual({ + allowed: true, + canonicalOrigin: "https://ntfy.sh", + privateOriginApproved: false, + reasonCode: null, + }); + }); + + it("keeps the raw-string floor: an octal loopback spelling is refused, not parsed", () => { + expect( + evaluateNotificationTarget("http://0177.0.0.1/message"), + ).toMatchObject({ + allowed: false, + reasonCode: "private_origin_not_approved", + }); + }); + + it("refuses an unparseable target as invalid", () => { + expect(evaluateNotificationTarget("not a url")).toEqual({ + allowed: false, + canonicalOrigin: null, + privateOriginApproved: false, + reasonCode: "invalid_origin", + }); + expect( + evaluateNotificationTarget("ftp://gotify.lan/message"), + ).toMatchObject({ allowed: false, reasonCode: "invalid_origin" }); + }); + + it("lets an unlisted private DNS name through to the pinned resolver, as before", () => { + // Nothing widens and nothing narrows for a name the operator did not + // list: input time cannot tell gotify.lan from a public name, so the + // verdict is the same as before #947 and the connect-time pin decides. + expect(evaluateNotificationTarget("https://gotify.lan/message")).toEqual({ + allowed: true, + canonicalOrigin: "https://gotify.lan", + privateOriginApproved: false, + reasonCode: null, + }); + }); +}); diff --git a/src/lib/notifications/egress-policy.ts b/src/lib/notifications/egress-policy.ts new file mode 100644 index 000000000..8afce08ed --- /dev/null +++ b/src/lib/notifications/egress-policy.ts @@ -0,0 +1,105 @@ +import { + evaluateOriginGrant, + INVALID_ORIGIN, + originOfUrl, + parsePrivateOrigins, + PRIVATE_ORIGIN_NOT_APPROVED, + type OriginVerdict, +} from "@/lib/private-origin-policy"; +import { annotate } from "@/lib/logging/context"; + +/** + * `NOTIFICATION_PRIVATE_ORIGINS` (#947) — the operator grant that lets the + * generic webhook and ntfy senders reach a relay on the operator's own + * network (a Gotify behind a LAN reverse proxy, a self-hosted ntfy on + * Tailscale). + * + * Both senders dial through `safeFetch` with the connect-time DNS pin, which + * refuses any destination that resolves to a private address. That is the + * DNS-rebinding defence (#217) and it is deliberate: a public-looking name + * with a LAN A record is exactly what it stops. The grant is the operator's + * way to say "this one origin is mine": comma-separated exact origins, + * + * NOTIFICATION_PRIVATE_ORIGINS="https://gotify.example.com,http://ntfy.lan:8080" + * + * A listed origin is still dialled through the pinned resolver with redirects + * forbidden; loopback, link-local and the metadata range stay refused even + * when listed. Nothing not listed widens, and there is no `=true`, CIDR, + * wildcard or suffix form. The grant lives in the server environment only — + * no request field and no settings toggle can confer it. + * + * The variable is read directly from `process.env` so the compose-whitelist + * guard sees the read. It is parsed once per distinct value: a malformed + * entry is logged once and grants nothing, and the valid entries beside it + * keep working, because a typo in one relay's origin must not take every + * public webhook on the instance down with it. + */ + +const ENV_NAME = "NOTIFICATION_PRIVATE_ORIGINS"; + +let cachedRaw: string | undefined; +let cachedOrigins: ReadonlySet = new Set(); + +export function configuredNotificationPrivateOrigins(): ReadonlySet { + const raw = process.env.NOTIFICATION_PRIVATE_ORIGINS; + if (raw === cachedRaw) return cachedOrigins; + cachedOrigins = parsePrivateOrigins(raw, (entry) => { + console.warn( + `${ENV_NAME}: ignoring entry "${entry}" — a grant is one exact http(s) origin (scheme://host[:port]) on a private network; loopback, link-local and metadata addresses cannot be granted`, + ); + }); + cachedRaw = raw; + return cachedOrigins; +} + +/** + * Resolve one webhook or ntfy target URL against the grant and the public + * floor. The target may carry a path (Gotify's `/message`, an ntfy topic); + * only its origin is compared to the grant set. The full URL goes to the + * public check so the raw-string guards in `isPublicUrl` (octal and hex + * address spellings) see what the user typed. + * + * One function for every caller — the settings save, the test button and the + * dispatcher's senders — so the decision cannot differ between the button + * that says "works" and the reminder that then does not arrive. + */ +export function evaluateNotificationTarget(url: string): OriginVerdict { + const origin = originOfUrl(url); + if (!origin) { + return { + allowed: false, + canonicalOrigin: null, + privateOriginApproved: false, + reasonCode: INVALID_ORIGIN, + }; + } + return evaluateOriginGrant( + origin, + configuredNotificationPrivateOrigins(), + url, + ); +} + +/** Predicate form for the Zod refine on the save routes. */ +export function isAllowedNotificationTarget(url: string): boolean { + return evaluateNotificationTarget(url).allowed; +} + +export { PRIVATE_ORIGIN_NOT_APPROVED }; + +/** + * Wide-event mark for a send that used the grant: one row per private + * egress, pinned to `{ channel, origin }` so a dashboard can count them and + * an operator can see which grant is actually in use. The origin is the + * canonical grant string, never the full target URL, which may carry a + * token in its path or query. + */ +export function annotatePrivateOriginEgress( + channel: "webhook" | "ntfy", + origin: string, +): void { + annotate({ + action: { name: "notification.egress.private_origin" }, + meta: { channel, origin }, + }); +} diff --git a/src/lib/notifications/retry-policy.ts b/src/lib/notifications/retry-policy.ts index 604007db7..6dbdfe8d8 100644 --- a/src/lib/notifications/retry-policy.ts +++ b/src/lib/notifications/retry-policy.ts @@ -35,6 +35,13 @@ export interface SendOutcome { reason?: string; /** Human-readable error fragment for logs (NOT for the disabled_reason column). */ message?: string; + /** + * Stable machine code an API surface may forward as `meta.errorCode`. Set + * only for a refusal the caller can act on — today the private-origin + * policy (#947), which the test routes answer with 422 and a reason + * instead of a bare 500. + */ + errorCode?: string; } /** diff --git a/src/lib/notifications/senders/__tests__/ntfy.test.ts b/src/lib/notifications/senders/__tests__/ntfy.test.ts new file mode 100644 index 000000000..a80a8a202 --- /dev/null +++ b/src/lib/notifications/senders/__tests__/ntfy.test.ts @@ -0,0 +1,214 @@ +/** + * ntfy sender unit tests. + * + * Covers the outbound shape (topic appended to the server URL, priority and + * tag headers, bearer token), the outcome classification, and (#947) the + * NOTIFICATION_PRIVATE_ORIGINS grant: a listed server rides the + * operator-approved pin, an unlisted one keeps the public pin, and a listed + * origin admits nothing beside itself. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const safeFetchMock = vi.fn(); +const recordPushAttemptMock = vi.fn(); +const annotateMock = vi.fn(); + +vi.mock("@/lib/safe-fetch", () => ({ + safeFetch: (...args: unknown[]) => safeFetchMock(...args), + SafeFetchError: class SafeFetchError extends Error { + kind: string; + constructor(message: string, kind: string) { + super(message); + this.kind = kind; + } + }, +})); + +vi.mock("@/lib/logging/context", () => ({ + getEvent: () => ({ addWarning: vi.fn(), addExternalCall: vi.fn() }), + annotate: (...args: unknown[]) => annotateMock(...args), +})); + +vi.mock("@/lib/notifications/senders/push-attempt-record", () => ({ + recordPushAttempt: (...args: unknown[]) => recordPushAttemptMock(...args), + recordPushAttemptForPayload: ( + _payload: unknown, + _recipientUserId: string, + attempt: unknown, + ) => recordPushAttemptMock(attempt), +})); + +import { sendViaNtfy } from "../ntfy"; +import { SafeFetchError } from "@/lib/safe-fetch"; + +const config = { + serverUrl: "https://ntfy.example.com/", + topic: "health", + authToken: "tk_secret", +}; + +function payload(over?: Record) { + return { + eventType: "SYSTEM_ALERT" as const, + userId: "user-1", + title: "Title", + message: "Body", + ...over, + }; +} + +type EgressOpts = { + requirePublicHost?: boolean; + operatorApprovedPrivateOrigin?: string; +}; + +const ORIGINAL_PRIVATE_ORIGINS = process.env.NOTIFICATION_PRIVATE_ORIGINS; + +beforeEach(() => { + safeFetchMock.mockReset(); + recordPushAttemptMock.mockReset(); + annotateMock.mockReset(); + delete process.env.NOTIFICATION_PRIVATE_ORIGINS; +}); + +afterEach(() => { + vi.clearAllMocks(); + if (ORIGINAL_PRIVATE_ORIGINS === undefined) { + delete process.env.NOTIFICATION_PRIVATE_ORIGINS; + } else { + process.env.NOTIFICATION_PRIVATE_ORIGINS = ORIGINAL_PRIVATE_ORIGINS; + } +}); + +describe("sendViaNtfy", () => { + it("POSTs the topic URL through safeFetch under the public pin and returns ok on 200", async () => { + safeFetchMock.mockResolvedValue({ ok: true, status: 200 }); + + const result = await sendViaNtfy(config, payload()); + + expect(result).toEqual({ ok: true, statusCode: 200 }); + const [url, init, opts] = safeFetchMock.mock.calls[0] as [ + string, + { method?: string; headers?: Record; body?: string }, + EgressOpts, + ]; + expect(url).toBe("https://ntfy.example.com/health"); + expect(init.method).toBe("POST"); + expect(init.headers?.Authorization).toBe("Bearer tk_secret"); + expect(init.body).toBe("Body"); + expect(opts.requirePublicHost).toBe(true); + expect(opts.operatorApprovedPrivateOrigin).toBeUndefined(); + expect(recordPushAttemptMock).toHaveBeenCalledWith( + expect.objectContaining({ channel: "NTFY", result: "ok" }), + ); + }); + + it("hard-rejects on 410 (topic gone)", async () => { + safeFetchMock.mockResolvedValue({ ok: false, status: 410 }); + + const result = await sendViaNtfy(config, payload()); + + expect(result.ok).toBe(false); + expect(result.hardReject).toBe(true); + expect(result.reason).toBe("ntfy_410_gone"); + }); + + it("reports a soft, named refusal when safeFetch blocks a private host (SSRF)", async () => { + safeFetchMock.mockRejectedValue( + new SafeFetchError("refused private host", "private_host"), + ); + + const result = await sendViaNtfy(config, payload()); + + expect(result).toMatchObject({ + ok: false, + hardReject: false, + reason: "ntfy_private_origin_refused", + errorCode: "private_origin_not_approved", + }); + expect(recordPushAttemptMock).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "NTFY", + result: "error", + reason: "ntfy_private_origin_refused", + }), + ); + }); + + it("keeps a genuine network fault distinct from a policy refusal", async () => { + safeFetchMock.mockRejectedValue(new SafeFetchError("timeout", "timeout")); + + const result = await sendViaNtfy(config, payload()); + + expect(result.reason).toBe("ntfy_network_error"); + expect(result.errorCode).toBeUndefined(); + }); + + describe("NOTIFICATION_PRIVATE_ORIGINS (#947)", () => { + it("dials a listed server through the operator-approved pin and marks the event", async () => { + process.env.NOTIFICATION_PRIVATE_ORIGINS = "http://ntfy.lan:8080"; + safeFetchMock.mockResolvedValue({ ok: true, status: 200 }); + + const result = await sendViaNtfy( + { serverUrl: "http://ntfy.lan:8080", topic: "health" }, + payload(), + ); + + expect(result.ok).toBe(true); + const [url, , opts] = safeFetchMock.mock.calls[0] as [ + string, + unknown, + EgressOpts, + ]; + expect(url).toBe("http://ntfy.lan:8080/health"); + expect(opts.requirePublicHost).toBe(false); + expect(opts.operatorApprovedPrivateOrigin).toBe("http://ntfy.lan:8080"); + expect(annotateMock).toHaveBeenCalledWith({ + action: { name: "notification.egress.private_origin" }, + meta: { channel: "ntfy", origin: "http://ntfy.lan:8080" }, + }); + }); + + it("keeps the public pin for a server the operator did not list", async () => { + process.env.NOTIFICATION_PRIVATE_ORIGINS = "https://gotify.example.com"; + safeFetchMock.mockResolvedValue({ ok: true, status: 200 }); + + await sendViaNtfy(config, payload()); + + const [, , opts] = safeFetchMock.mock.calls[0] as [ + string, + unknown, + EgressOpts, + ]; + expect(opts.requirePublicHost).toBe(true); + expect(opts.operatorApprovedPrivateOrigin).toBeUndefined(); + expect(annotateMock).not.toHaveBeenCalled(); + }); + + it.each([ + ["sibling port", "http://ntfy.lan:9090"], + ["other scheme", "https://ntfy.lan:8080"], + ["sub-host", "http://push.ntfy.lan:8080"], + ["unlisted private literal", "http://192.168.1.9"], + ])( + "refuses a %s without dialling: a listed origin admits only itself", + async (_label, serverUrl) => { + process.env.NOTIFICATION_PRIVATE_ORIGINS = "http://ntfy.lan:8080"; + + const result = await sendViaNtfy( + { serverUrl, topic: "health" }, + payload(), + ); + + expect(safeFetchMock).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + ok: false, + hardReject: false, + reason: "ntfy_private_origin_refused", + errorCode: "private_origin_not_approved", + }); + expect(annotateMock).not.toHaveBeenCalled(); + }, + ); + }); +}); diff --git a/src/lib/notifications/senders/__tests__/webhook.test.ts b/src/lib/notifications/senders/__tests__/webhook.test.ts index aab98366d..6655238cb 100644 --- a/src/lib/notifications/senders/__tests__/webhook.test.ts +++ b/src/lib/notifications/senders/__tests__/webhook.test.ts @@ -3,7 +3,10 @@ * * Covers: success, hard-reject classification (404/410/401/403), SSRF block * (safeFetch throws `private_host`), cooldown-relevant transient classification - * (5xx), and the push_attempts ledger write per outcome. + * (5xx), the push_attempts ledger write per outcome, and (#947) the + * NOTIFICATION_PRIVATE_ORIGINS grant: a listed origin rides the + * operator-approved pin, an unlisted one keeps the public pin, and a listed + * origin admits nothing beside itself. */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -21,8 +24,10 @@ vi.mock("@/lib/safe-fetch", () => ({ }, })); +const annotateMock = vi.fn(); vi.mock("@/lib/logging/context", () => ({ getEvent: () => ({ addWarning: vi.fn(), addExternalCall: vi.fn() }), + annotate: (...args: unknown[]) => annotateMock(...args), })); vi.mock("@/lib/notifications/senders/push-attempt-record", () => ({ @@ -53,15 +58,29 @@ function payload(over?: Record) { }; } +const ORIGINAL_PRIVATE_ORIGINS = process.env.NOTIFICATION_PRIVATE_ORIGINS; + beforeEach(() => { safeFetchMock.mockReset(); recordPushAttemptMock.mockReset(); + annotateMock.mockReset(); + delete process.env.NOTIFICATION_PRIVATE_ORIGINS; }); afterEach(() => { vi.clearAllMocks(); + if (ORIGINAL_PRIVATE_ORIGINS === undefined) { + delete process.env.NOTIFICATION_PRIVATE_ORIGINS; + } else { + process.env.NOTIFICATION_PRIVATE_ORIGINS = ORIGINAL_PRIVATE_ORIGINS; + } }); +type EgressOpts = { + requirePublicHost?: boolean; + operatorApprovedPrivateOrigin?: string; +}; + describe("sendViaWebhook", () => { it("POSTs through safeFetch with requirePublicHost and returns ok on 200", async () => { safeFetchMock.mockResolvedValue({ ok: true, status: 200 }); @@ -115,7 +134,10 @@ describe("sendViaWebhook", () => { expect(result.reason).toBe("webhook_503"); }); - it("returns a transient network error when safeFetch blocks a private host (SSRF)", async () => { + it("reports a soft, named refusal when safeFetch blocks a private host (SSRF)", async () => { + // Before #947 this collapsed into `webhook_network_error` and the test + // button answered a bare 500. The refusal stays soft (delivery resumes + // the moment the operator lists the origin) but names itself. safeFetchMock.mockRejectedValue( new SafeFetchError("refused private host", "private_host"), ); @@ -124,16 +146,28 @@ describe("sendViaWebhook", () => { expect(result.ok).toBe(false); expect(result.hardReject).toBe(false); - expect(result.reason).toBe("webhook_network_error"); + expect(result.reason).toBe("webhook_private_origin_refused"); + expect(result.errorCode).toBe("private_origin_not_approved"); expect(recordPushAttemptMock).toHaveBeenCalledWith( expect.objectContaining({ channel: "WEBHOOK", result: "error", - reason: "webhook_network_error", + reason: "webhook_private_origin_refused", }), ); }); + it("keeps a genuine network fault distinct from a policy refusal", async () => { + safeFetchMock.mockRejectedValue( + new SafeFetchError("socket hang up", "network"), + ); + + const result = await sendViaWebhook(config, payload()); + + expect(result.reason).toBe("webhook_network_error"); + expect(result.errorCode).toBeUndefined(); + }); + it("omits the custom header when not configured", async () => { safeFetchMock.mockResolvedValue({ ok: true, status: 200 }); await sendViaWebhook({ url: "https://example.com/hook" }, payload()); @@ -142,4 +176,87 @@ describe("sendViaWebhook", () => { (init as { headers?: Record }).headers?.Authorization, ).toBeUndefined(); }); + + describe("NOTIFICATION_PRIVATE_ORIGINS (#947)", () => { + it("dials a listed origin through the operator-approved pin and marks the event", async () => { + process.env.NOTIFICATION_PRIVATE_ORIGINS = + "https://gotify.example.com, http://ntfy.lan:8080"; + safeFetchMock.mockResolvedValue({ ok: true, status: 200 }); + + const result = await sendViaWebhook(config, payload()); + + expect(result.ok).toBe(true); + const [url, , opts] = safeFetchMock.mock.calls[0] as [ + string, + unknown, + EgressOpts, + ]; + expect(url).toBe("https://gotify.example.com/message"); + expect(opts.requirePublicHost).toBe(false); + expect(opts.operatorApprovedPrivateOrigin).toBe( + "https://gotify.example.com", + ); + expect(annotateMock).toHaveBeenCalledWith({ + action: { name: "notification.egress.private_origin" }, + meta: { channel: "webhook", origin: "https://gotify.example.com" }, + }); + }); + + it("keeps the public pin for a target the operator did not list", async () => { + process.env.NOTIFICATION_PRIVATE_ORIGINS = "http://ntfy.lan:8080"; + safeFetchMock.mockResolvedValue({ ok: true, status: 200 }); + + await sendViaWebhook(config, payload()); + + const [, , opts] = safeFetchMock.mock.calls[0] as [ + string, + unknown, + EgressOpts, + ]; + expect(opts.requirePublicHost).toBe(true); + expect(opts.operatorApprovedPrivateOrigin).toBeUndefined(); + expect(annotateMock).not.toHaveBeenCalled(); + }); + + it.each([ + ["sibling port", "https://gotify.example.com:8443/message"], + ["other scheme", "http://gotify.example.com/message"], + ["sub-host", "https://push.gotify.example.com/message"], + ["unlisted private literal", "http://10.0.0.9/message"], + ])( + "refuses a %s without dialling: a listed origin admits only itself", + async (_label, url) => { + process.env.NOTIFICATION_PRIVATE_ORIGINS = "https://gotify.example.com"; + + const result = await sendViaWebhook({ url }, payload()); + + expect(safeFetchMock).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + ok: false, + hardReject: false, + reason: "webhook_private_origin_refused", + errorCode: "private_origin_not_approved", + }); + expect(annotateMock).not.toHaveBeenCalled(); + }, + ); + + it("approves a listed literal private address the input floor alone would refuse", async () => { + process.env.NOTIFICATION_PRIVATE_ORIGINS = "http://10.0.0.9:8080"; + safeFetchMock.mockResolvedValue({ ok: true, status: 200 }); + + const result = await sendViaWebhook( + { url: "http://10.0.0.9:8080/message" }, + payload(), + ); + + expect(result.ok).toBe(true); + const [, , opts] = safeFetchMock.mock.calls[0] as [ + string, + unknown, + EgressOpts, + ]; + expect(opts.operatorApprovedPrivateOrigin).toBe("http://10.0.0.9:8080"); + }); + }); }); diff --git a/src/lib/notifications/senders/ntfy.ts b/src/lib/notifications/senders/ntfy.ts index ea966adac..7c601420c 100644 --- a/src/lib/notifications/senders/ntfy.ts +++ b/src/lib/notifications/senders/ntfy.ts @@ -6,10 +6,15 @@ import type { SendOutcome } from "@/lib/notifications/retry-policy"; import { classifyHttpStatus } from "@/lib/notifications/retry-policy"; import { getEvent } from "@/lib/logging/context"; import { recordPushAttemptForPayload } from "@/lib/notifications/senders/push-attempt-record"; -import { safeFetch } from "@/lib/safe-fetch"; +import { safeFetch, SafeFetchError } from "@/lib/safe-fetch"; import { plainPushText } from "@/lib/notifications/strip-emoji"; import { stripHtml } from "@/lib/notifications/strip-html"; import { isUrgentPayload } from "@/lib/notifications/types"; +import { + annotatePrivateOriginEgress, + evaluateNotificationTarget, + PRIVATE_ORIGIN_NOT_APPROVED, +} from "@/lib/notifications/egress-policy"; /** * Send notification via ntfy (simple HTTP POST). @@ -60,11 +65,24 @@ export async function sendViaNtfy( // Strip HTML tags for ntfy (plain text only) + emoji on routine reminders const body = plainPushText(stripHtml(payload.message), payload.eventType); - // requirePublicHost adds the DNS-rebinding pin (issue #217) on top - // of the input-time isPublicUrl guard already enforced when the - // user saved the ntfy serverUrl in their channel config. The - // authToken would otherwise leak on a rebinding flip to a private - // address. + // The DNS-rebinding pin (issue #217) sits on top of the input-time + // guard enforced when the user saved the serverUrl; the authToken would + // otherwise leak on a rebinding flip to a private address. A server the + // operator listed in NOTIFICATION_PRIVATE_ORIGINS (#947) runs under the + // operator-approved pin instead — same resolver pin, redirects + // forbidden, loopback and metadata refused. Same verdict function as + // the settings routes and the webhook sender. + const policy = evaluateNotificationTarget(url); + if (!policy.allowed || !policy.canonicalOrigin) { + throw new SafeFetchError( + "ntfy server refused by the notification origin policy", + "private_host", + ); + } + if (policy.privateOriginApproved) { + annotatePrivateOriginEgress("ntfy", policy.canonicalOrigin); + } + const res = await safeFetch( url, { @@ -72,7 +90,13 @@ export async function sendViaNtfy( headers, body, }, - { timeoutMs: 5_000, requirePublicHost: true }, + { + timeoutMs: 5_000, + requirePublicHost: !policy.privateOriginApproved, + ...(policy.privateOriginApproved + ? { operatorApprovedPrivateOrigin: policy.canonicalOrigin } + : {}), + }, ); getEvent()?.addExternalCall({ @@ -107,6 +131,13 @@ export async function sendViaNtfy( }; } catch (err) { const message = err instanceof Error ? err.message : "request_failed"; + // A policy refusal is not a network fault (see the webhook sender): soft + // classification, its own reason, and a code the test route can forward. + const policyRefused = + err instanceof SafeFetchError && err.kind === "private_host"; + const reason = policyRefused + ? "ntfy_private_origin_refused" + : "ntfy_network_error"; getEvent()?.addExternalCall({ service: "ntfy", method: "sendNotification", @@ -118,13 +149,14 @@ export async function sendViaNtfy( channel: "NTFY", eventType: payload.eventType, result: "error", - reason: "ntfy_network_error", + reason, }); return { ok: false, hardReject: false, - reason: "ntfy_network_error", + reason, message, + ...(policyRefused ? { errorCode: PRIVATE_ORIGIN_NOT_APPROVED } : {}), }; } } diff --git a/src/lib/notifications/senders/webhook.ts b/src/lib/notifications/senders/webhook.ts index 34de62f72..74483c9b0 100644 --- a/src/lib/notifications/senders/webhook.ts +++ b/src/lib/notifications/senders/webhook.ts @@ -6,9 +6,14 @@ import type { SendOutcome } from "@/lib/notifications/retry-policy"; import { classifyHttpStatus } from "@/lib/notifications/retry-policy"; import { getEvent } from "@/lib/logging/context"; import { recordPushAttemptForPayload } from "@/lib/notifications/senders/push-attempt-record"; -import { safeFetch } from "@/lib/safe-fetch"; +import { safeFetch, SafeFetchError } from "@/lib/safe-fetch"; import { plainPushText } from "@/lib/notifications/strip-emoji"; import { stripHtml } from "@/lib/notifications/strip-html"; +import { + annotatePrivateOriginEgress, + evaluateNotificationTarget, + PRIVATE_ORIGIN_NOT_APPROVED, +} from "@/lib/notifications/egress-policy"; /** * Send a notification via a generic outbound webhook (v1.17.1). @@ -20,10 +25,15 @@ import { stripHtml } from "@/lib/notifications/strip-html"; * `title`/`message` fields are stripped of HTML + decorative emoji on routine * reminders exactly like the ntfy sender. * - * Outbound goes through `safeFetch({ requirePublicHost: true })`: the SSRF - * floor plus the connect-time DNS-rebinding pin apply because the host is - * user-supplied. The optional shared-secret header would otherwise leak on a - * rebinding flip to a private address. + * Outbound goes through `safeFetch` with the connect-time DNS-rebinding pin + * because the host is user-supplied; the optional shared-secret header would + * otherwise leak on a rebinding flip to a private address. A public target + * runs under the public pin. An origin the operator listed in + * `NOTIFICATION_PRIVATE_ORIGINS` (#947) runs under the operator-approved pin + * instead: still resolved and pinned inside the connector, redirects + * forbidden, loopback and metadata answers refused. The verdict comes from + * `evaluateNotificationTarget`, the same function the settings routes use, + * so the test button and the dispatcher cannot disagree. * * Returns a `SendOutcome` so the dispatcher can distinguish hard rejects * (404/410 endpoint gone, 401/403 secret wrong) from soft errors (5xx, 429, @@ -63,6 +73,17 @@ export async function sendViaWebhook( : "default", }); + const policy = evaluateNotificationTarget(config.url); + if (!policy.allowed || !policy.canonicalOrigin) { + throw new SafeFetchError( + "webhook target refused by the notification origin policy", + "private_host", + ); + } + if (policy.privateOriginApproved) { + annotatePrivateOriginEgress("webhook", policy.canonicalOrigin); + } + const res = await safeFetch( config.url, { @@ -70,7 +91,13 @@ export async function sendViaWebhook( headers, body, }, - { timeoutMs: 5_000, requirePublicHost: true }, + { + timeoutMs: 5_000, + requirePublicHost: !policy.privateOriginApproved, + ...(policy.privateOriginApproved + ? { operatorApprovedPrivateOrigin: policy.canonicalOrigin } + : {}), + }, ); getEvent()?.addExternalCall({ @@ -105,6 +132,15 @@ export async function sendViaWebhook( }; } catch (err) { const message = err instanceof Error ? err.message : "request_failed"; + // A policy refusal is not a network fault: the target is on a private + // network the operator has not listed. It keeps the soft classification + // (delivery resumes the moment the origin is listed) but carries its own + // reason so the ledger and the test button can say why. + const policyRefused = + err instanceof SafeFetchError && err.kind === "private_host"; + const reason = policyRefused + ? "webhook_private_origin_refused" + : "webhook_network_error"; getEvent()?.addExternalCall({ service: "webhook", method: "sendNotification", @@ -116,13 +152,14 @@ export async function sendViaWebhook( channel: "WEBHOOK", eventType: payload.eventType, result: "error", - reason: "webhook_network_error", + reason, }); return { ok: false, hardReject: false, - reason: "webhook_network_error", + reason, message, + ...(policyRefused ? { errorCode: PRIVATE_ORIGIN_NOT_APPROVED } : {}), }; } } From 984a75d036533be37b593df3a7f7800d58e5d261 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 16:46:13 +0200 Subject: [PATCH 03/11] fix(api): name the private-origin refusal on webhook and ntfy routes The save routes evaluate the same policy the senders do. A target on a private network the operator has not listed is a 422 with meta.errorCode = private_origin_not_approved and a message that names NOTIFICATION_PRIVATE_ORIGINS, instead of the generic "Invalid data"; a listed private address saves although the plain public floor would have refused it. The settings schemas take the target predicate as a parameter so the validator module never reads the environment; the exported constants keep the public floor for the OpenAPI contract. The test routes forward the sender's refusal as the same 422 and code instead of a bare 500, so the card can say why the test failed rather than sending the operator to the wide-event log. A delivery fault is still a 500. Both test routes and the dispatcher reach the same sender function, and a structural test holds them there. The OpenAPI descriptions name the code on all four routes; the test button's error map gains the code in every locale. Refs #947 --- docs/api/openapi.yaml | 43 +- messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/ko.json | 1 + messages/pl.json | 1 + .../__tests__/private-origin-routes.test.ts | 381 ++++++++++++++++++ src/app/api/settings/ntfy/route.ts | 35 +- src/app/api/settings/ntfy/test/route.ts | 14 + src/app/api/settings/webhook/route.ts | 46 ++- src/app/api/settings/webhook/test/route.ts | 14 + .../__tests__/private-origin-policy.test.ts | 12 + src/lib/openapi/routes/settings.ts | 22 +- src/lib/private-origin-policy.ts | 11 +- src/lib/validations/notifications.ts | 85 ++-- 17 files changed, 614 insertions(+), 56 deletions(-) create mode 100644 src/app/api/settings/__tests__/private-origin-routes.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 0acb08f3d..e468f3627 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -11969,7 +11969,8 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": description: Body matched neither accepted shape, or `enabled` is true while the stored config has no server URL and - topic. + topic; or the server URL is on a private network the operator has not listed in + `NOTIFICATION_PRIVATE_ORIGINS` (`meta.errorCode` = `private_origin_not_approved`). Nothing was stored. content: application/json: schema: @@ -12094,7 +12095,9 @@ paths: "413": *a16 "415": *a17 "422": - description: Body matched neither accepted shape, or `enabled` is true while no webhook URL is stored. + description: Body matched neither accepted shape, or `enabled` is true while no webhook URL is stored; or the URL is on + a private network the operator has not listed in `NOTIFICATION_PRIVATE_ORIGINS` (`meta.errorCode` = + `private_origin_not_approved`). Nothing was stored. content: application/json: schema: @@ -12311,7 +12314,9 @@ paths: summary: Send a test message to the saved ntfy topic description: Publishes one message to the account's configured ntfy server and topic. Takes no body. Rate-limited 5 per 5 minutes per user. Refuses with 400 when no ntfy channel is saved or when the saved config is missing a server - URL or topic; a publish failure is a 500. + URL or topic; a publish failure is a 500. A server on a private network is refused with 422 and `meta.errorCode` + = `private_origin_not_approved` unless the operator listed its exact origin in `NOTIFICATION_PRIVATE_ORIGINS`; + the test and the scheduled delivery take the same decision. responses: "200": description: The provider accepted the message. Not proof it was delivered. @@ -12320,7 +12325,13 @@ paths: schema: $ref: "#/components/schemas/NtfyTestEnvelope" "401": *a1 - "422": *a4 + "422": + description: The saved server is on a private network the operator has not approved (`meta.errorCode` = + `private_origin_not_approved`). The resolved address is never echoed. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 /api/settings/telegram/test: post: @@ -12353,7 +12364,9 @@ paths: description: Posts one payload to the account's configured webhook URL. Takes no body. Rate-limited 5 per 5 minutes per user. Refuses with 400 when no webhook channel is saved or the saved config has no URL; a delivery failure is a 500. The URL was checked against the SSRF floor when it was saved, so a test cannot be used to reach an internal - host. + host. A URL on a private network is refused with 422 and `meta.errorCode` = `private_origin_not_approved` unless + the operator listed its exact origin in `NOTIFICATION_PRIVATE_ORIGINS`; the test and the scheduled delivery take + the same decision. responses: "200": description: The provider accepted the message. Not proof it was delivered. @@ -12362,7 +12375,13 @@ paths: schema: $ref: "#/components/schemas/WebhookTestEnvelope" "401": *a1 - "422": *a4 + "422": + description: The saved URL is on a private network the operator has not approved (`meta.errorCode` = + `private_origin_not_approved`). The resolved address is never echoed. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 /api/settings/data: delete: @@ -25600,9 +25619,10 @@ components: required: - enabled description: "ntfy channel config. `serverUrl` must pass the SSRF floor — a URL resolving into a private range is - refused at input time. An EMPTY OR OMITTED `authToken` preserves the stored one rather than clearing it: the GET - never returns the secret, so a client round-tripping the form would otherwise wipe it on every unrelated save. - Sending a non-empty value replaces it. There is no way to clear a stored auth token through this endpoint." + refused at input time unless the operator listed its exact origin in `NOTIFICATION_PRIVATE_ORIGINS`. An EMPTY OR + OMITTED `authToken` preserves the stored one rather than clearing it: the GET never returns the secret, so a + client round-tripping the form would otherwise wipe it on every unrelated save. Sending a non-empty value + replaces it. There is no way to clear a stored auth token through this endpoint." TelegramSettingsPutRequest: anyOf: - $ref: "#/components/schemas/NotificationChannelEnabledRequest" @@ -25660,8 +25680,9 @@ components: - enabled description: "Generic-webhook channel config — one channel covering Gotify, Discord, Slack, a Matrix bridge, Home Assistant, or any relay accepting an inbound JSON POST. `url` must pass the SSRF floor at input time and is - re-checked at dispatch time. An EMPTY OR OMITTED `headerValue` preserves the stored one, for the same reason as - ntfy's `authToken`: the GET never returns it. There is no way to clear a stored header value through this + re-checked at dispatch time; a private origin passes both only when the operator listed it in + `NOTIFICATION_PRIVATE_ORIGINS`. An EMPTY OR OMITTED `headerValue` preserves the stored one, for the same reason + as ntfy's `authToken`: the GET never returns it. There is no way to clear a stored header value through this endpoint." DeleteAccountRequest: type: object diff --git a/messages/de.json b/messages/de.json index fca6667b9..bfb2c3f93 100644 --- a/messages/de.json +++ b/messages/de.json @@ -5990,6 +5990,7 @@ "connection_failed": "Verbindung fehlgeschlagen", "not_configured": "Nicht konfiguriert", "url_not_public": "URL ist kein öffentlicher Endpunkt", + "private_origin_not_approved": "Ziel im privaten Netz. Der Betreiber muss diesen Origin in NOTIFICATION_PRIVATE_ORIGINS eintragen", "url_invalid": "URL ist ungültig", "redirected": "Endpunkt leitet weiter — URL prüfen", "endpoint_not_found": "Endpunkt unter der URL nicht gefunden", diff --git a/messages/en.json b/messages/en.json index 5891d7ddf..5ad731168 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5990,6 +5990,7 @@ "connection_failed": "Connection failed", "not_configured": "Not configured", "url_not_public": "URL is not a public endpoint", + "private_origin_not_approved": "Private network target. The operator has to list this origin in NOTIFICATION_PRIVATE_ORIGINS", "url_invalid": "URL is invalid", "redirected": "Endpoint redirects — check the URL", "endpoint_not_found": "Endpoint not found at the URL", diff --git a/messages/es.json b/messages/es.json index 990f5c414..cf5293105 100644 --- a/messages/es.json +++ b/messages/es.json @@ -5990,6 +5990,7 @@ "connection_failed": "Falló la conexión", "not_configured": "No configurado", "url_not_public": "La URL no es un endpoint público", + "private_origin_not_approved": "Destino en una red privada. El operador debe añadir este origen a NOTIFICATION_PRIVATE_ORIGINS", "url_invalid": "La URL no es válida", "redirected": "El endpoint redirige — comprueba la URL", "endpoint_not_found": "No se encontró el endpoint en la URL", diff --git a/messages/fr.json b/messages/fr.json index eee2b3cbd..b58b49ab8 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -5990,6 +5990,7 @@ "connection_failed": "Échec de la connexion", "not_configured": "Non configuré", "url_not_public": "L’URL n’est pas un point de terminaison public", + "private_origin_not_approved": "Cible sur un réseau privé. L’opérateur doit ajouter cette origine à NOTIFICATION_PRIVATE_ORIGINS", "url_invalid": "L’URL n’est pas valide", "redirected": "Le point de terminaison redirige — vérifiez l’URL", "endpoint_not_found": "Point de terminaison introuvable à l’URL", diff --git a/messages/it.json b/messages/it.json index f550c93a0..c390dddc1 100644 --- a/messages/it.json +++ b/messages/it.json @@ -5990,6 +5990,7 @@ "connection_failed": "Connessione non riuscita", "not_configured": "Non configurato", "url_not_public": "L’URL non è un endpoint pubblico", + "private_origin_not_approved": "Destinazione su una rete privata. L’operatore deve aggiungere questa origine a NOTIFICATION_PRIVATE_ORIGINS", "url_invalid": "L’URL non è valido", "redirected": "L’endpoint reindirizza — controlla l’URL", "endpoint_not_found": "Endpoint non trovato all’URL", diff --git a/messages/ko.json b/messages/ko.json index 641082b67..9f00cb7e2 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -5990,6 +5990,7 @@ "connection_failed": "연결에 실패했어요", "not_configured": "설정되지 않았어요", "url_not_public": "URL이 공개 엔드포인트가 아니에요", + "private_origin_not_approved": "비공개 네트워크 대상이에요. 운영자가 NOTIFICATION_PRIVATE_ORIGINS에 이 출처를 등록해야 해요", "url_invalid": "URL이 올바르지 않아요", "redirected": "엔드포인트가 리디렉션돼요 — URL을 확인해 주세요", "endpoint_not_found": "해당 URL에 엔드포인트가 없어요", diff --git a/messages/pl.json b/messages/pl.json index 1caaad0a3..270131987 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -5990,6 +5990,7 @@ "connection_failed": "Połączenie nie powiodło się", "not_configured": "Nieskonfigurowano", "url_not_public": "Adres URL nie jest publicznym punktem końcowym", + "private_origin_not_approved": "Cel w sieci prywatnej. Operator musi dodać to źródło do NOTIFICATION_PRIVATE_ORIGINS", "url_invalid": "Adres URL jest nieprawidłowy", "redirected": "Punkt końcowy przekierowuje — sprawdź adres URL", "endpoint_not_found": "Nie znaleziono punktu końcowego pod adresem URL", diff --git a/src/app/api/settings/__tests__/private-origin-routes.test.ts b/src/app/api/settings/__tests__/private-origin-routes.test.ts new file mode 100644 index 000000000..7ad276f50 --- /dev/null +++ b/src/app/api/settings/__tests__/private-origin-routes.test.ts @@ -0,0 +1,381 @@ +/** + * #947 — a webhook or ntfy target on a private network, at the four routes + * that meet it. + * + * The save routes evaluate the same policy the senders do, so a private + * target that is not listed in NOTIFICATION_PRIVATE_ORIGINS is a 422 that + * names the reason (`meta.errorCode = private_origin_not_approved`) instead + * of a generic "Invalid data", and a listed literal address saves although + * the plain public floor would refuse it. The test routes forward the + * sender's own refusal as a 422 with the same code instead of the bare 500 + * that made the reporter read the wide-event log to learn it was policy. + * + * The last block is the structural half: the test button and the dispatcher + * must reach the SAME sender function, so the decision cannot differ between + * the button that says "works" and the reminder that then does not arrive. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + notificationChannel: { + findUnique: vi.fn(), + updateMany: vi.fn(), + upsert: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/crypto", () => ({ + encrypt: vi.fn((value: string) => `encrypted:${value}`), + decrypt: vi.fn((value: string) => value.replace(/^encrypted:/, "")), +})); + +vi.mock("@/lib/app-settings", () => ({ + isChannelGloballyEnabled: vi.fn().mockResolvedValue(true), +})); + +const sendViaWebhookMock = vi.fn(); +const sendViaNtfyMock = vi.fn(); +vi.mock("@/lib/notifications/senders/webhook", () => ({ + sendViaWebhook: (...args: unknown[]) => sendViaWebhookMock(...args), +})); +vi.mock("@/lib/notifications/senders/ntfy", () => ({ + sendViaNtfy: (...args: unknown[]) => sendViaNtfyMock(...args), +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn().mockResolvedValue({ + allowed: true, + remaining: 4, + resetAt: Date.now() + 60_000, + }), + rateLimitHeaders: () => ({}), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { PUT as putWebhook } from "../webhook/route"; +import { PUT as putNtfy } from "../ntfy/route"; +import { POST as testWebhook } from "../webhook/test/route"; +import { POST as testNtfy } from "../ntfy/test/route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "testuser", role: "USER" as const }, +}; + +type Route = (request: Request) => Promise; + +function put(path: string, body: unknown): Promise { + const route = (path === "webhook" ? putWebhook : putNtfy) as Route; + return route( + new Request(`http://localhost/api/settings/${path}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }), + ); +} + +function post(path: string): Promise { + const route = (path === "webhook" ? testWebhook : testNtfy) as Route; + return route( + new Request(`http://localhost/api/settings/${path}/test`, { + method: "POST", + }), + ); +} + +const ORIGINAL_PRIVATE_ORIGINS = process.env.NOTIFICATION_PRIVATE_ORIGINS; + +beforeEach(() => { + vi.clearAllMocks(); + delete process.env.NOTIFICATION_PRIVATE_ORIGINS; + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue( + null as never, + ); + vi.mocked(prisma.notificationChannel.upsert).mockResolvedValue({} as never); +}); + +afterEach(() => { + if (ORIGINAL_PRIVATE_ORIGINS === undefined) { + delete process.env.NOTIFICATION_PRIVATE_ORIGINS; + } else { + process.env.NOTIFICATION_PRIVATE_ORIGINS = ORIGINAL_PRIVATE_ORIGINS; + } +}); + +describe("PUT /api/settings/webhook — private targets", () => { + it("refuses an unlisted private address with the reason, and stores nothing", async () => { + const response = await put("webhook", { + url: "http://10.0.0.5:8080/message", + enabled: true, + }); + + expect(response.status).toBe(422); + const json = await response.json(); + expect(json.data).toBeNull(); + expect(json.meta).toEqual({ errorCode: "private_origin_not_approved" }); + expect(json.error).toContain("NOTIFICATION_PRIVATE_ORIGINS"); + expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled(); + }); + + it("refuses a sub-host of a listed origin: a grant names one origin only", async () => { + process.env.NOTIFICATION_PRIVATE_ORIGINS = "https://gotify.example.com"; + + const response = await put("webhook", { + url: "https://push.gotify.example.com/message", + enabled: true, + }); + + expect(response.status).toBe(422); + const json = await response.json(); + expect(json.meta).toEqual({ errorCode: "private_origin_not_approved" }); + expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled(); + }); + + it("saves a listed private address the public floor alone would refuse", async () => { + process.env.NOTIFICATION_PRIVATE_ORIGINS = "http://10.0.0.5:8080"; + + const response = await put("webhook", { + url: "http://10.0.0.5:8080/message", + enabled: true, + }); + + expect(response.status).toBe(200); + expect(prisma.notificationChannel.upsert).toHaveBeenCalledTimes(1); + const call = vi.mocked(prisma.notificationChannel.upsert).mock.calls[0][0]; + expect(call.create.config).toBe( + 'encrypted:{"url":"http://10.0.0.5:8080/message"}', + ); + }); + + it("keeps the plain shape refusal for a body that is not a private-origin case", async () => { + const response = await put("webhook", { url: 42, enabled: true }); + + expect(response.status).toBe(422); + const json = await response.json(); + expect(json.error).toBe("Invalid data"); + expect(json.meta).toBeUndefined(); + }); + + it("refuses a loopback grant at save time: listing it does not open it", async () => { + // The parser drops a loopback or localhost grant with a warning, so the + // save is refused with the reason and the operator learns that the list + // is not the way to reach the container itself. + vi.spyOn(console, "warn").mockImplementation(() => {}); + process.env.NOTIFICATION_PRIVATE_ORIGINS = + "http://127.0.0.1:8080, http://localhost:8080"; + + for (const url of [ + "http://127.0.0.1:8080/message", + "http://localhost:8080/message", + ]) { + const response = await put("webhook", { url, enabled: true }); + expect(response.status).toBe(422); + const json = await response.json(); + expect(json.meta).toEqual({ errorCode: "private_origin_not_approved" }); + } + expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled(); + }); + + it("saves an mDNS name the input floor refuses, once the operator lists it", async () => { + process.env.NOTIFICATION_PRIVATE_ORIGINS = "http://gotify.local"; + + const refused = await put("webhook", { + url: "http://ntfy.local/message", + enabled: true, + }); + expect(refused.status).toBe(422); + await expect(refused.json()).resolves.toMatchObject({ + meta: { errorCode: "private_origin_not_approved" }, + }); + + const saved = await put("webhook", { + url: "http://gotify.local/message", + enabled: true, + }); + expect(saved.status).toBe(200); + expect(prisma.notificationChannel.upsert).toHaveBeenCalledTimes(1); + }); +}); + +describe("PUT /api/settings/ntfy — private servers", () => { + it("refuses an unlisted private server with the reason, and stores nothing", async () => { + const response = await put("ntfy", { + serverUrl: "http://192.168.1.20:8080", + topic: "health", + enabled: true, + }); + + expect(response.status).toBe(422); + const json = await response.json(); + expect(json.meta).toEqual({ errorCode: "private_origin_not_approved" }); + expect(json.error).toContain("NOTIFICATION_PRIVATE_ORIGINS"); + expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled(); + }); + + it("saves a listed private server", async () => { + process.env.NOTIFICATION_PRIVATE_ORIGINS = "http://192.168.1.20:8080"; + + const response = await put("ntfy", { + serverUrl: "http://192.168.1.20:8080", + topic: "health", + enabled: true, + }); + + expect(response.status).toBe(200); + expect(prisma.notificationChannel.upsert).toHaveBeenCalledTimes(1); + }); + + it("refuses a sibling port of a listed server", async () => { + process.env.NOTIFICATION_PRIVATE_ORIGINS = "http://192.168.1.20:8080"; + + const response = await put("ntfy", { + serverUrl: "http://192.168.1.20:9090", + topic: "health", + enabled: true, + }); + + expect(response.status).toBe(422); + const json = await response.json(); + expect(json.meta).toEqual({ errorCode: "private_origin_not_approved" }); + }); +}); + +describe("POST /api/settings/{webhook,ntfy}/test — the refusal names itself", () => { + beforeEach(() => { + vi.mocked(prisma.notificationChannel.findUnique).mockImplementation( + (async (args: { where: { userId_type: { type: string } } }) => + args.where.userId_type.type === "WEBHOOK" + ? { config: 'encrypted:{"url":"https://gotify.lan/message"}' } + : { + config: + 'encrypted:{"serverUrl":"https://ntfy.lan","topic":"health"}', + }) as never, + ); + }); + + it.each([ + ["webhook", sendViaWebhookMock], + ["ntfy", sendViaNtfyMock], + ])( + "%s: answers a private-origin refusal with 422 and the code, not a 500", + async (path, sender) => { + sender.mockResolvedValue({ + ok: false, + hardReject: false, + reason: `${path}_private_origin_refused`, + errorCode: "private_origin_not_approved", + }); + + const response = await post(path); + + expect(response.status).toBe(422); + const json = await response.json(); + expect(json.meta).toEqual({ errorCode: "private_origin_not_approved" }); + expect(json.error).toContain("NOTIFICATION_PRIVATE_ORIGINS"); + }, + ); + + it.each([ + ["webhook", sendViaWebhookMock], + ["ntfy", sendViaNtfyMock], + ])("%s: a plain delivery failure is still a 500", async (path, sender) => { + sender.mockResolvedValue({ + ok: false, + hardReject: false, + reason: `${path}_503`, + statusCode: 503, + }); + + const response = await post(path); + + expect(response.status).toBe(500); + const json = await response.json(); + expect(json.meta).toBeUndefined(); + }); + + it.each([ + ["webhook", sendViaWebhookMock], + ["ntfy", sendViaNtfyMock], + ])("%s: a delivered test is a 200", async (path, sender) => { + sender.mockResolvedValue({ ok: true, statusCode: 200 }); + + const response = await post(path); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ + data: { sent: true }, + error: null, + }); + }); +}); + +describe("the test button and the dispatcher share one sender", () => { + const SRC = join(process.cwd(), "src"); + const read = (rel: string) => readFileSync(join(SRC, rel), "utf8"); + + it.each([ + [ + "webhook", + "app/api/settings/webhook/test/route.ts", + /import \{ sendViaWebhook \} from "@\/lib\/notifications\/senders\/webhook"/, + /sendViaWebhook\(config, payload\)/, + ], + [ + "ntfy", + "app/api/settings/ntfy/test/route.ts", + /import \{ sendViaNtfy \} from "@\/lib\/notifications\/senders\/ntfy"/, + /sendViaNtfy\(config, payload\)/, + ], + ])( + "%s: the route and the dispatcher import the same sender module", + (_channel, routeFile, importPattern, dispatcherCall) => { + expect(read(routeFile)).toMatch(importPattern); + const dispatcher = read("lib/notifications/dispatcher.ts"); + expect(dispatcher).toMatch(importPattern); + expect(dispatcher).toMatch(dispatcherCall); + }, + ); + + it("neither sender decides the private-origin verdict on its own", () => { + // The senders take the verdict from `evaluateNotificationTarget`, the + // function the save routes use too. No sender may carry a second policy. + for (const rel of [ + "lib/notifications/senders/webhook.ts", + "lib/notifications/senders/ntfy.ts", + ]) { + const src = read(rel); + expect(src).toMatch(/evaluateNotificationTarget\(/); + expect(src).not.toMatch(/process\.env\.NOTIFICATION_PRIVATE_ORIGINS/); + expect(src).not.toMatch(/isPublicUrl\(/); + } + for (const rel of [ + "app/api/settings/webhook/route.ts", + "app/api/settings/ntfy/route.ts", + ]) { + expect(read(rel)).toMatch(/isAllowedNotificationTarget/); + } + }); +}); diff --git a/src/app/api/settings/ntfy/route.ts b/src/app/api/settings/ntfy/route.ts index ea54e4c0f..d0fff0ead 100644 --- a/src/app/api/settings/ntfy/route.ts +++ b/src/app/api/settings/ntfy/route.ts @@ -2,14 +2,29 @@ import { prisma } from "@/lib/db"; import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; import { notificationChannelEnabledSchema, - ntfySettingsSchema, + ntfySettingsSchemaWith, } from "@/lib/validations/notifications"; +import { + evaluateNotificationTarget, + isAllowedNotificationTarget, + PRIVATE_ORIGIN_NOT_APPROVED, +} from "@/lib/notifications/egress-policy"; import { encrypt, decrypt } from "@/lib/crypto"; import { NextRequest } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { isChannelGloballyEnabled } from "@/lib/app-settings"; import { annotate } from "@/lib/logging/context"; +/** + * The save-time schema evaluates the same policy the sender does: the public + * floor, plus the operator's exact-origin grant (#947). A listed private + * server therefore saves; an unlisted one is refused here with the reason. + */ +const ntfySettingsSchema = ntfySettingsSchemaWith(isAllowedNotificationTarget); + +const PRIVATE_ORIGIN_REFUSAL = + "This server is on a private network. The operator has to list its exact origin (scheme://host:port) in NOTIFICATION_PRIVATE_ORIGINS before HealthLog can send to it."; + /** * Refusal for an enable attempt on a channel the operator switched off * instance-wide (`AppSettings.ntfyGlobal`). A toggle that appeared to work @@ -119,7 +134,23 @@ export const PUT = apiHandler(async (request: NextRequest) => { } const parsed = ntfySettingsSchema.safeParse(body); - if (!parsed.success) return apiError("Invalid data", 422); + if (!parsed.success) { + const candidate = (body as { serverUrl?: unknown } | null)?.serverUrl; + if ( + typeof candidate === "string" && + evaluateNotificationTarget(candidate).reasonCode === + PRIVATE_ORIGIN_NOT_APPROVED + ) { + annotate({ + action: { name: "settings.ntfy.update" }, + meta: { refused: PRIVATE_ORIGIN_NOT_APPROVED }, + }); + return apiError(PRIVATE_ORIGIN_REFUSAL, 422, { + errorCode: PRIVATE_ORIGIN_NOT_APPROVED, + }); + } + return apiError("Invalid data", 422); + } const { serverUrl, topic, authToken, enabled } = parsed.data; diff --git a/src/app/api/settings/ntfy/test/route.ts b/src/app/api/settings/ntfy/test/route.ts index 0d6fa08d9..3805f9f8f 100644 --- a/src/app/api/settings/ntfy/test/route.ts +++ b/src/app/api/settings/ntfy/test/route.ts @@ -42,6 +42,20 @@ export const POST = apiHandler(async () => { }); if (!result.ok) { + // A private-origin refusal is a policy decision, not a delivery fault: + // say so, with the code the card translates, instead of a bare 500 that + // sends the operator to the wide-event log to learn why (#947). + if (result.errorCode) { + annotate({ + action: { name: "settings.ntfy.test" }, + meta: { success: false, refused: result.errorCode }, + }); + return apiError( + "The server is on a private network the operator has not approved. List its exact origin (scheme://host:port) in NOTIFICATION_PRIVATE_ORIGINS on the server.", + 422, + { errorCode: result.errorCode }, + ); + } return apiError("Failed to send test message", 500); } diff --git a/src/app/api/settings/webhook/route.ts b/src/app/api/settings/webhook/route.ts index 8129a2745..d8f1c2280 100644 --- a/src/app/api/settings/webhook/route.ts +++ b/src/app/api/settings/webhook/route.ts @@ -2,22 +2,42 @@ import { prisma } from "@/lib/db"; import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; import { notificationChannelEnabledSchema, - webhookSettingsSchema, + webhookSettingsSchemaWith, } from "@/lib/validations/notifications"; +import { + evaluateNotificationTarget, + isAllowedNotificationTarget, + PRIVATE_ORIGIN_NOT_APPROVED, +} from "@/lib/notifications/egress-policy"; import { encrypt, decrypt } from "@/lib/crypto"; import { NextRequest } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; +/** + * The save-time schema evaluates the same policy the sender does: the public + * floor, plus the operator's exact-origin grant (#947). A listed private + * address therefore saves; an unlisted one is refused here with the reason, + * before anything is encrypted onto the row. + */ +const webhookSettingsSchema = webhookSettingsSchemaWith( + isAllowedNotificationTarget, +); + +const PRIVATE_ORIGIN_REFUSAL = + "This address is on a private network. The operator has to list its exact origin (scheme://host:port) in NOTIFICATION_PRIVATE_ORIGINS before HealthLog can send to it."; + /** * Generic-webhook channel config (v1.17.1). * GET: current config (header value redacted — only presence flagged). * PUT: upsert config. * * Covers Gotify / Discord / Slack / Matrix-bridge / Home Assistant in one - * channel: the user supplies a public URL and an optional shared-secret - * header. SSRF is enforced at input time (`isPublicUrl` in the schema) and - * again at dispatch time (`safeFetch({ requirePublicHost: true })`). + * channel: the user supplies a URL and an optional shared-secret header. SSRF + * is enforced at input time (the schema's target predicate) and again at + * dispatch time (`safeFetch` with the connect-time pin); a private origin + * passes both only when the operator listed it in + * `NOTIFICATION_PRIVATE_ORIGINS`. */ export const GET = apiHandler(async () => { const { user } = await requireAuth(); @@ -108,7 +128,23 @@ export const PUT = apiHandler(async (request: NextRequest) => { } const parsed = webhookSettingsSchema.safeParse(body); - if (!parsed.success) return apiError("Invalid data", 422); + if (!parsed.success) { + const candidate = (body as { url?: unknown } | null)?.url; + if ( + typeof candidate === "string" && + evaluateNotificationTarget(candidate).reasonCode === + PRIVATE_ORIGIN_NOT_APPROVED + ) { + annotate({ + action: { name: "settings.webhook.update" }, + meta: { refused: PRIVATE_ORIGIN_NOT_APPROVED }, + }); + return apiError(PRIVATE_ORIGIN_REFUSAL, 422, { + errorCode: PRIVATE_ORIGIN_NOT_APPROVED, + }); + } + return apiError("Invalid data", 422); + } const { url, headerName, headerValue, enabled } = parsed.data; diff --git a/src/app/api/settings/webhook/test/route.ts b/src/app/api/settings/webhook/test/route.ts index 96fce255b..267345482 100644 --- a/src/app/api/settings/webhook/test/route.ts +++ b/src/app/api/settings/webhook/test/route.ts @@ -41,6 +41,20 @@ export const POST = apiHandler(async () => { }); if (!result.ok) { + // A private-origin refusal is a policy decision, not a delivery fault: + // say so, with the code the card translates, instead of a bare 500 that + // sends the operator to the wide-event log to learn why (#947). + if (result.errorCode) { + annotate({ + action: { name: "settings.webhook.test" }, + meta: { success: false, refused: result.errorCode }, + }); + return apiError( + "The target is on a private network the operator has not approved. List its exact origin (scheme://host:port) in NOTIFICATION_PRIVATE_ORIGINS on the server.", + 422, + { errorCode: result.errorCode }, + ); + } return apiError("Failed to send test message", 500); } diff --git a/src/lib/__tests__/private-origin-policy.test.ts b/src/lib/__tests__/private-origin-policy.test.ts index 88540e7fd..7cd57b174 100644 --- a/src/lib/__tests__/private-origin-policy.test.ts +++ b/src/lib/__tests__/private-origin-policy.test.ts @@ -57,10 +57,22 @@ describe("canonicalOrigin", () => { ["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]"], + ["localhost", "http://localhost:8080"], + ["a *.localhost name", "http://gotify.localhost"], ])("refuses %s as a grant", (_label, value) => { expect(canonicalOrigin(value)).toBeNull(); }); + it("keeps mDNS and other reserved-looking names grantable when listed exactly", () => { + // The input-time floor refuses `.local` and `.internal` unlisted; an + // operator naming the exact origin is the override, and a loopback or + // metadata answer is still dropped at dial time. + expect(canonicalOrigin("http://gotify.local")).toBe("http://gotify.local"); + expect(canonicalOrigin("http://ntfy.internal:8080")).toBe( + "http://ntfy.internal:8080", + ); + }); + 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", diff --git a/src/lib/openapi/routes/settings.ts b/src/lib/openapi/routes/settings.ts index 5edde619c..746714f56 100644 --- a/src/lib/openapi/routes/settings.ts +++ b/src/lib/openapi/routes/settings.ts @@ -242,7 +242,7 @@ const channelEnabledOnlyRequest = notificationChannelEnabledSchema.meta({ const ntfySettingsRequest = ntfySettingsSchema.meta({ id: "NtfySettingsRequest", description: - "ntfy channel config. `serverUrl` must pass the SSRF floor — a URL resolving into a private range is refused at input time. An EMPTY OR OMITTED `authToken` preserves the stored one rather than clearing it: the GET never returns the secret, so a client round-tripping the form would otherwise wipe it on every unrelated save. Sending a non-empty value replaces it. There is no way to clear a stored auth token through this endpoint.", + "ntfy channel config. `serverUrl` must pass the SSRF floor — a URL resolving into a private range is refused at input time unless the operator listed its exact origin in `NOTIFICATION_PRIVATE_ORIGINS`. An EMPTY OR OMITTED `authToken` preserves the stored one rather than clearing it: the GET never returns the secret, so a client round-tripping the form would otherwise wipe it on every unrelated save. Sending a non-empty value replaces it. There is no way to clear a stored auth token through this endpoint.", }); const telegramSettingsRequest = telegramSettingsSchema.meta({ @@ -254,7 +254,7 @@ const telegramSettingsRequest = telegramSettingsSchema.meta({ const webhookSettingsRequest = webhookSettingsSchema.meta({ id: "WebhookSettingsRequest", description: - "Generic-webhook channel config — one channel covering Gotify, Discord, Slack, a Matrix bridge, Home Assistant, or any relay accepting an inbound JSON POST. `url` must pass the SSRF floor at input time and is re-checked at dispatch time. An EMPTY OR OMITTED `headerValue` preserves the stored one, for the same reason as ntfy's `authToken`: the GET never returns it. There is no way to clear a stored header value through this endpoint.", + "Generic-webhook channel config — one channel covering Gotify, Discord, Slack, a Matrix bridge, Home Assistant, or any relay accepting an inbound JSON POST. `url` must pass the SSRF floor at input time and is re-checked at dispatch time; a private origin passes both only when the operator listed it in `NOTIFICATION_PRIVATE_ORIGINS`. An EMPTY OR OMITTED `headerValue` preserves the stored one, for the same reason as ntfy's `authToken`: the GET never returns it. There is no way to clear a stored header value through this endpoint.", }); /** @@ -399,7 +399,7 @@ export const settingsPaths: NonNullable = { ...stdResponses, "422": { description: - "Body matched neither accepted shape, or `enabled` is true while the stored config has no server URL and topic.", + "Body matched neither accepted shape, or `enabled` is true while the stored config has no server URL and topic; or the server URL is on a private network the operator has not listed in `NOTIFICATION_PRIVATE_ORIGINS` (`meta.errorCode` = `private_origin_not_approved`). Nothing was stored.", content: { "application/json": { schema: errorEnvelope } }, }, }, @@ -515,7 +515,7 @@ export const settingsPaths: NonNullable = { ...stdResponses, "422": { description: - "Body matched neither accepted shape, or `enabled` is true while no webhook URL is stored.", + "Body matched neither accepted shape, or `enabled` is true while no webhook URL is stored; or the URL is on a private network the operator has not listed in `NOTIFICATION_PRIVATE_ORIGINS` (`meta.errorCode` = `private_origin_not_approved`). Nothing was stored.", content: { "application/json": { schema: errorEnvelope } }, }, }, @@ -703,7 +703,7 @@ export const settingsPaths: NonNullable = { tags: ["Notifications"], summary: "Send a test message to the saved ntfy topic", description: - "Publishes one message to the account's configured ntfy server and topic. Takes no body. Rate-limited 5 per 5 minutes per user. Refuses with 400 when no ntfy channel is saved or when the saved config is missing a server URL or topic; a publish failure is a 500.", + "Publishes one message to the account's configured ntfy server and topic. Takes no body. Rate-limited 5 per 5 minutes per user. Refuses with 400 when no ntfy channel is saved or when the saved config is missing a server URL or topic; a publish failure is a 500. A server on a private network is refused with 422 and `meta.errorCode` = `private_origin_not_approved` unless the operator listed its exact origin in `NOTIFICATION_PRIVATE_ORIGINS`; the test and the scheduled delivery take the same decision.", responses: { "200": { description: @@ -715,6 +715,11 @@ export const settingsPaths: NonNullable = { }, }, ...stdResponses, + "422": { + description: + "The saved server is on a private network the operator has not approved (`meta.errorCode` = `private_origin_not_approved`). The resolved address is never echoed.", + content: { "application/json": { schema: errorEnvelope } }, + }, }, }, }, @@ -744,7 +749,7 @@ export const settingsPaths: NonNullable = { tags: ["Notifications"], summary: "Send a test payload to the saved webhook", description: - "Posts one payload to the account's configured webhook URL. Takes no body. Rate-limited 5 per 5 minutes per user. Refuses with 400 when no webhook channel is saved or the saved config has no URL; a delivery failure is a 500. The URL was checked against the SSRF floor when it was saved, so a test cannot be used to reach an internal host.", + "Posts one payload to the account's configured webhook URL. Takes no body. Rate-limited 5 per 5 minutes per user. Refuses with 400 when no webhook channel is saved or the saved config has no URL; a delivery failure is a 500. The URL was checked against the SSRF floor when it was saved, so a test cannot be used to reach an internal host. A URL on a private network is refused with 422 and `meta.errorCode` = `private_origin_not_approved` unless the operator listed its exact origin in `NOTIFICATION_PRIVATE_ORIGINS`; the test and the scheduled delivery take the same decision.", responses: { "200": { description: @@ -756,6 +761,11 @@ export const settingsPaths: NonNullable = { }, }, ...stdResponses, + "422": { + description: + "The saved URL is on a private network the operator has not approved (`meta.errorCode` = `private_origin_not_approved`). The resolved address is never echoed.", + content: { "application/json": { schema: errorEnvelope } }, + }, }, }, }, diff --git a/src/lib/private-origin-policy.ts b/src/lib/private-origin-policy.ts index 794d08a34..df27715c6 100644 --- a/src/lib/private-origin-policy.ts +++ b/src/lib/private-origin-policy.ts @@ -48,7 +48,16 @@ export function canonicalOrigin(value: string): string | 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, ""); + // `localhost` and `*.localhost` are loopback by definition (RFC 6761); + // refuse them at parse time like a literal loopback address. Other + // reserved-looking names (`.local` mDNS, `.internal`, `.lan`) stay + // grantable: an explicitly listed origin is what the list is for, and + // the dial-time floor still drops a loopback or metadata answer. + const hostname = url.hostname.toLowerCase(); + if (hostname === "localhost" || hostname.endsWith(".localhost")) { + return null; + } + const literal = hostname.replace(/^\[|\]$/g, ""); if (isIP(literal) !== 0 && !isOperatorGrantableIp(literal)) return null; return url.origin; } catch { diff --git a/src/lib/validations/notifications.ts b/src/lib/validations/notifications.ts index 1cc8e12e6..98d960e01 100644 --- a/src/lib/validations/notifications.ts +++ b/src/lib/validations/notifications.ts @@ -337,41 +337,64 @@ export function isPublicUrl(url: string): boolean { } } -export const ntfySettingsSchema = z.object({ - serverUrl: z - .url("Ungültige Server-URL") - .max(200) - .refine( - (url) => isPublicUrl(url), - "Server-URL darf nicht auf interne Netzwerke zeigen", - ) - .optional() - .or(z.literal("")), - topic: z.string().max(100).optional().or(z.literal("")), - authToken: z.string().max(200).optional().or(z.literal("")), - enabled: z.boolean(), -}); +/** + * ntfy channel settings. The server URL is user-supplied and must pass the + * target predicate at input time. The exported constant carries the plain + * public floor (`isPublicUrl`) for the OpenAPI contract; the save route + * builds the same shape with `isAllowedNotificationTarget`, which adds the + * operator's `NOTIFICATION_PRIVATE_ORIGINS` grant (#947) on top of the floor + * without this module having to read the environment. + */ +export function ntfySettingsSchemaWith( + isAllowedTarget: (url: string) => boolean, +) { + return z.object({ + serverUrl: z + .url("Ungültige Server-URL") + .max(200) + .refine( + isAllowedTarget, + "Server-URL darf nicht auf interne Netzwerke zeigen", + ) + .optional() + .or(z.literal("")), + topic: z.string().max(100).optional().or(z.literal("")), + authToken: z.string().max(200).optional().or(z.literal("")), + enabled: z.boolean(), + }); +} + +export const ntfySettingsSchema = ntfySettingsSchemaWith(isPublicUrl); /** * Generic-webhook channel settings (v1.17.1). The URL is user-supplied and - * must pass the SSRF floor (`isPublicUrl`) at input time; the dispatcher - * re-checks it at fetch time via `safeFetch({ requirePublicHost: true })`. The - * optional header name/value carry a shared secret (e.g. Gotify token). + * must pass the target predicate at input time; the sender re-checks it at + * fetch time through `safeFetch` with the connect-time pin. The exported + * constant carries the plain public floor for the OpenAPI contract; the save + * route passes `isAllowedNotificationTarget` so an origin the operator listed + * in `NOTIFICATION_PRIVATE_ORIGINS` (#947) saves. The optional header + * name/value carry a shared secret (e.g. Gotify token). */ -export const webhookSettingsSchema = z.object({ - url: z - .url("Ungültige Webhook-URL") - .max(500) - .refine( - (url) => isPublicUrl(url), - "Webhook-URL darf nicht auf interne Netzwerke zeigen", - ) - .optional() - .or(z.literal("")), - headerName: z.string().max(100).optional().or(z.literal("")), - headerValue: z.string().max(500).optional().or(z.literal("")), - enabled: z.boolean(), -}); +export function webhookSettingsSchemaWith( + isAllowedTarget: (url: string) => boolean, +) { + return z.object({ + url: z + .url("Ungültige Webhook-URL") + .max(500) + .refine( + isAllowedTarget, + "Webhook-URL darf nicht auf interne Netzwerke zeigen", + ) + .optional() + .or(z.literal("")), + headerName: z.string().max(100).optional().or(z.literal("")), + headerValue: z.string().max(500).optional().or(z.literal("")), + enabled: z.boolean(), + }); +} + +export const webhookSettingsSchema = webhookSettingsSchemaWith(isPublicUrl); /** * Email channel settings (v1.17.1). The SMTP transport is operator-configured; From 9630b5997389fac87171bd6897219dba03ab0402 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 16:47:55 +0200 Subject: [PATCH 04/11] chore(compose): forward and document NOTIFICATION_PRIVATE_ORIGINS Put the variable on the compose environment whitelist (without it a value in .env never reaches the process), on both example files, and in the env manifest so check-env reports it. The self-hosting notifications page gains a section on notification targets on a private network: the exact-origin grammar, what stays refused even when listed, and a table of which target is refused where so an operator knows which lever to pull. Refs #947 --- .env.example | 11 ++++ .env.production.example | 14 +++++ docker-compose.yml | 7 +++ docs/self-hosting/notifications.md | 85 ++++++++++++++++++++++++++---- scripts/env-manifest.json | 11 ++++ 5 files changed, 119 insertions(+), 9 deletions(-) diff --git a/.env.example b/.env.example index 31cee47cc..1564836ab 100644 --- a/.env.example +++ b/.env.example @@ -92,6 +92,17 @@ API_TOKEN_HMAC_KEY="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32" # TELEGRAM_WEBHOOK_SECRET="" +# ----------------------------------------------------------------------------- +# Notification targets on a private network (optional) +# ----------------------------------------------------------------------------- +# Webhook (Gotify, Home Assistant, n8n) and ntfy targets on your own network, +# comma-separated exact origins, e.g. "https://gotify.example.com,http://ntfy.lan:8080". +# A target resolving to a private address is otherwise refused at send time. +# Loopback, link-local and metadata addresses stay refused even when listed. +# See docs/self-hosting/notifications.md. +# NOTIFICATION_PRIVATE_ORIGINS="" + + # ----------------------------------------------------------------------------- # AI Provider (optional) # ----------------------------------------------------------------------------- diff --git a/.env.production.example b/.env.production.example index 27486de2b..190e26dd7 100644 --- a/.env.production.example +++ b/.env.production.example @@ -71,6 +71,20 @@ API_TOKEN_HMAC_KEY="" # NIGHTSCOUT_PRIVATE_ORIGINS="" +# ----------------------------------------------------------------------------- +# Notification targets on a private network -- optional +# ----------------------------------------------------------------------------- +# Webhook (Gotify, Home Assistant, n8n, a Discord relay) and ntfy targets on +# your own network, comma-separated exact origins (scheme://host[:port], no +# path). A target that resolves to a private address is otherwise refused at +# send time; the test button reports it as private_origin_not_approved. A +# listed origin is still dialled through the pinned resolver with redirects +# forbidden; loopback, link-local and metadata addresses stay refused even +# when listed, and a malformed entry is logged once and grants nothing. +# See docs/self-hosting/notifications.md. +# NOTIFICATION_PRIVATE_ORIGINS="" + + # ----------------------------------------------------------------------------- # WHOOP -- optional integration # ----------------------------------------------------------------------------- diff --git a/docker-compose.yml b/docker-compose.yml index f09232067..434cfdd27 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -205,6 +205,13 @@ x-healthlog-env: &healthlog_env # validator should accept, comma-separated (e.g. "http://nightscout.lan"). # Without it a LAN/private Nightscout URL is refused as an SSRF target. NIGHTSCOUT_PRIVATE_ORIGINS: "${NIGHTSCOUT_PRIVATE_ORIGINS:-}" + # Webhook (Gotify, Home Assistant, n8n) and ntfy targets on your own + # network, comma-separated exact origins (e.g. "https://gotify.example.com, + # http://ntfy.lan:8080"). Without it a target that resolves to a private + # address is refused at send time as an SSRF target. Loopback, link-local + # and metadata addresses stay refused even when listed. See + # docs/self-hosting/notifications.md. + NOTIFICATION_PRIVATE_ORIGINS: "${NOTIFICATION_PRIVATE_ORIGINS:-}" # Open-Meteo endpoints for the environment/weather correlation. Default to # the hosted service; point them at your own Open-Meteo deployment to keep # the coordinate lookups off a third party. diff --git a/docs/self-hosting/notifications.md b/docs/self-hosting/notifications.md index 6f7e01ab1..ade4cd6ae 100644 --- a/docs/self-hosting/notifications.md +++ b/docs/self-hosting/notifications.md @@ -180,10 +180,12 @@ HealthLog publishes to that topic. server if you self-host ntfy) and **Topic**, then save. If your ntfy server requires auth, an access token can be attached; the -server URL is validated as a public host and dialled through the egress -guard (`src/lib/notifications/senders/ntfy.ts`). Urgent events are sent at -ntfy's top priority (`5` / max) so they bypass batching and surface on the -lock screen. +server URL is validated at save time and dialled through the egress guard +(`src/lib/notifications/senders/ntfy.ts`). A self-hosted ntfy on your own +network needs the operator grant described under [Notification targets on +a private network](#notification-targets-on-a-private-network). Urgent +events are sent at ntfy's top priority (`5` / max) so they bypass batching +and surface on the lock screen. ## Webhook @@ -197,11 +199,76 @@ optionally, one custom header (e.g. an auth token). **header value**. 2. Save. A Test button fires a one-off POST so you can confirm routing. -The URL is validated as a public host and dialled through -`safeFetch({ requirePublicHost: true })` — the SSRF guard blocks private -and loopback ranges because the target is user-supplied -(`src/lib/notifications/senders/webhook.ts`). The webhook carries the -event's urgency flag so you can route urgent events differently. +The URL is validated at save time and dialled through `safeFetch` with +the connect-time resolver pin — the SSRF guard refuses a target that +resolves to a private or loopback address because the target is +user-supplied (`src/lib/notifications/senders/webhook.ts`). A Gotify or +Home Assistant on your own network needs the operator grant described in +the next section. The webhook carries the event's urgency flag so you can +route urgent events differently. + +## Notification targets on a private network + +Every webhook and ntfy target is resolved at send time and refused when +the answer is a private, loopback, link-local or CGNAT address. That is +the DNS-rebinding defence, and it applies to a public-looking name with a +LAN A record (a Gotify behind Nginx Proxy Manager with a DNS-01 +certificate) exactly as it applies to `http://192.168.1.20`. The test +button and the scheduled delivery take the same decision, so a test that +fails with "Private network target" means nothing would have arrived +either. + +The operator opens a target by listing its exact origin in the server +environment: + +```env +NOTIFICATION_PRIVATE_ORIGINS="https://gotify.example.com,http://ntfy.lan:8080" +``` + +Each entry is one `scheme://host[:port]`, the same grammar as +`NIGHTSCOUT_PRIVATE_ORIGINS`: + +- only `http` and `https`; scheme, host and effective port must all match + what the user saves (`https://gotify.example.com:8443` and + `https://gotify.example.com` are two different grants); +- no path, query, credentials, wildcard (`*.lan`) or CIDR (`10.0.0.0/8`) — + a range grant would let a DNS rebinding to any address in the range pass + the pin, so an operator with several relays lists several origins; +- listing a host does not list its sub-hosts or sibling ports; +- loopback (`127.0.0.0/8`, `::1`, `localhost`), the unspecified address, + link-local and the cloud-metadata range (`169.254.0.0/16`, `fe80::/10`) + cannot be granted: a literal entry is logged once and ignored, and a + listed name that resolves there is refused at dial time. On a + host-network deployment, list the LAN address of the relay instead of + `localhost`; +- RFC1918, IPv6 unique-local and CGNAT (a Tailscale address or MagicDNS + name) are what the list is for; +- a malformed entry is logged once at first use and grants nothing; the + valid entries beside it keep working. + +A listed origin is still dialled through the pinned resolver with +redirects forbidden, so a relay that answers with a redirect to another +host is refused. The variable has to be on the compose `environment:` +whitelist to reach the container; the published `docker-compose.yml` +lists it. Restart the app after changing it. + +What is refused where, so you know which lever to pull: + +| Target | Without the grant | With the exact origin listed | +| ----------------------------------------------------------- | ----------------- | ---------------------------- | +| Public name resolving to a public address | works | works, public pin | +| Public name with a private A record (LAN reverse proxy) | refused at send | works | +| `gotify.lan`, `gotify.home.arpa`, a Docker service name | refused at send | works | +| `gotify.local` (mDNS) | refused at save | works | +| Literal `192.168.x.x`, `10.x.x.x`, `100.64.x.x`, `fd00::/8` | refused at save | works | +| `localhost`, `127.0.0.1`, `169.254.169.254`, `::1` | refused at save | still refused | + +A private target that is not listed is refused when the card is saved +(`422`, `meta.errorCode` = `private_origin_not_approved`, the message +names the variable) or, for a DNS name the save cannot classify, when the +test button or a reminder dials it. The resolved address is never shown +to the user. Telegram, email and Web Push are not affected: their hosts +are not user-supplied. ## Email (SMTP) diff --git a/scripts/env-manifest.json b/scripts/env-manifest.json index b1d95efa4..910134538 100644 --- a/scripts/env-manifest.json +++ b/scripts/env-manifest.json @@ -226,6 +226,17 @@ } ] }, + { + "name": "Notification targets on a private network", + "description": "Operator grant for webhook (Gotify, Home Assistant, n8n) and ntfy targets on the operator's own network. Unset, a target that resolves to a private address is refused at send time and the test button reports private_origin_not_approved. Optional; public targets never need it.", + "required": false, + "variables": [ + { + "name": "NOTIFICATION_PRIVATE_ORIGINS", + "purpose": "Comma-separated exact origins (scheme://host[:port], no path, no wildcard, no CIDR), e.g. \"https://gotify.example.com,http://ntfy.lan:8080\". Loopback, link-local and metadata addresses stay refused even when listed; a malformed entry is logged once and grants nothing." + } + ] + }, { "name": "Off-host backups", "description": "Required ALL-OR-NONE: any of these without the rest disables backups silently.", From 761a5a54c150ed90598b05d4cacffde9cfc115d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 16:51:57 +0200 Subject: [PATCH 05/11] fix(notifications): keep the egress mark from clobbering a set action The mark named its action unconditionally, so a job that had already named its own (the low-stock underflow branch) or a settings route whose test then failed at the relay lost that name on the wide event. Set the action only when the event has none, and always land the channel and origin meta pair, through a small hasAction() on the builder. Note the parse-time floor on the Nightscout page, which shares the grammar. Refs #947 --- docs/integrations/nightscout.md | 7 +++ docs/self-hosting/notifications.md | 16 +++---- src/lib/logging/event-builder.ts | 10 +++++ .../__tests__/egress-policy.test.ts | 43 +++++++++++++++++++ src/lib/notifications/egress-policy.ts | 16 ++++--- .../senders/__tests__/ntfy.test.ts | 8 +++- .../senders/__tests__/webhook.test.ts | 8 +++- 7 files changed, 93 insertions(+), 15 deletions(-) diff --git a/docs/integrations/nightscout.md b/docs/integrations/nightscout.md index 37f32f340..3a75f8ef4 100644 --- a/docs/integrations/nightscout.md +++ b/docs/integrations/nightscout.md @@ -28,9 +28,16 @@ Each entry is an exact normalized `scheme://host:port` trust unit: rejected; - approving one hostname does not approve subdomains, sibling hosts, or a different port; and +- loopback (`127.0.0.0/8`, `::1`, `localhost`), the unspecified address, + link-local and the cloud-metadata range cannot be listed, and a listed + name that resolves there is refused at connect time — the grant opens + your network, never the container or the host it runs on; and - any malformed non-empty entry fails evaluation instead of silently weakening the configured policy. +The grammar is shared with `NOTIFICATION_PRIVATE_ORIGINS`, the same grant +for webhook and ntfy targets (`docs/self-hosting/notifications.md`). + Do not put a Nightscout API token, capability URL, health data, or a URL containing credentials in the allowlist. Restart the application after changing the environment. diff --git a/docs/self-hosting/notifications.md b/docs/self-hosting/notifications.md index ade4cd6ae..0dc298b51 100644 --- a/docs/self-hosting/notifications.md +++ b/docs/self-hosting/notifications.md @@ -254,14 +254,14 @@ lists it. Restart the app after changing it. What is refused where, so you know which lever to pull: -| Target | Without the grant | With the exact origin listed | -| ----------------------------------------------------------- | ----------------- | ---------------------------- | -| Public name resolving to a public address | works | works, public pin | -| Public name with a private A record (LAN reverse proxy) | refused at send | works | -| `gotify.lan`, `gotify.home.arpa`, a Docker service name | refused at send | works | -| `gotify.local` (mDNS) | refused at save | works | -| Literal `192.168.x.x`, `10.x.x.x`, `100.64.x.x`, `fd00::/8` | refused at save | works | -| `localhost`, `127.0.0.1`, `169.254.169.254`, `::1` | refused at save | still refused | +| Target | Without the grant | With the exact origin listed | +| ----------------------------------------------------------- | ----------------- | --------------------------------------- | +| Public name resolving to a public address | works | works, public pin | +| Public name with a private A record (LAN reverse proxy) | refused at send | works | +| `gotify.lan`, `gotify.home.arpa`, a Docker service name | refused at send | works | +| `gotify.local` (mDNS) | refused at save | allowed, if the container resolves mDNS | +| Literal `192.168.x.x`, `10.x.x.x`, `100.64.x.x`, `fd00::/8` | refused at save | works | +| `localhost`, `127.0.0.1`, `169.254.169.254`, `::1` | refused at save | still refused | A private target that is not listed is refused when the card is saved (`422`, `meta.errorCode` = `private_origin_not_approved`, the message diff --git a/src/lib/logging/event-builder.ts b/src/lib/logging/event-builder.ts index 13347ae3b..439d65b5b 100644 --- a/src/lib/logging/event-builder.ts +++ b/src/lib/logging/event-builder.ts @@ -150,6 +150,16 @@ export class WideEventBuilder { return this; } + /** + * Whether a caller has already named this event's action. A leaf that + * wants to mark the event (the private-origin egress mark in the + * notification senders) checks this first so it never overwrites the + * route's or job's own action name, which dashboards pin on. + */ + hasAction(): boolean { + return this.event.action !== undefined; + } + setBackground(bg: WideEvent["background"]): this { this.event.background = bg; return this; diff --git a/src/lib/notifications/__tests__/egress-policy.test.ts b/src/lib/notifications/__tests__/egress-policy.test.ts index 1818abd7b..50631967e 100644 --- a/src/lib/notifications/__tests__/egress-policy.test.ts +++ b/src/lib/notifications/__tests__/egress-policy.test.ts @@ -12,7 +12,11 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { eventStorage } from "@/lib/logging/context"; +import { WideEventBuilder } from "@/lib/logging/event-builder"; + import { + annotatePrivateOriginEgress, configuredNotificationPrivateOrigins, evaluateNotificationTarget, isAllowedNotificationTarget, @@ -166,3 +170,42 @@ describe("evaluateNotificationTarget", () => { }); }); }); + +describe("annotatePrivateOriginEgress", () => { + it("names the action when the event has none and pins the meta pair", () => { + const event = new WideEventBuilder("background"); + eventStorage.run(event, () => { + annotatePrivateOriginEgress("webhook", "https://gotify.example.com"); + }); + + const json = event.toJSON(); + expect(json.action).toEqual({ name: "notification.egress.private_origin" }); + expect(json.meta).toEqual({ + channel: "webhook", + origin: "https://gotify.example.com", + }); + }); + + it("keeps an action the route or job already set, and still lands the meta", () => { + // A settings route or a job names its own action; dashboards pin on it. + // The egress mark must not overwrite that name. + const event = new WideEventBuilder("http"); + event.setAction({ name: "settings.webhook.test" }); + eventStorage.run(event, () => { + annotatePrivateOriginEgress("ntfy", "http://ntfy.lan:8080"); + }); + + const json = event.toJSON(); + expect(json.action).toEqual({ name: "settings.webhook.test" }); + expect(json.meta).toEqual({ + channel: "ntfy", + origin: "http://ntfy.lan:8080", + }); + }); + + it("is a no-op without an active event", () => { + expect(() => + annotatePrivateOriginEgress("webhook", "https://gotify.example.com"), + ).not.toThrow(); + }); +}); diff --git a/src/lib/notifications/egress-policy.ts b/src/lib/notifications/egress-policy.ts index 8afce08ed..aab9a14f5 100644 --- a/src/lib/notifications/egress-policy.ts +++ b/src/lib/notifications/egress-policy.ts @@ -6,7 +6,7 @@ import { PRIVATE_ORIGIN_NOT_APPROVED, type OriginVerdict, } from "@/lib/private-origin-policy"; -import { annotate } from "@/lib/logging/context"; +import { annotate, getEvent } from "@/lib/logging/context"; /** * `NOTIFICATION_PRIVATE_ORIGINS` (#947) — the operator grant that lets the @@ -93,13 +93,19 @@ export { PRIVATE_ORIGIN_NOT_APPROVED }; * an operator can see which grant is actually in use. The origin is the * canonical grant string, never the full target URL, which may carry a * token in its path or query. + * + * The action name is set only when the event has none yet: a settings route + * or a job that already named its action keeps it, and the meta pair still + * lands on the same event, so the mark is never lost and never clobbers. */ export function annotatePrivateOriginEgress( channel: "webhook" | "ntfy", origin: string, ): void { - annotate({ - action: { name: "notification.egress.private_origin" }, - meta: { channel, origin }, - }); + const event = getEvent(); + if (!event) return; + if (!event.hasAction()) { + annotate({ action: { name: "notification.egress.private_origin" } }); + } + annotate({ meta: { channel, origin } }); } diff --git a/src/lib/notifications/senders/__tests__/ntfy.test.ts b/src/lib/notifications/senders/__tests__/ntfy.test.ts index a80a8a202..770a6fdae 100644 --- a/src/lib/notifications/senders/__tests__/ntfy.test.ts +++ b/src/lib/notifications/senders/__tests__/ntfy.test.ts @@ -25,7 +25,11 @@ vi.mock("@/lib/safe-fetch", () => ({ })); vi.mock("@/lib/logging/context", () => ({ - getEvent: () => ({ addWarning: vi.fn(), addExternalCall: vi.fn() }), + getEvent: () => ({ + addWarning: vi.fn(), + addExternalCall: vi.fn(), + hasAction: () => false, + }), annotate: (...args: unknown[]) => annotateMock(...args), })); @@ -165,6 +169,8 @@ describe("sendViaNtfy", () => { expect(opts.operatorApprovedPrivateOrigin).toBe("http://ntfy.lan:8080"); expect(annotateMock).toHaveBeenCalledWith({ action: { name: "notification.egress.private_origin" }, + }); + expect(annotateMock).toHaveBeenCalledWith({ meta: { channel: "ntfy", origin: "http://ntfy.lan:8080" }, }); }); diff --git a/src/lib/notifications/senders/__tests__/webhook.test.ts b/src/lib/notifications/senders/__tests__/webhook.test.ts index 6655238cb..410ce3896 100644 --- a/src/lib/notifications/senders/__tests__/webhook.test.ts +++ b/src/lib/notifications/senders/__tests__/webhook.test.ts @@ -26,7 +26,11 @@ vi.mock("@/lib/safe-fetch", () => ({ const annotateMock = vi.fn(); vi.mock("@/lib/logging/context", () => ({ - getEvent: () => ({ addWarning: vi.fn(), addExternalCall: vi.fn() }), + getEvent: () => ({ + addWarning: vi.fn(), + addExternalCall: vi.fn(), + hasAction: () => false, + }), annotate: (...args: unknown[]) => annotateMock(...args), })); @@ -198,6 +202,8 @@ describe("sendViaWebhook", () => { ); expect(annotateMock).toHaveBeenCalledWith({ action: { name: "notification.egress.private_origin" }, + }); + expect(annotateMock).toHaveBeenCalledWith({ meta: { channel: "webhook", origin: "https://gotify.example.com" }, }); }); From 9c306f04e7ab716a917e45d2298ef0a472874fd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 18:45:57 +0200 Subject: [PATCH 06/11] fix(notifications): name the never-grantable refusal and redact the grant log A loopback, link-local or metadata target answered private_origin_not_approved with a message telling the user to have the operator list it, and the parser refuses such an entry forever: a dead end the route test even pinned. Those targets now answer private_origin_not_grantable (catalogued, on all four routes, translated in seven locales) with a message that says no grant can open them and to use the relay's LAN address. Both senders carry the reason into the outcome so the test button forwards the same code. The malformed-entry warning printed the entry verbatim, which is where a pasted token or userinfo sits; the parser now hands callers scheme and host only, plus the reason in words. The Nightscout list throws a named error with that redacted entry and reason, and the connect route answers it as a 422 with the message instead of a bare 500, so a self-host that listed http://localhost:1337 reads what to change. The two settings cards translate a coded save refusal like the test button does; the docs and the changelog name the sub-host and sibling- port narrowing; the dispatcher floor tests assert the policy marker so a real NXDOMAIN cannot pass for a refusal. Refs #947 --- docs/api/openapi.yaml | 16 ++-- docs/self-hosting/notifications.md | 16 ++-- messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/ko.json | 1 + messages/pl.json | 1 + src/app/api/nightscout/connect/route.ts | 21 ++++- .../__tests__/private-origin-routes.test.ts | 38 +++++++-- src/app/api/settings/ntfy/route.ts | 27 ++++-- src/app/api/settings/ntfy/test/route.ts | 4 +- src/app/api/settings/webhook/route.ts | 27 ++++-- src/app/api/settings/webhook/test/route.ts | 4 +- src/components/settings/ntfy-card.tsx | 16 +++- src/components/settings/webhook-card.tsx | 16 +++- .../__tests__/private-origin-policy.test.ts | 72 +++++++++++++++- .../__tests__/safe-fetch-dispatcher.test.ts | 18 ++++ .../__tests__/egress-policy.test.ts | 32 ++++++- src/lib/notifications/egress-policy.ts | 14 ++-- .../senders/__tests__/ntfy.test.ts | 15 ++++ .../senders/__tests__/webhook.test.ts | 15 ++++ src/lib/notifications/senders/ntfy.ts | 12 ++- src/lib/notifications/senders/webhook.ts | 12 ++- src/lib/openapi/error-codes.ts | 1 + src/lib/openapi/routes/settings.ts | 8 +- src/lib/private-origin-policy.ts | 84 +++++++++++++++---- .../validations/__tests__/nightscout.test.ts | 20 +++++ src/lib/validations/nightscout.ts | 26 +++++- 30 files changed, 436 insertions(+), 85 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 74e680555..5bac35ca7 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -12022,7 +12022,8 @@ paths: "422": description: Body matched neither accepted shape, or `enabled` is true while the stored config has no server URL and topic; or the server URL is on a private network the operator has not listed in - `NOTIFICATION_PRIVATE_ORIGINS` (`meta.errorCode` = `private_origin_not_approved`). Nothing was stored. + `NOTIFICATION_PRIVATE_ORIGINS` (`meta.errorCode` = `private_origin_not_approved`), or it is a loopback, + link-local or metadata address that no grant can open (`private_origin_not_grantable`). Nothing was stored. content: application/json: schema: @@ -12149,7 +12150,8 @@ paths: "422": description: Body matched neither accepted shape, or `enabled` is true while no webhook URL is stored; or the URL is on a private network the operator has not listed in `NOTIFICATION_PRIVATE_ORIGINS` (`meta.errorCode` = - `private_origin_not_approved`). Nothing was stored. + `private_origin_not_approved`), or it is a loopback, link-local or metadata address that no grant can open + (`private_origin_not_grantable`). Nothing was stored. content: application/json: schema: @@ -12386,7 +12388,8 @@ paths: "401": *a1 "422": description: The saved server is on a private network the operator has not approved (`meta.errorCode` = - `private_origin_not_approved`). The resolved address is never echoed. + `private_origin_not_approved`), or is a loopback, link-local or metadata address that no grant can open + (`private_origin_not_grantable`). The resolved address is never echoed. content: application/json: schema: @@ -12436,7 +12439,8 @@ paths: "401": *a1 "422": description: The saved URL is on a private network the operator has not approved (`meta.errorCode` = - `private_origin_not_approved`). The resolved address is never echoed. + `private_origin_not_approved`), or is a loopback, link-local or metadata address that no grant can open + (`private_origin_not_grantable`). The resolved address is never echoed. content: application/json: schema: @@ -30099,8 +30103,8 @@ components: dashboard_layout_conflict, empty_file, health_score_config_conflict, insights_layout_conflict, invalid_base_updated_at, json_carries_no_intake_time, json_not_an_array, medication_layout_conflict, missing_required_columns, modules_conflict, mood_tag_layout_conflict, not_configured, - notification_prefs_conflict, oidc_only, private_origin_not_approved, rate_limited_self, too_many_rows, - unreadable_json, upstream_invalid_json, vapid_not_configured" + notification_prefs_conflict, oidc_only, private_origin_not_approved, private_origin_not_grantable, + rate_limited_self, too_many_rows, unreadable_json, upstream_invalid_json, vapid_not_configured" type: string additionalProperties: {} required: diff --git a/docs/self-hosting/notifications.md b/docs/self-hosting/notifications.md index 0dc298b51..74c00207c 100644 --- a/docs/self-hosting/notifications.md +++ b/docs/self-hosting/notifications.md @@ -254,14 +254,14 @@ lists it. Restart the app after changing it. What is refused where, so you know which lever to pull: -| Target | Without the grant | With the exact origin listed | -| ----------------------------------------------------------- | ----------------- | --------------------------------------- | -| Public name resolving to a public address | works | works, public pin | -| Public name with a private A record (LAN reverse proxy) | refused at send | works | -| `gotify.lan`, `gotify.home.arpa`, a Docker service name | refused at send | works | -| `gotify.local` (mDNS) | refused at save | allowed, if the container resolves mDNS | -| Literal `192.168.x.x`, `10.x.x.x`, `100.64.x.x`, `fd00::/8` | refused at save | works | -| `localhost`, `127.0.0.1`, `169.254.169.254`, `::1` | refused at save | still refused | +| Target | Without the grant | With the exact origin listed | +| ----------------------------------------------------------- | ----------------- | ---------------------------------------------- | +| Public name resolving to a public address | works | works, public pin | +| Public name with a private A record (LAN reverse proxy) | refused at send | works | +| `gotify.lan`, `gotify.home.arpa`, a Docker service name | refused at send | works | +| `gotify.local` (mDNS) | refused at save | allowed, if the container resolves mDNS | +| Literal `192.168.x.x`, `10.x.x.x`, `100.64.x.x`, `fd00::/8` | refused at save | works | +| `localhost`, `127.0.0.1`, `169.254.169.254`, `::1` | refused at save | still refused (`private_origin_not_grantable`) | A private target that is not listed is refused when the card is saved (`422`, `meta.errorCode` = `private_origin_not_approved`, the message diff --git a/messages/de.json b/messages/de.json index fada64eae..f54c191dc 100644 --- a/messages/de.json +++ b/messages/de.json @@ -5991,6 +5991,7 @@ "not_configured": "Nicht konfiguriert", "url_not_public": "URL ist kein öffentlicher Endpunkt", "private_origin_not_approved": "Ziel im privaten Netz. Der Betreiber muss diesen Origin in NOTIFICATION_PRIVATE_ORIGINS eintragen", + "private_origin_not_grantable": "Diese Adresse ist Loopback, Link-local oder eine Metadaten-Adresse; keine Betreiber-Freigabe kann sie öffnen. Bitte die LAN-Adresse des Relays verwenden", "url_invalid": "URL ist ungültig", "redirected": "Endpunkt leitet weiter — URL prüfen", "endpoint_not_found": "Endpunkt unter der URL nicht gefunden", diff --git a/messages/en.json b/messages/en.json index 6d9bc7c0e..3ac231118 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5991,6 +5991,7 @@ "not_configured": "Not configured", "url_not_public": "URL is not a public endpoint", "private_origin_not_approved": "Private network target. The operator has to list this origin in NOTIFICATION_PRIVATE_ORIGINS", + "private_origin_not_grantable": "This address is loopback, link-local or a metadata address; no operator grant can open it. Use the relay's LAN address", "url_invalid": "URL is invalid", "redirected": "Endpoint redirects — check the URL", "endpoint_not_found": "Endpoint not found at the URL", diff --git a/messages/es.json b/messages/es.json index ca0e7969b..a3ac9e070 100644 --- a/messages/es.json +++ b/messages/es.json @@ -5991,6 +5991,7 @@ "not_configured": "No configurado", "url_not_public": "La URL no es un endpoint público", "private_origin_not_approved": "Destino en una red privada. El operador debe añadir este origen a NOTIFICATION_PRIVATE_ORIGINS", + "private_origin_not_grantable": "Esta dirección es loopback, de enlace local o de metadatos; ninguna autorización del operador puede abrirla. Usa la dirección LAN del relay", "url_invalid": "La URL no es válida", "redirected": "El endpoint redirige — comprueba la URL", "endpoint_not_found": "No se encontró el endpoint en la URL", diff --git a/messages/fr.json b/messages/fr.json index 55052bcd3..b7ee3631f 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -5991,6 +5991,7 @@ "not_configured": "Non configuré", "url_not_public": "L’URL n’est pas un point de terminaison public", "private_origin_not_approved": "Cible sur un réseau privé. L’opérateur doit ajouter cette origine à NOTIFICATION_PRIVATE_ORIGINS", + "private_origin_not_grantable": "Cette adresse est une adresse de bouclage, de lien local ou de métadonnées ; aucune autorisation de l’opérateur ne peut l’ouvrir. Utilisez l’adresse LAN du relais", "url_invalid": "L’URL n’est pas valide", "redirected": "Le point de terminaison redirige — vérifiez l’URL", "endpoint_not_found": "Point de terminaison introuvable à l’URL", diff --git a/messages/it.json b/messages/it.json index 1ba139607..9f30da14a 100644 --- a/messages/it.json +++ b/messages/it.json @@ -5991,6 +5991,7 @@ "not_configured": "Non configurato", "url_not_public": "L’URL non è un endpoint pubblico", "private_origin_not_approved": "Destinazione su una rete privata. L’operatore deve aggiungere questa origine a NOTIFICATION_PRIVATE_ORIGINS", + "private_origin_not_grantable": "Questo indirizzo è di loopback, link-local o dei metadati; nessuna autorizzazione dell’operatore può aprirlo. Usa l’indirizzo LAN del relay", "url_invalid": "L’URL non è valido", "redirected": "L’endpoint reindirizza — controlla l’URL", "endpoint_not_found": "Endpoint non trovato all’URL", diff --git a/messages/ko.json b/messages/ko.json index d8a2b6581..a67d3418f 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -5991,6 +5991,7 @@ "not_configured": "설정되지 않았어요", "url_not_public": "URL이 공개 엔드포인트가 아니에요", "private_origin_not_approved": "비공개 네트워크 대상이에요. 운영자가 NOTIFICATION_PRIVATE_ORIGINS에 이 출처를 등록해야 해요", + "private_origin_not_grantable": "이 주소는 루프백, 링크 로컬 또는 메타데이터 주소예요. 운영자 등록으로도 열 수 없으니 릴레이의 LAN 주소를 사용해 주세요", "url_invalid": "URL이 올바르지 않아요", "redirected": "엔드포인트가 리디렉션돼요 — URL을 확인해 주세요", "endpoint_not_found": "해당 URL에 엔드포인트가 없어요", diff --git a/messages/pl.json b/messages/pl.json index c4dfafe32..bfeba4653 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -5991,6 +5991,7 @@ "not_configured": "Nieskonfigurowano", "url_not_public": "Adres URL nie jest publicznym punktem końcowym", "private_origin_not_approved": "Cel w sieci prywatnej. Operator musi dodać to źródło do NOTIFICATION_PRIVATE_ORIGINS", + "private_origin_not_grantable": "Ten adres to loopback, link-local lub adres metadanych; żadna zgoda operatora nie może go otworzyć. Użyj adresu LAN przekaźnika", "url_invalid": "Adres URL jest nieprawidłowy", "redirected": "Punkt końcowy przekierowuje — sprawdź adres URL", "endpoint_not_found": "Nie znaleziono punktu końcowego pod adresem URL", diff --git a/src/app/api/nightscout/connect/route.ts b/src/app/api/nightscout/connect/route.ts index 77bc5f383..a0d297e38 100644 --- a/src/app/api/nightscout/connect/route.ts +++ b/src/app/api/nightscout/connect/route.ts @@ -26,6 +26,7 @@ import { configuredNightscoutPrivateOrigins, evaluateNightscoutOrigin, nightscoutConnectSchema, + NightscoutOriginConfigError, } from "@/lib/validations/nightscout"; /** @@ -67,10 +68,22 @@ export const POST = apiHandler(async (request: NextRequest) => { } const { url, token } = result.data; - const policy = evaluateNightscoutOrigin( - url, - configuredNightscoutPrivateOrigins(), - ); + // An operator list that holds a non-grant is an operator problem, not a + // user one: say which entry and why, rather than a 500 with no words. + let privateOrigins: ReadonlySet; + try { + privateOrigins = configuredNightscoutPrivateOrigins(); + } catch (err) { + if (err instanceof NightscoutOriginConfigError) { + annotate({ + action: { name: "nightscout.connect" }, + meta: { refused: "operator_origin_list_invalid" }, + }); + return apiError(err.message, 422); + } + throw err; + } + const policy = evaluateNightscoutOrigin(url, privateOrigins); // SSRF floor at INPUT time, not merely as a side effect of the live probe // below. `fetchSgvEntries` does route through `safeFetch` with the same diff --git a/src/app/api/settings/__tests__/private-origin-routes.test.ts b/src/app/api/settings/__tests__/private-origin-routes.test.ts index 7ad276f50..fa133af22 100644 --- a/src/app/api/settings/__tests__/private-origin-routes.test.ts +++ b/src/app/api/settings/__tests__/private-origin-routes.test.ts @@ -179,10 +179,10 @@ describe("PUT /api/settings/webhook — private targets", () => { expect(json.meta).toBeUndefined(); }); - it("refuses a loopback grant at save time: listing it does not open it", async () => { - // The parser drops a loopback or localhost grant with a warning, so the - // save is refused with the reason and the operator learns that the list - // is not the way to reach the container itself. + it("answers a loopback target with the not-grantable code, listed or not (M1)", async () => { + // This used to pin a dead end: the route said "list it", the parser + // dropped the listing, and the next save said "list it" again. A target + // no grant can open gets its own code and a message that says so. vi.spyOn(console, "warn").mockImplementation(() => {}); process.env.NOTIFICATION_PRIVATE_ORIGINS = "http://127.0.0.1:8080, http://localhost:8080"; @@ -190,11 +190,16 @@ describe("PUT /api/settings/webhook — private targets", () => { for (const url of [ "http://127.0.0.1:8080/message", "http://localhost:8080/message", + "http://169.254.169.254/latest", + "http://[::1]:8080/message", ]) { const response = await put("webhook", { url, enabled: true }); expect(response.status).toBe(422); const json = await response.json(); - expect(json.meta).toEqual({ errorCode: "private_origin_not_approved" }); + expect(json.meta).toEqual({ errorCode: "private_origin_not_grantable" }); + expect(json.error).toMatch(/LAN address/); + expect(json.error).not.toContain("NOTIFICATION_PRIVATE_ORIGINS"); + expect(json.details.issues.length).toBeGreaterThan(0); } expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled(); }); @@ -298,6 +303,29 @@ describe("POST /api/settings/{webhook,ntfy}/test — the refusal names itself", }, ); + it.each([ + ["webhook", sendViaWebhookMock], + ["ntfy", sendViaNtfyMock], + ])( + "%s: forwards the not-grantable code with a message that does not say list it (M1)", + async (path, sender) => { + sender.mockResolvedValue({ + ok: false, + hardReject: false, + reason: `${path}_private_origin_refused`, + errorCode: "private_origin_not_grantable", + }); + + const response = await post(path); + + expect(response.status).toBe(422); + const json = await response.json(); + expect(json.meta).toEqual({ errorCode: "private_origin_not_grantable" }); + expect(json.error).toMatch(/LAN address/); + expect(json.error).not.toContain("NOTIFICATION_PRIVATE_ORIGINS"); + }, + ); + it.each([ ["webhook", sendViaWebhookMock], ["ntfy", sendViaNtfyMock], diff --git a/src/app/api/settings/ntfy/route.ts b/src/app/api/settings/ntfy/route.ts index 1fb7d455a..ab084384f 100644 --- a/src/app/api/settings/ntfy/route.ts +++ b/src/app/api/settings/ntfy/route.ts @@ -13,7 +13,8 @@ import { import { evaluateNotificationTarget, isAllowedNotificationTarget, - PRIVATE_ORIGIN_NOT_APPROVED, + PRIVATE_ORIGIN_NOT_APPROVED_CODE, + PRIVATE_ORIGIN_NOT_GRANTABLE_CODE, } from "@/lib/notifications/egress-policy"; import { encrypt, decrypt } from "@/lib/crypto"; import { NextRequest } from "next/server"; @@ -30,6 +31,8 @@ const ntfySettingsSchema = ntfySettingsSchemaWith(isAllowedNotificationTarget); const PRIVATE_ORIGIN_REFUSAL = "This server is on a private network. The operator has to list its exact origin (scheme://host:port) in NOTIFICATION_PRIVATE_ORIGINS before HealthLog can send to it."; +const NOT_GRANTABLE_REFUSAL = + "This server is loopback, link-local or a metadata address, which no operator grant can open. Use the relay's LAN address instead."; /** * Refusal for an enable attempt on a channel the operator switched off @@ -145,18 +148,26 @@ export const PUT = apiHandler(async (request: NextRequest) => { // adds the code and a message naming the operator's lever (#947). const issues = sanitiseZodIssues(parsed.error.issues); const candidate = (body as { serverUrl?: unknown } | null)?.serverUrl; + const reason = + typeof candidate === "string" + ? evaluateNotificationTarget(candidate).reasonCode + : null; if ( - typeof candidate === "string" && - evaluateNotificationTarget(candidate).reasonCode === - PRIVATE_ORIGIN_NOT_APPROVED + reason === PRIVATE_ORIGIN_NOT_APPROVED_CODE || + reason === PRIVATE_ORIGIN_NOT_GRANTABLE_CODE ) { annotate({ action: { name: "settings.ntfy.update" }, - meta: { refused: PRIVATE_ORIGIN_NOT_APPROVED }, - }); - return apiValidationError(PRIVATE_ORIGIN_REFUSAL, issues, 422, { - errorCode: PRIVATE_ORIGIN_NOT_APPROVED, + meta: { refused: reason }, }); + return apiValidationError( + reason === PRIVATE_ORIGIN_NOT_GRANTABLE_CODE + ? NOT_GRANTABLE_REFUSAL + : PRIVATE_ORIGIN_REFUSAL, + issues, + 422, + { errorCode: reason }, + ); } return apiValidationError("Invalid data", issues, 422); } diff --git a/src/app/api/settings/ntfy/test/route.ts b/src/app/api/settings/ntfy/test/route.ts index 3805f9f8f..ad1371206 100644 --- a/src/app/api/settings/ntfy/test/route.ts +++ b/src/app/api/settings/ntfy/test/route.ts @@ -51,7 +51,9 @@ export const POST = apiHandler(async () => { meta: { success: false, refused: result.errorCode }, }); return apiError( - "The server is on a private network the operator has not approved. List its exact origin (scheme://host:port) in NOTIFICATION_PRIVATE_ORIGINS on the server.", + result.errorCode === "private_origin_not_grantable" + ? "The server is loopback, link-local or a metadata address, which no operator grant can open. Use the relay's LAN address instead." + : "The server is on a private network the operator has not approved. List its exact origin (scheme://host:port) in NOTIFICATION_PRIVATE_ORIGINS on the server.", 422, { errorCode: result.errorCode }, ); diff --git a/src/app/api/settings/webhook/route.ts b/src/app/api/settings/webhook/route.ts index 4d4fba24e..c4f3ded09 100644 --- a/src/app/api/settings/webhook/route.ts +++ b/src/app/api/settings/webhook/route.ts @@ -13,7 +13,8 @@ import { import { evaluateNotificationTarget, isAllowedNotificationTarget, - PRIVATE_ORIGIN_NOT_APPROVED, + PRIVATE_ORIGIN_NOT_APPROVED_CODE, + PRIVATE_ORIGIN_NOT_GRANTABLE_CODE, } from "@/lib/notifications/egress-policy"; import { encrypt, decrypt } from "@/lib/crypto"; import { NextRequest } from "next/server"; @@ -32,6 +33,8 @@ const webhookSettingsSchema = webhookSettingsSchemaWith( const PRIVATE_ORIGIN_REFUSAL = "This address is on a private network. The operator has to list its exact origin (scheme://host:port) in NOTIFICATION_PRIVATE_ORIGINS before HealthLog can send to it."; +const NOT_GRANTABLE_REFUSAL = + "This address is loopback, link-local or a metadata address, which no operator grant can open. Use the relay's LAN address instead."; /** * Generic-webhook channel config (v1.17.1). @@ -139,18 +142,26 @@ export const PUT = apiHandler(async (request: NextRequest) => { // adds the code and a message naming the operator's lever (#947). const issues = sanitiseZodIssues(parsed.error.issues); const candidate = (body as { url?: unknown } | null)?.url; + const reason = + typeof candidate === "string" + ? evaluateNotificationTarget(candidate).reasonCode + : null; if ( - typeof candidate === "string" && - evaluateNotificationTarget(candidate).reasonCode === - PRIVATE_ORIGIN_NOT_APPROVED + reason === PRIVATE_ORIGIN_NOT_APPROVED_CODE || + reason === PRIVATE_ORIGIN_NOT_GRANTABLE_CODE ) { annotate({ action: { name: "settings.webhook.update" }, - meta: { refused: PRIVATE_ORIGIN_NOT_APPROVED }, - }); - return apiValidationError(PRIVATE_ORIGIN_REFUSAL, issues, 422, { - errorCode: PRIVATE_ORIGIN_NOT_APPROVED, + meta: { refused: reason }, }); + return apiValidationError( + reason === PRIVATE_ORIGIN_NOT_GRANTABLE_CODE + ? NOT_GRANTABLE_REFUSAL + : PRIVATE_ORIGIN_REFUSAL, + issues, + 422, + { errorCode: reason }, + ); } return apiValidationError("Invalid data", issues, 422); } diff --git a/src/app/api/settings/webhook/test/route.ts b/src/app/api/settings/webhook/test/route.ts index 267345482..964c74141 100644 --- a/src/app/api/settings/webhook/test/route.ts +++ b/src/app/api/settings/webhook/test/route.ts @@ -50,7 +50,9 @@ export const POST = apiHandler(async () => { meta: { success: false, refused: result.errorCode }, }); return apiError( - "The target is on a private network the operator has not approved. List its exact origin (scheme://host:port) in NOTIFICATION_PRIVATE_ORIGINS on the server.", + result.errorCode === "private_origin_not_grantable" + ? "The target is loopback, link-local or a metadata address, which no operator grant can open. Use the relay's LAN address instead." + : "The target is on a private network the operator has not approved. List its exact origin (scheme://host:port) in NOTIFICATION_PRIVATE_ORIGINS on the server.", 422, { errorCode: result.errorCode }, ); diff --git a/src/components/settings/ntfy-card.tsx b/src/components/settings/ntfy-card.tsx index c1bbe9dfa..349cf3631 100644 --- a/src/components/settings/ntfy-card.tsx +++ b/src/components/settings/ntfy-card.tsx @@ -72,8 +72,20 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) { }), }); if (!res.ok) { - const json = await res.json(); - throw new Error(json.error || t("common.error")); + const json = (await res.json()) as { + error?: string; + meta?: { errorCode?: string }; + }; + // A coded refusal (the private-origin policy) translates like the + // test button's; anything else shows the server sentence. + const code = json.meta?.errorCode; + const key = code ? `settings.testConnection.errors.${code}` : null; + const translated = key ? t(key) : null; + throw new Error( + translated && translated !== key + ? translated + : json.error || t("common.error"), + ); } }, onSuccess: () => { diff --git a/src/components/settings/webhook-card.tsx b/src/components/settings/webhook-card.tsx index ebfe9f2bf..bc34ee243 100644 --- a/src/components/settings/webhook-card.tsx +++ b/src/components/settings/webhook-card.tsx @@ -72,8 +72,20 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) { }), }); if (!res.ok) { - const json = await res.json(); - throw new Error(json.error || t("common.error")); + const json = (await res.json()) as { + error?: string; + meta?: { errorCode?: string }; + }; + // A coded refusal (the private-origin policy) translates like the + // test button's; anything else shows the server sentence. + const code = json.meta?.errorCode; + const key = code ? `settings.testConnection.errors.${code}` : null; + const translated = key ? t(key) : null; + throw new Error( + translated && translated !== key + ? translated + : json.error || t("common.error"), + ); } }, onSuccess: () => { diff --git a/src/lib/__tests__/private-origin-policy.test.ts b/src/lib/__tests__/private-origin-policy.test.ts index 7cd57b174..3976d5116 100644 --- a/src/lib/__tests__/private-origin-policy.test.ts +++ b/src/lib/__tests__/private-origin-policy.test.ts @@ -16,9 +16,12 @@ import { describe, expect, it, vi } from "vitest"; import { canonicalOrigin, + describeGrantRejection, evaluateOriginGrant, parsePrivateOrigins, - PRIVATE_ORIGIN_NOT_APPROVED, + PRIVATE_ORIGIN_NOT_APPROVED_CODE, + PRIVATE_ORIGIN_NOT_GRANTABLE_CODE, + redactGrantEntry, } from "../private-origin-policy"; describe("canonicalOrigin", () => { @@ -113,7 +116,7 @@ describe("parsePrivateOrigins", () => { expect(onInvalid).not.toHaveBeenCalled(); }); - it("hands every malformed entry to the caller and keeps the valid ones", () => { + it("hands every malformed entry to the caller, with a reason, and keeps the valid ones", () => { const onInvalid = vi.fn(); expect( parsePrivateOrigins( @@ -126,6 +129,25 @@ describe("parsePrivateOrigins", () => { "http://127.0.0.1:8080", "not an origin", ]); + expect(onInvalid.mock.calls[1][1]).toMatch(/loopback.*cannot be granted/); + expect(onInvalid.mock.calls[0][1]).toMatch(/one exact http\(s\) origin/); + }); + + it("never hands a query, fragment or userinfo to the caller (M3)", () => { + const onInvalid = vi.fn(); + parsePrivateOrigins( + "http://10.0.0.4/message?token=AbC123, http://user:pw@gotify.lan/, https://ntfy.lan/topic#frag", + onInvalid, + ); + const seen = onInvalid.mock.calls.map(([entry]) => entry); + expect(seen).toEqual([ + "http://10.0.0.4/message", + "http://gotify.lan/", + "https://ntfy.lan/topic", + ]); + expect(JSON.stringify(onInvalid.mock.calls)).not.toMatch( + /AbC123|token=|user:pw|#frag/, + ); }); it("lets the caller turn a malformed entry into a throw", () => { @@ -160,7 +182,7 @@ describe("evaluateOriginGrant", () => { allowed: false, canonicalOrigin: origin, privateOriginApproved: false, - reasonCode: PRIVATE_ORIGIN_NOT_APPROVED, + reasonCode: PRIVATE_ORIGIN_NOT_APPROVED_CODE, }); }); @@ -178,7 +200,49 @@ describe("evaluateOriginGrant", () => { allowed: false, canonicalOrigin: "http://192.168.1.9:8080", privateOriginApproved: false, - reasonCode: PRIVATE_ORIGIN_NOT_APPROVED, + reasonCode: PRIVATE_ORIGIN_NOT_APPROVED_CODE, }); }); + + it.each([ + ["IPv4 loopback", "http://127.0.0.1:8080"], + ["localhost", "http://localhost:8080"], + ["a *.localhost name", "http://gotify.localhost"], + ["link-local / metadata", "http://169.254.169.254"], + ["IPv6 loopback", "http://[::1]:8080"], + ["unspecified", "http://0.0.0.0:8080"], + ])( + "answers %s with the not-grantable code, not a request to list it (M1)", + (_label, origin) => { + expect(evaluateOriginGrant(origin, grants)).toEqual({ + allowed: false, + canonicalOrigin: origin, + privateOriginApproved: false, + reasonCode: PRIVATE_ORIGIN_NOT_GRANTABLE_CODE, + }); + }, + ); +}); + +describe("redactGrantEntry / describeGrantRejection", () => { + it("keeps scheme and host, drops userinfo, query and fragment", () => { + expect(redactGrantEntry("http://user:pw@gotify.lan/?token=x#y")).toBe( + "http://gotify.lan/", + ); + expect(redactGrantEntry("https://gotify.example.com")).toBe( + "https://gotify.example.com", + ); + expect(redactGrantEntry("not an origin?token=x")).toBe("not an origin"); + }); + + it("names the never-grantable class apart from a grammar error", () => { + expect(describeGrantRejection("http://localhost:1337")).toMatch( + /loopback.*LAN address/, + ); + expect(describeGrantRejection("http://[::1]:1337")).toMatch(/loopback/); + expect(describeGrantRejection("https://*.lan")).toMatch(/exact http/); + expect(describeGrantRejection("http://10.0.0.4/path")).toMatch( + /exact http/, + ); + }); }); diff --git a/src/lib/__tests__/safe-fetch-dispatcher.test.ts b/src/lib/__tests__/safe-fetch-dispatcher.test.ts index aa1446ab7..f557905bf 100644 --- a/src/lib/__tests__/safe-fetch-dispatcher.test.ts +++ b/src/lib/__tests__/safe-fetch-dispatcher.test.ts @@ -24,6 +24,21 @@ const EMBEDDED_PRIVATE_DNS_ANSWERS = [ ["RFC8215 local-use metadata", "64:ff9b:1:a9fe:0:a9fe::"], ] as const; +/** True when the policy marker the dispatcher stamps is anywhere in the chain. */ +function hasPrivateHostMarker(error: unknown): boolean { + if (!error || typeof error !== "object") return false; + const value = error as { + safeFetchKind?: unknown; + cause?: unknown; + errors?: unknown[]; + }; + return ( + value.safeFetchKind === "private_host" || + hasPrivateHostMarker(value.cause) || + (value.errors ?? []).some(hasPrivateHostMarker) + ); +} + function errorCodes(error: unknown): string[] { if (!error || typeof error !== "object") return []; const value = error as { @@ -306,6 +321,9 @@ describe("pinnedOperatorApprovedDispatcher", () => { expect(caught).toBeDefined(); expect(errorCodes(caught)).toContain("ENOTFOUND"); + // A real NXDOMAIN would also be ENOTFOUND; the policy marker is what + // proves the refusal came from the floor and not from the resolver. + expect(hasPrivateHostMarker(caught)).toBe(true); }, 5_000, ); diff --git a/src/lib/notifications/__tests__/egress-policy.test.ts b/src/lib/notifications/__tests__/egress-policy.test.ts index 50631967e..77e5585a2 100644 --- a/src/lib/notifications/__tests__/egress-policy.test.ts +++ b/src/lib/notifications/__tests__/egress-policy.test.ts @@ -75,6 +75,21 @@ describe("configuredNotificationPrivateOrigins", () => { expect(lines[0]).toContain("NOTIFICATION_PRIVATE_ORIGINS"); expect(lines[0]).toContain("https://*.lan"); expect(lines[1]).toContain("http://127.0.0.1:8080"); + expect(lines[1]).toMatch(/loopback.*cannot be granted/); + }); + + it("warns without echoing a query, fragment or userinfo (M3)", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + process.env.NOTIFICATION_PRIVATE_ORIGINS = + "http://10.0.0.4/message?token=AbC123, http://user:pw@gotify.lan/, https://ntfy.lan/topic#frag"; + + expect(configuredNotificationPrivateOrigins()).toEqual(new Set()); + + const lines = warn.mock.calls.map((call) => String(call[0])); + expect(lines).toHaveLength(3); + expect(lines[0]).toContain('"http://10.0.0.4/message"'); + expect(lines[1]).toContain('"http://gotify.lan/"'); + expect(lines.join("\n")).not.toMatch(/AbC123|token=|user:pw|#frag/); }); it("never widens on a wholly malformed value", () => { @@ -137,12 +152,25 @@ describe("evaluateNotificationTarget", () => { }); }); - it("keeps the raw-string floor: an octal loopback spelling is refused, not parsed", () => { + it("keeps the raw-string floor: an octal loopback spelling is refused as never grantable", () => { expect( evaluateNotificationTarget("http://0177.0.0.1/message"), ).toMatchObject({ allowed: false, - reasonCode: "private_origin_not_approved", + reasonCode: "private_origin_not_grantable", + }); + }); + + it.each([ + ["loopback", "http://127.0.0.1:8080/message"], + ["localhost", "http://localhost:8080/message"], + ["metadata", "http://169.254.169.254/latest"], + ["IPv6 loopback", "http://[::1]:8080/message"], + ])("answers a %s target with the not-grantable code (M1)", (_l, url) => { + process.env.NOTIFICATION_PRIVATE_ORIGINS = "https://gotify.example.com"; + expect(evaluateNotificationTarget(url)).toMatchObject({ + allowed: false, + reasonCode: "private_origin_not_grantable", }); }); diff --git a/src/lib/notifications/egress-policy.ts b/src/lib/notifications/egress-policy.ts index aab9a14f5..35fd67daa 100644 --- a/src/lib/notifications/egress-policy.ts +++ b/src/lib/notifications/egress-policy.ts @@ -3,7 +3,8 @@ import { INVALID_ORIGIN, originOfUrl, parsePrivateOrigins, - PRIVATE_ORIGIN_NOT_APPROVED, + PRIVATE_ORIGIN_NOT_APPROVED_CODE, + PRIVATE_ORIGIN_NOT_GRANTABLE_CODE, type OriginVerdict, } from "@/lib/private-origin-policy"; import { annotate, getEvent } from "@/lib/logging/context"; @@ -43,10 +44,11 @@ let cachedOrigins: ReadonlySet = new Set(); export function configuredNotificationPrivateOrigins(): ReadonlySet { const raw = process.env.NOTIFICATION_PRIVATE_ORIGINS; if (raw === cachedRaw) return cachedOrigins; - cachedOrigins = parsePrivateOrigins(raw, (entry) => { - console.warn( - `${ENV_NAME}: ignoring entry "${entry}" — a grant is one exact http(s) origin (scheme://host[:port]) on a private network; loopback, link-local and metadata addresses cannot be granted`, - ); + // The entry arrives reduced to scheme and host: this line goes to stdout, + // not through the wide-event redactors, and a malformed entry is where a + // pasted token or userinfo would sit. + cachedOrigins = parsePrivateOrigins(raw, (redactedEntry, reason) => { + console.warn(`${ENV_NAME}: ignoring entry "${redactedEntry}": ${reason}`); }); cachedRaw = raw; return cachedOrigins; @@ -85,7 +87,7 @@ export function isAllowedNotificationTarget(url: string): boolean { return evaluateNotificationTarget(url).allowed; } -export { PRIVATE_ORIGIN_NOT_APPROVED }; +export { PRIVATE_ORIGIN_NOT_APPROVED_CODE, PRIVATE_ORIGIN_NOT_GRANTABLE_CODE }; /** * Wide-event mark for a send that used the grant: one row per private diff --git a/src/lib/notifications/senders/__tests__/ntfy.test.ts b/src/lib/notifications/senders/__tests__/ntfy.test.ts index 770a6fdae..4e71b47f7 100644 --- a/src/lib/notifications/senders/__tests__/ntfy.test.ts +++ b/src/lib/notifications/senders/__tests__/ntfy.test.ts @@ -217,4 +217,19 @@ describe("sendViaNtfy", () => { }, ); }); + + it("reports the not-grantable code for a loopback target, without dialling (M1)", async () => { + const result = await sendViaNtfy( + { serverUrl: "http://localhost:8080", topic: "health" }, + payload(), + ); + + expect(safeFetchMock).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + ok: false, + hardReject: false, + reason: "ntfy_private_origin_refused", + errorCode: "private_origin_not_grantable", + }); + }); }); diff --git a/src/lib/notifications/senders/__tests__/webhook.test.ts b/src/lib/notifications/senders/__tests__/webhook.test.ts index 410ce3896..e9d8a561e 100644 --- a/src/lib/notifications/senders/__tests__/webhook.test.ts +++ b/src/lib/notifications/senders/__tests__/webhook.test.ts @@ -265,4 +265,19 @@ describe("sendViaWebhook", () => { expect(opts.operatorApprovedPrivateOrigin).toBe("http://10.0.0.9:8080"); }); }); + + it("reports the not-grantable code for a loopback target, without dialling (M1)", async () => { + const result = await sendViaWebhook( + { url: "http://127.0.0.1:8080/message" }, + payload(), + ); + + expect(safeFetchMock).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + ok: false, + hardReject: false, + reason: "webhook_private_origin_refused", + errorCode: "private_origin_not_grantable", + }); + }); }); diff --git a/src/lib/notifications/senders/ntfy.ts b/src/lib/notifications/senders/ntfy.ts index 7c601420c..1692112b8 100644 --- a/src/lib/notifications/senders/ntfy.ts +++ b/src/lib/notifications/senders/ntfy.ts @@ -13,8 +13,9 @@ import { isUrgentPayload } from "@/lib/notifications/types"; import { annotatePrivateOriginEgress, evaluateNotificationTarget, - PRIVATE_ORIGIN_NOT_APPROVED, + PRIVATE_ORIGIN_NOT_APPROVED_CODE, } from "@/lib/notifications/egress-policy"; +import type { OriginReason } from "@/lib/private-origin-policy"; /** * Send notification via ntfy (simple HTTP POST). @@ -31,6 +32,10 @@ export async function sendViaNtfy( ): Promise { const userId = payload.recipientUserId ?? payload.userId; const start = performance.now(); + // Set when the policy itself refuses, so the outcome can say which of the + // two refusals it was: an origin the operator could list, or one no grant + // can open. + let policyReason: OriginReason | null = null; try { const url = `${config.serverUrl.replace(/\/$/, "")}/${encodeURIComponent(config.topic)}`; @@ -74,6 +79,7 @@ export async function sendViaNtfy( // the settings routes and the webhook sender. const policy = evaluateNotificationTarget(url); if (!policy.allowed || !policy.canonicalOrigin) { + policyReason = policy.reasonCode; throw new SafeFetchError( "ntfy server refused by the notification origin policy", "private_host", @@ -156,7 +162,9 @@ export async function sendViaNtfy( hardReject: false, reason, message, - ...(policyRefused ? { errorCode: PRIVATE_ORIGIN_NOT_APPROVED } : {}), + ...(policyRefused + ? { errorCode: policyReason ?? PRIVATE_ORIGIN_NOT_APPROVED_CODE } + : {}), }; } } diff --git a/src/lib/notifications/senders/webhook.ts b/src/lib/notifications/senders/webhook.ts index 74483c9b0..c9d480a7e 100644 --- a/src/lib/notifications/senders/webhook.ts +++ b/src/lib/notifications/senders/webhook.ts @@ -12,8 +12,9 @@ import { stripHtml } from "@/lib/notifications/strip-html"; import { annotatePrivateOriginEgress, evaluateNotificationTarget, - PRIVATE_ORIGIN_NOT_APPROVED, + PRIVATE_ORIGIN_NOT_APPROVED_CODE, } from "@/lib/notifications/egress-policy"; +import type { OriginReason } from "@/lib/private-origin-policy"; /** * Send a notification via a generic outbound webhook (v1.17.1). @@ -45,6 +46,10 @@ export async function sendViaWebhook( ): Promise { const userId = payload.recipientUserId ?? payload.userId; const start = performance.now(); + // Set when the policy itself refuses, so the outcome can say which of the + // two refusals it was: an origin the operator could list, or one no grant + // can open. + let policyReason: OriginReason | null = null; try { const headers: Record = { "Content-Type": "application/json", @@ -75,6 +80,7 @@ export async function sendViaWebhook( const policy = evaluateNotificationTarget(config.url); if (!policy.allowed || !policy.canonicalOrigin) { + policyReason = policy.reasonCode; throw new SafeFetchError( "webhook target refused by the notification origin policy", "private_host", @@ -159,7 +165,9 @@ export async function sendViaWebhook( hardReject: false, reason, message, - ...(policyRefused ? { errorCode: PRIVATE_ORIGIN_NOT_APPROVED } : {}), + ...(policyRefused + ? { errorCode: policyReason ?? PRIVATE_ORIGIN_NOT_APPROVED_CODE } + : {}), }; } } diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts index 042a10e8e..0c00ef15b 100644 --- a/src/lib/openapi/error-codes.ts +++ b/src/lib/openapi/error-codes.ts @@ -334,6 +334,7 @@ export const ERROR_CODE_CATALOGUE: Readonly> = "notification_prefs_conflict", "oidc_only", "private_origin_not_approved", + "private_origin_not_grantable", "rate_limited_self", "too_many_rows", "unreadable_json", diff --git a/src/lib/openapi/routes/settings.ts b/src/lib/openapi/routes/settings.ts index f1e30a86a..833bc2f4f 100644 --- a/src/lib/openapi/routes/settings.ts +++ b/src/lib/openapi/routes/settings.ts @@ -400,7 +400,7 @@ export const settingsPaths: NonNullable = { ...stdResponses, "422": { description: - "Body matched neither accepted shape, or `enabled` is true while the stored config has no server URL and topic; or the server URL is on a private network the operator has not listed in `NOTIFICATION_PRIVATE_ORIGINS` (`meta.errorCode` = `private_origin_not_approved`). Nothing was stored.", + "Body matched neither accepted shape, or `enabled` is true while the stored config has no server URL and topic; or the server URL is on a private network the operator has not listed in `NOTIFICATION_PRIVATE_ORIGINS` (`meta.errorCode` = `private_origin_not_approved`), or it is a loopback, link-local or metadata address that no grant can open (`private_origin_not_grantable`). Nothing was stored.", content: { "application/json": { schema: errorEnvelope } }, }, }, @@ -516,7 +516,7 @@ export const settingsPaths: NonNullable = { ...stdResponses, "422": { description: - "Body matched neither accepted shape, or `enabled` is true while no webhook URL is stored; or the URL is on a private network the operator has not listed in `NOTIFICATION_PRIVATE_ORIGINS` (`meta.errorCode` = `private_origin_not_approved`). Nothing was stored.", + "Body matched neither accepted shape, or `enabled` is true while no webhook URL is stored; or the URL is on a private network the operator has not listed in `NOTIFICATION_PRIVATE_ORIGINS` (`meta.errorCode` = `private_origin_not_approved`), or it is a loopback, link-local or metadata address that no grant can open (`private_origin_not_grantable`). Nothing was stored.", content: { "application/json": { schema: errorEnvelope } }, }, }, @@ -719,7 +719,7 @@ export const settingsPaths: NonNullable = { ...stdResponses, "422": { description: - "The saved server is on a private network the operator has not approved (`meta.errorCode` = `private_origin_not_approved`). The resolved address is never echoed.", + "The saved server is on a private network the operator has not approved (`meta.errorCode` = `private_origin_not_approved`), or is a loopback, link-local or metadata address that no grant can open (`private_origin_not_grantable`). The resolved address is never echoed.", content: { "application/json": { schema: errorEnvelope } }, }, }, @@ -765,7 +765,7 @@ export const settingsPaths: NonNullable = { ...stdResponses, "422": { description: - "The saved URL is on a private network the operator has not approved (`meta.errorCode` = `private_origin_not_approved`). The resolved address is never echoed.", + "The saved URL is on a private network the operator has not approved (`meta.errorCode` = `private_origin_not_approved`), or is a loopback, link-local or metadata address that no grant can open (`private_origin_not_grantable`). The resolved address is never echoed.", content: { "application/json": { schema: errorEnvelope } }, }, }, diff --git a/src/lib/private-origin-policy.ts b/src/lib/private-origin-policy.ts index df27715c6..1f2c0d8c2 100644 --- a/src/lib/private-origin-policy.ts +++ b/src/lib/private-origin-policy.ts @@ -17,12 +17,64 @@ import { * several origins. */ -export const PRIVATE_ORIGIN_NOT_APPROVED = +export const PRIVATE_ORIGIN_NOT_APPROVED_CODE = "private_origin_not_approved" as const; +/** + * The target is loopback, unspecified, link-local or the metadata range: no + * grant can ever open it, so telling the user to ask the operator would send + * them in a circle. On host networking the LAN address is the answer. + */ +export const PRIVATE_ORIGIN_NOT_GRANTABLE_CODE = + "private_origin_not_grantable" as const; export const INVALID_ORIGIN = "invalid_origin" as const; export type OriginReason = - typeof PRIVATE_ORIGIN_NOT_APPROVED | typeof INVALID_ORIGIN; + | typeof PRIVATE_ORIGIN_NOT_APPROVED_CODE + | typeof PRIVATE_ORIGIN_NOT_GRANTABLE_CODE + | typeof INVALID_ORIGIN; + +/** + * A hostname no grant may name: `localhost` / `*.localhost` (loopback by + * definition, RFC 6761) or a literal address outside the grantable ranges. + * DNS names are not judged here — a name that resolves into those ranges is + * dropped at dial time by the pinned dispatcher. + */ +export function isNeverGrantableHost(hostname: string): boolean { + const lower = hostname.toLowerCase(); + if (lower === "localhost" || lower.endsWith(".localhost")) return true; + const literal = lower.replace(/^\[|\]$/g, ""); + return isIP(literal) !== 0 && !isOperatorGrantableIp(literal); +} + +/** + * The entry as it may be logged or put in an error message: scheme and host + * only. A malformed entry is malformed precisely when it carries the shapes + * that hold a secret — a query with a token, userinfo, a capability URL + * pasted whole — and this text goes to stdout, not through the wide-event + * redactors. + */ +export function redactGrantEntry(entry: string): string { + return entry + .split(/[?#]/, 1)[0] + .replace(/\/\/[^/@]*@/, "//") + .slice(0, 200); +} + +/** Why an entry is not a grant, in operator-readable words. */ +export function describeGrantRejection(entry: string): string { + try { + const url = new URL(entry); + if ( + (url.protocol === "http:" || url.protocol === "https:") && + isNeverGrantableHost(url.hostname) + ) { + return "loopback, link-local and metadata addresses cannot be granted; on host networking list the LAN address of the relay instead"; + } + } catch { + // fall through to the grammar sentence + } + return "a grant is one exact http(s) origin (scheme://host[:port]) with no path, query, credentials or wildcard"; +} export interface OriginVerdict { allowed: boolean; @@ -48,17 +100,12 @@ export function canonicalOrigin(value: string): string | 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; - // `localhost` and `*.localhost` are loopback by definition (RFC 6761); - // refuse them at parse time like a literal loopback address. Other + // Loopback, unspecified, link-local and metadata literals, and + // `localhost` / `*.localhost`, are refused at parse time. Other // reserved-looking names (`.local` mDNS, `.internal`, `.lan`) stay // grantable: an explicitly listed origin is what the list is for, and // the dial-time floor still drops a loopback or metadata answer. - const hostname = url.hostname.toLowerCase(); - if (hostname === "localhost" || hostname.endsWith(".localhost")) { - return null; - } - const literal = hostname.replace(/^\[|\]$/g, ""); - if (isIP(literal) !== 0 && !isOperatorGrantableIp(literal)) return null; + if (isNeverGrantableHost(url.hostname)) return null; return url.origin; } catch { return null; @@ -102,15 +149,16 @@ function sharesApprovedPrivateHostname( /** * 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 + * Every malformed non-empty entry is handed to `onInvalid` with the entry + * already reduced to scheme and host (`redactGrantEntry`) and the reason in + * words (`describeGrantRejection`); the callback 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, + onInvalid: (redactedEntry: string, reason: string) => void, ): ReadonlySet { const origins = new Set(); for (const entry of (raw ?? "").split(",")) { @@ -118,7 +166,7 @@ export function parsePrivateOrigins( if (!trimmed) continue; const origin = canonicalOrigin(trimmed); if (!origin) { - onInvalid(trimmed); + onInvalid(redactGrantEntry(trimmed), describeGrantRejection(trimmed)); continue; } origins.add(origin); @@ -158,7 +206,7 @@ export function evaluateOriginGrant( allowed: false, canonicalOrigin: origin, privateOriginApproved: false, - reasonCode: PRIVATE_ORIGIN_NOT_APPROVED, + reasonCode: PRIVATE_ORIGIN_NOT_APPROVED_CODE, }; } @@ -171,10 +219,14 @@ export function evaluateOriginGrant( }; } + // Two refusals that read differently to the person who gets them: a + // private address the operator could list, and one no grant can open. return { allowed: false, canonicalOrigin: origin, privateOriginApproved: false, - reasonCode: PRIVATE_ORIGIN_NOT_APPROVED, + reasonCode: isNeverGrantableHost(new URL(origin).hostname) + ? PRIVATE_ORIGIN_NOT_GRANTABLE_CODE + : PRIVATE_ORIGIN_NOT_APPROVED_CODE, }; } diff --git a/src/lib/validations/__tests__/nightscout.test.ts b/src/lib/validations/__tests__/nightscout.test.ts index 6cc5127b8..190eaed04 100644 --- a/src/lib/validations/__tests__/nightscout.test.ts +++ b/src/lib/validations/__tests__/nightscout.test.ts @@ -120,4 +120,24 @@ describe("Nightscout exact-origin policy", () => { reasonCode: "invalid_origin", }); }); + + it("names the entry and the reason when a loopback grant is listed, without its token (M2)", () => { + let caught: unknown; + try { + parseNightscoutPrivateOrigins( + "https://cgm.lan, http://localhost:1337/?token=SECRET123", + ); + } catch (err) { + caught = err; + } + expect(caught).toBeInstanceOf(Error); + const message = (caught as Error).message; + expect((caught as Error).name).toBe("NightscoutOriginConfigError"); + expect(message).toContain( + 'NIGHTSCOUT_PRIVATE_ORIGINS entry "http://localhost:1337/"', + ); + expect(message).toMatch(/loopback.*LAN address/); + expect(message).not.toContain("SECRET123"); + expect(message).not.toContain("token="); + }); }); diff --git a/src/lib/validations/nightscout.ts b/src/lib/validations/nightscout.ts index c7df58694..4c21be4a9 100644 --- a/src/lib/validations/nightscout.ts +++ b/src/lib/validations/nightscout.ts @@ -4,14 +4,32 @@ import { evaluateOriginGrant, INVALID_ORIGIN, parsePrivateOrigins, - PRIVATE_ORIGIN_NOT_APPROVED, + PRIVATE_ORIGIN_NOT_APPROVED_CODE, type OriginReason, type OriginVerdict, } from "@/lib/private-origin-policy"; -export const NIGHTSCOUT_PRIVATE_ORIGIN_REASON = PRIVATE_ORIGIN_NOT_APPROVED; +export const NIGHTSCOUT_PRIVATE_ORIGIN_REASON = + PRIVATE_ORIGIN_NOT_APPROVED_CODE; export const NIGHTSCOUT_INVALID_ORIGIN_REASON = INVALID_ORIGIN; +/** + * Thrown when `NIGHTSCOUT_PRIVATE_ORIGINS` holds an entry that is not a + * grant. The message names the entry (scheme and host only) and the reason, + * so the sync ledger and the connect form show the operator what to edit + * instead of a bare "invalid entry". Loopback and `localhost` entries + * became invalid with the shared address floor (#947); a host-networking + * deployment lists the LAN address instead. + */ +export class NightscoutOriginConfigError extends Error { + constructor(redactedEntry: string, reason: string) { + super( + `Invalid NIGHTSCOUT_PRIVATE_ORIGINS entry "${redactedEntry}": ${reason}`, + ); + this.name = "NightscoutOriginConfigError"; + } +} + export type NightscoutOriginReason = OriginReason; export type NightscoutOriginVerdict = OriginVerdict; @@ -28,8 +46,8 @@ export type NightscoutOriginVerdict = OriginVerdict; export function parseNightscoutPrivateOrigins( raw: string | undefined, ): ReadonlySet { - return parsePrivateOrigins(raw, () => { - throw new Error("Invalid NIGHTSCOUT_PRIVATE_ORIGINS entry"); + return parsePrivateOrigins(raw, (redactedEntry, reason) => { + throw new NightscoutOriginConfigError(redactedEntry, reason); }); } From 94bed12edc834cfe442534d791274d9639b7ad9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 19:08:04 +0200 Subject: [PATCH 07/11] fix(egress): keep loopback grantable under an exact operator origin A Nightscout self-host on host networking with NIGHTSCOUT_PRIVATE_ORIGINS=http://localhost:1337 worked before and must keep working unchanged; an operator who lists an exact loopback origin with its port has made the same deliberate decision as one who lists a LAN origin. The never-grantable floor is now the unspecified address, link-local and the metadata endpoint only. Loopback (127/8, ::1, localhost) is grantable when listed exactly, still resolved and pinned at connect time with redirects forbidden, and still refused when not listed. ::1 is answered before the embedded-IPv4 unwrap, which read it as the IPv4-compatible spelling of 0.0.0.1 and refused it as unspecified. The pinned Nightscout transport test runs the real floor again. Each refused range keeps its own test; loopback moves to the grantable-when-listed set with a test that an unlisted loopback is still refused, and a Nightscout grant of http://localhost:1337 is asserted to parse. Refs #947 --- .env.example | 2 +- .env.production.example | 2 +- docker-compose.yml | 4 +- docs/integrations/nightscout.md | 8 +-- docs/self-hosting/notifications.md | 22 +++--- messages/de.json | 2 +- messages/en.json | 2 +- messages/es.json | 2 +- messages/fr.json | 2 +- messages/it.json | 2 +- messages/ko.json | 2 +- messages/pl.json | 2 +- scripts/env-manifest.json | 2 +- .../__tests__/private-origin-routes.test.ts | 36 ++++++++-- src/app/api/settings/ntfy/route.ts | 2 +- src/app/api/settings/ntfy/test/route.ts | 2 +- src/app/api/settings/webhook/route.ts | 2 +- src/app/api/settings/webhook/test/route.ts | 2 +- .../__tests__/private-origin-policy.test.ts | 70 ++++++++++++++----- .../__tests__/safe-fetch-dispatcher.test.ts | 45 ++++++------ .../__tests__/client-pinned.test.ts | 18 +---- .../__tests__/egress-policy.test.ts | 32 ++++++--- src/lib/notifications/egress-policy.ts | 4 +- .../senders/__tests__/ntfy.test.ts | 4 +- .../senders/__tests__/webhook.test.ts | 4 +- src/lib/openapi/routes/settings.ts | 8 +-- src/lib/private-origin-policy.ts | 38 +++++----- src/lib/safe-fetch-dispatcher.ts | 11 +-- .../validations/__tests__/nightscout.test.ts | 25 +++++-- src/lib/validations/nightscout.ts | 6 +- src/lib/validations/notifications.ts | 29 ++++---- 31 files changed, 236 insertions(+), 156 deletions(-) diff --git a/.env.example b/.env.example index 1564836ab..82733aeda 100644 --- a/.env.example +++ b/.env.example @@ -98,7 +98,7 @@ API_TOKEN_HMAC_KEY="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32" # Webhook (Gotify, Home Assistant, n8n) and ntfy targets on your own network, # comma-separated exact origins, e.g. "https://gotify.example.com,http://ntfy.lan:8080". # A target resolving to a private address is otherwise refused at send time. -# Loopback, link-local and metadata addresses stay refused even when listed. +# Link-local, metadata and unspecified addresses stay refused even when listed. # See docs/self-hosting/notifications.md. # NOTIFICATION_PRIVATE_ORIGINS="" diff --git a/.env.production.example b/.env.production.example index 190e26dd7..0d5f7a2c5 100644 --- a/.env.production.example +++ b/.env.production.example @@ -79,7 +79,7 @@ API_TOKEN_HMAC_KEY="" # path). A target that resolves to a private address is otherwise refused at # send time; the test button reports it as private_origin_not_approved. A # listed origin is still dialled through the pinned resolver with redirects -# forbidden; loopback, link-local and metadata addresses stay refused even +# forbidden; link-local, metadata and unspecified addresses stay refused even # when listed, and a malformed entry is logged once and grants nothing. # See docs/self-hosting/notifications.md. # NOTIFICATION_PRIVATE_ORIGINS="" diff --git a/docker-compose.yml b/docker-compose.yml index 434cfdd27..e55a024af 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -208,8 +208,8 @@ x-healthlog-env: &healthlog_env # Webhook (Gotify, Home Assistant, n8n) and ntfy targets on your own # network, comma-separated exact origins (e.g. "https://gotify.example.com, # http://ntfy.lan:8080"). Without it a target that resolves to a private - # address is refused at send time as an SSRF target. Loopback, link-local - # and metadata addresses stay refused even when listed. See + # address is refused at send time as an SSRF target. Link-local, metadata + # and unspecified addresses stay refused even when listed. See # docs/self-hosting/notifications.md. NOTIFICATION_PRIVATE_ORIGINS: "${NOTIFICATION_PRIVATE_ORIGINS:-}" # Open-Meteo endpoints for the environment/weather correlation. Default to diff --git a/docs/integrations/nightscout.md b/docs/integrations/nightscout.md index 3a75f8ef4..f90b09f07 100644 --- a/docs/integrations/nightscout.md +++ b/docs/integrations/nightscout.md @@ -28,10 +28,10 @@ Each entry is an exact normalized `scheme://host:port` trust unit: rejected; - approving one hostname does not approve subdomains, sibling hosts, or a different port; and -- loopback (`127.0.0.0/8`, `::1`, `localhost`), the unspecified address, - link-local and the cloud-metadata range cannot be listed, and a listed - name that resolves there is refused at connect time — the grant opens - your network, never the container or the host it runs on; and +- the unspecified address, link-local and the cloud-metadata range cannot + be listed, and a listed name that resolves there is refused at connect + time; loopback (`http://localhost:1337`, `http://127.0.0.1:1337`) stays a + valid grant for a host-networking deployment, exactly as before; and - any malformed non-empty entry fails evaluation instead of silently weakening the configured policy. diff --git a/docs/self-hosting/notifications.md b/docs/self-hosting/notifications.md index 74c00207c..a3dd06de1 100644 --- a/docs/self-hosting/notifications.md +++ b/docs/self-hosting/notifications.md @@ -235,14 +235,14 @@ Each entry is one `scheme://host[:port]`, the same grammar as a range grant would let a DNS rebinding to any address in the range pass the pin, so an operator with several relays lists several origins; - listing a host does not list its sub-hosts or sibling ports; -- loopback (`127.0.0.0/8`, `::1`, `localhost`), the unspecified address, - link-local and the cloud-metadata range (`169.254.0.0/16`, `fe80::/10`) - cannot be granted: a literal entry is logged once and ignored, and a - listed name that resolves there is refused at dial time. On a - host-network deployment, list the LAN address of the relay instead of - `localhost`; -- RFC1918, IPv6 unique-local and CGNAT (a Tailscale address or MagicDNS - name) are what the list is for; +- the unspecified address (`0.0.0.0`, `::`), link-local and the + cloud-metadata range (`169.254.0.0/16`, `fe80::/10`) cannot be granted: a + literal entry is logged once and ignored, and a listed name that resolves + there is refused at dial time; +- RFC1918, IPv6 unique-local, CGNAT (a Tailscale address or MagicDNS name) + and, on a host-network deployment, loopback (`http://127.0.0.1:8080`, + `http://localhost:8080`, `::1`) are what the list is for — an exact + loopback origin with its port is as deliberate a decision as a LAN one; - a malformed entry is logged once at first use and grants nothing; the valid entries beside it keep working. @@ -261,11 +261,13 @@ What is refused where, so you know which lever to pull: | `gotify.lan`, `gotify.home.arpa`, a Docker service name | refused at send | works | | `gotify.local` (mDNS) | refused at save | allowed, if the container resolves mDNS | | Literal `192.168.x.x`, `10.x.x.x`, `100.64.x.x`, `fd00::/8` | refused at save | works | -| `localhost`, `127.0.0.1`, `169.254.169.254`, `::1` | refused at save | still refused (`private_origin_not_grantable`) | +| `localhost`, `127.0.0.1`, `::1` (host networking) | refused at save | works | +| `0.0.0.0`, `169.254.169.254`, `fe80::…` | refused at save | still refused (`private_origin_not_grantable`) | A private target that is not listed is refused when the card is saved (`422`, `meta.errorCode` = `private_origin_not_approved`, the message -names the variable) or, for a DNS name the save cannot classify, when the +names the variable; a link-local, metadata or unspecified target answers +`private_origin_not_grantable` instead, because no grant can open it) or, for a DNS name the save cannot classify, when the test button or a reminder dials it. The resolved address is never shown to the user. Telegram, email and Web Push are not affected: their hosts are not user-supplied. diff --git a/messages/de.json b/messages/de.json index f54c191dc..caa40fff9 100644 --- a/messages/de.json +++ b/messages/de.json @@ -5991,7 +5991,7 @@ "not_configured": "Nicht konfiguriert", "url_not_public": "URL ist kein öffentlicher Endpunkt", "private_origin_not_approved": "Ziel im privaten Netz. Der Betreiber muss diesen Origin in NOTIFICATION_PRIVATE_ORIGINS eintragen", - "private_origin_not_grantable": "Diese Adresse ist Loopback, Link-local oder eine Metadaten-Adresse; keine Betreiber-Freigabe kann sie öffnen. Bitte die LAN-Adresse des Relays verwenden", + "private_origin_not_grantable": "Diese Adresse ist Link-local, eine Metadaten- oder eine unspezifizierte Adresse; keine Betreiber-Freigabe kann sie öffnen. Bitte die Adresse verwenden, auf der das Relay lauscht", "url_invalid": "URL ist ungültig", "redirected": "Endpunkt leitet weiter — URL prüfen", "endpoint_not_found": "Endpunkt unter der URL nicht gefunden", diff --git a/messages/en.json b/messages/en.json index 3ac231118..d58ec0a2c 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5991,7 +5991,7 @@ "not_configured": "Not configured", "url_not_public": "URL is not a public endpoint", "private_origin_not_approved": "Private network target. The operator has to list this origin in NOTIFICATION_PRIVATE_ORIGINS", - "private_origin_not_grantable": "This address is loopback, link-local or a metadata address; no operator grant can open it. Use the relay's LAN address", + "private_origin_not_grantable": "This address is link-local, a metadata or an unspecified address; no operator grant can open it. Use the address the relay listens on", "url_invalid": "URL is invalid", "redirected": "Endpoint redirects — check the URL", "endpoint_not_found": "Endpoint not found at the URL", diff --git a/messages/es.json b/messages/es.json index a3ac9e070..4a8802a5f 100644 --- a/messages/es.json +++ b/messages/es.json @@ -5991,7 +5991,7 @@ "not_configured": "No configurado", "url_not_public": "La URL no es un endpoint público", "private_origin_not_approved": "Destino en una red privada. El operador debe añadir este origen a NOTIFICATION_PRIVATE_ORIGINS", - "private_origin_not_grantable": "Esta dirección es loopback, de enlace local o de metadatos; ninguna autorización del operador puede abrirla. Usa la dirección LAN del relay", + "private_origin_not_grantable": "Esta dirección es de enlace local, de metadatos o no especificada; ninguna autorización del operador puede abrirla. Usa la dirección en la que escucha el relay", "url_invalid": "La URL no es válida", "redirected": "El endpoint redirige — comprueba la URL", "endpoint_not_found": "No se encontró el endpoint en la URL", diff --git a/messages/fr.json b/messages/fr.json index b7ee3631f..b30e60b9a 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -5991,7 +5991,7 @@ "not_configured": "Non configuré", "url_not_public": "L’URL n’est pas un point de terminaison public", "private_origin_not_approved": "Cible sur un réseau privé. L’opérateur doit ajouter cette origine à NOTIFICATION_PRIVATE_ORIGINS", - "private_origin_not_grantable": "Cette adresse est une adresse de bouclage, de lien local ou de métadonnées ; aucune autorisation de l’opérateur ne peut l’ouvrir. Utilisez l’adresse LAN du relais", + "private_origin_not_grantable": "Cette adresse est une adresse de lien local, de métadonnées ou non spécifiée ; aucune autorisation de l’opérateur ne peut l’ouvrir. Utilisez l’adresse sur laquelle le relais écoute", "url_invalid": "L’URL n’est pas valide", "redirected": "Le point de terminaison redirige — vérifiez l’URL", "endpoint_not_found": "Point de terminaison introuvable à l’URL", diff --git a/messages/it.json b/messages/it.json index 9f30da14a..407352537 100644 --- a/messages/it.json +++ b/messages/it.json @@ -5991,7 +5991,7 @@ "not_configured": "Non configurato", "url_not_public": "L’URL non è un endpoint pubblico", "private_origin_not_approved": "Destinazione su una rete privata. L’operatore deve aggiungere questa origine a NOTIFICATION_PRIVATE_ORIGINS", - "private_origin_not_grantable": "Questo indirizzo è di loopback, link-local o dei metadati; nessuna autorizzazione dell’operatore può aprirlo. Usa l’indirizzo LAN del relay", + "private_origin_not_grantable": "Questo indirizzo è link-local, dei metadati o non specificato; nessuna autorizzazione dell’operatore può aprirlo. Usa l’indirizzo su cui il relay è in ascolto", "url_invalid": "L’URL non è valido", "redirected": "L’endpoint reindirizza — controlla l’URL", "endpoint_not_found": "Endpoint non trovato all’URL", diff --git a/messages/ko.json b/messages/ko.json index a67d3418f..2e364231e 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -5991,7 +5991,7 @@ "not_configured": "설정되지 않았어요", "url_not_public": "URL이 공개 엔드포인트가 아니에요", "private_origin_not_approved": "비공개 네트워크 대상이에요. 운영자가 NOTIFICATION_PRIVATE_ORIGINS에 이 출처를 등록해야 해요", - "private_origin_not_grantable": "이 주소는 루프백, 링크 로컬 또는 메타데이터 주소예요. 운영자 등록으로도 열 수 없으니 릴레이의 LAN 주소를 사용해 주세요", + "private_origin_not_grantable": "이 주소는 링크 로컬, 메타데이터 또는 미지정 주소예요. 운영자 등록으로도 열 수 없으니 릴레이가 실제로 수신하는 주소를 사용해 주세요", "url_invalid": "URL이 올바르지 않아요", "redirected": "엔드포인트가 리디렉션돼요 — URL을 확인해 주세요", "endpoint_not_found": "해당 URL에 엔드포인트가 없어요", diff --git a/messages/pl.json b/messages/pl.json index bfeba4653..c09076960 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -5991,7 +5991,7 @@ "not_configured": "Nieskonfigurowano", "url_not_public": "Adres URL nie jest publicznym punktem końcowym", "private_origin_not_approved": "Cel w sieci prywatnej. Operator musi dodać to źródło do NOTIFICATION_PRIVATE_ORIGINS", - "private_origin_not_grantable": "Ten adres to loopback, link-local lub adres metadanych; żadna zgoda operatora nie może go otworzyć. Użyj adresu LAN przekaźnika", + "private_origin_not_grantable": "Ten adres to link-local, adres metadanych lub nieokreślony; żadna zgoda operatora nie może go otworzyć. Użyj adresu, na którym nasłuchuje przekaźnik", "url_invalid": "Adres URL jest nieprawidłowy", "redirected": "Punkt końcowy przekierowuje — sprawdź adres URL", "endpoint_not_found": "Nie znaleziono punktu końcowego pod adresem URL", diff --git a/scripts/env-manifest.json b/scripts/env-manifest.json index 910134538..5c251bfe1 100644 --- a/scripts/env-manifest.json +++ b/scripts/env-manifest.json @@ -233,7 +233,7 @@ "variables": [ { "name": "NOTIFICATION_PRIVATE_ORIGINS", - "purpose": "Comma-separated exact origins (scheme://host[:port], no path, no wildcard, no CIDR), e.g. \"https://gotify.example.com,http://ntfy.lan:8080\". Loopback, link-local and metadata addresses stay refused even when listed; a malformed entry is logged once and grants nothing." + "purpose": "Comma-separated exact origins (scheme://host[:port], no path, no wildcard, no CIDR), e.g. \"https://gotify.example.com,http://ntfy.lan:8080\". Link-local, metadata and unspecified addresses stay refused even when listed; a malformed entry is logged once and grants nothing." } ] }, diff --git a/src/app/api/settings/__tests__/private-origin-routes.test.ts b/src/app/api/settings/__tests__/private-origin-routes.test.ts index fa133af22..4bc8d5b77 100644 --- a/src/app/api/settings/__tests__/private-origin-routes.test.ts +++ b/src/app/api/settings/__tests__/private-origin-routes.test.ts @@ -179,25 +179,47 @@ describe("PUT /api/settings/webhook — private targets", () => { expect(json.meta).toBeUndefined(); }); - it("answers a loopback target with the not-grantable code, listed or not (M1)", async () => { + it("saves a listed loopback target and refuses an unlisted one with the listable code", async () => { + // Host networking: an exact loopback origin with its port is a grant like + // any other. Unlisted, it is refused with the code that says "list it", + // because listing it does open it. + process.env.NOTIFICATION_PRIVATE_ORIGINS = "http://localhost:8080"; + + const refused = await put("webhook", { + url: "http://127.0.0.1:8080/message", + enabled: true, + }); + expect(refused.status).toBe(422); + await expect(refused.json()).resolves.toMatchObject({ + meta: { errorCode: "private_origin_not_approved" }, + }); + + const saved = await put("webhook", { + url: "http://localhost:8080/message", + enabled: true, + }); + expect(saved.status).toBe(200); + expect(prisma.notificationChannel.upsert).toHaveBeenCalledTimes(1); + }); + + it("answers a metadata or unspecified target with the not-grantable code, listed or not (M1)", async () => { // This used to pin a dead end: the route said "list it", the parser // dropped the listing, and the next save said "list it" again. A target // no grant can open gets its own code and a message that says so. vi.spyOn(console, "warn").mockImplementation(() => {}); process.env.NOTIFICATION_PRIVATE_ORIGINS = - "http://127.0.0.1:8080, http://localhost:8080"; + "http://169.254.169.254, http://0.0.0.0:8080"; for (const url of [ - "http://127.0.0.1:8080/message", - "http://localhost:8080/message", "http://169.254.169.254/latest", - "http://[::1]:8080/message", + "http://0.0.0.0:8080/message", + "http://[fe80::1]:8080/message", ]) { const response = await put("webhook", { url, enabled: true }); expect(response.status).toBe(422); const json = await response.json(); expect(json.meta).toEqual({ errorCode: "private_origin_not_grantable" }); - expect(json.error).toMatch(/LAN address/); + expect(json.error).toMatch(/no operator grant can open/); expect(json.error).not.toContain("NOTIFICATION_PRIVATE_ORIGINS"); expect(json.details.issues.length).toBeGreaterThan(0); } @@ -321,7 +343,7 @@ describe("POST /api/settings/{webhook,ntfy}/test — the refusal names itself", expect(response.status).toBe(422); const json = await response.json(); expect(json.meta).toEqual({ errorCode: "private_origin_not_grantable" }); - expect(json.error).toMatch(/LAN address/); + expect(json.error).toMatch(/no operator grant can open/); expect(json.error).not.toContain("NOTIFICATION_PRIVATE_ORIGINS"); }, ); diff --git a/src/app/api/settings/ntfy/route.ts b/src/app/api/settings/ntfy/route.ts index ab084384f..0aba06dda 100644 --- a/src/app/api/settings/ntfy/route.ts +++ b/src/app/api/settings/ntfy/route.ts @@ -32,7 +32,7 @@ const ntfySettingsSchema = ntfySettingsSchemaWith(isAllowedNotificationTarget); const PRIVATE_ORIGIN_REFUSAL = "This server is on a private network. The operator has to list its exact origin (scheme://host:port) in NOTIFICATION_PRIVATE_ORIGINS before HealthLog can send to it."; const NOT_GRANTABLE_REFUSAL = - "This server is loopback, link-local or a metadata address, which no operator grant can open. Use the relay's LAN address instead."; + "This server is a link-local, metadata or unspecified address, which no operator grant can open. Use the address the relay actually listens on."; /** * Refusal for an enable attempt on a channel the operator switched off diff --git a/src/app/api/settings/ntfy/test/route.ts b/src/app/api/settings/ntfy/test/route.ts index ad1371206..b260f1643 100644 --- a/src/app/api/settings/ntfy/test/route.ts +++ b/src/app/api/settings/ntfy/test/route.ts @@ -52,7 +52,7 @@ export const POST = apiHandler(async () => { }); return apiError( result.errorCode === "private_origin_not_grantable" - ? "The server is loopback, link-local or a metadata address, which no operator grant can open. Use the relay's LAN address instead." + ? "The server is a link-local, metadata or unspecified address, which no operator grant can open. Use the address the relay actually listens on." : "The server is on a private network the operator has not approved. List its exact origin (scheme://host:port) in NOTIFICATION_PRIVATE_ORIGINS on the server.", 422, { errorCode: result.errorCode }, diff --git a/src/app/api/settings/webhook/route.ts b/src/app/api/settings/webhook/route.ts index c4f3ded09..1f1b072b3 100644 --- a/src/app/api/settings/webhook/route.ts +++ b/src/app/api/settings/webhook/route.ts @@ -34,7 +34,7 @@ const webhookSettingsSchema = webhookSettingsSchemaWith( const PRIVATE_ORIGIN_REFUSAL = "This address is on a private network. The operator has to list its exact origin (scheme://host:port) in NOTIFICATION_PRIVATE_ORIGINS before HealthLog can send to it."; const NOT_GRANTABLE_REFUSAL = - "This address is loopback, link-local or a metadata address, which no operator grant can open. Use the relay's LAN address instead."; + "This address is a link-local, metadata or unspecified address, which no operator grant can open. Use the address the relay actually listens on."; /** * Generic-webhook channel config (v1.17.1). diff --git a/src/app/api/settings/webhook/test/route.ts b/src/app/api/settings/webhook/test/route.ts index 964c74141..cc6bfa6dc 100644 --- a/src/app/api/settings/webhook/test/route.ts +++ b/src/app/api/settings/webhook/test/route.ts @@ -51,7 +51,7 @@ export const POST = apiHandler(async () => { }); return apiError( result.errorCode === "private_origin_not_grantable" - ? "The target is loopback, link-local or a metadata address, which no operator grant can open. Use the relay's LAN address instead." + ? "The target is a link-local, metadata or unspecified address, which no operator grant can open. Use the address the relay actually listens on." : "The target is on a private network the operator has not approved. List its exact origin (scheme://host:port) in NOTIFICATION_PRIVATE_ORIGINS on the server.", 422, { errorCode: result.errorCode }, diff --git a/src/lib/__tests__/private-origin-policy.test.ts b/src/lib/__tests__/private-origin-policy.test.ts index 3976d5116..10826c41a 100644 --- a/src/lib/__tests__/private-origin-policy.test.ts +++ b/src/lib/__tests__/private-origin-policy.test.ts @@ -51,21 +51,29 @@ describe("canonicalOrigin", () => { }); 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]"], - ["localhost", "http://localhost:8080"], - ["a *.localhost name", "http://gotify.localhost"], ])("refuses %s as a grant", (_label, value) => { expect(canonicalOrigin(value)).toBeNull(); }); + it("keeps loopback grantable: an exact loopback origin is a deliberate host-networking decision", () => { + expect(canonicalOrigin("http://127.0.0.1:8080")).toBe( + "http://127.0.0.1:8080", + ); + expect(canonicalOrigin("http://127.10.20.30")).toBe("http://127.10.20.30"); + expect(canonicalOrigin("http://[::1]:8080")).toBe("http://[::1]:8080"); + expect(canonicalOrigin("http://localhost:1337")).toBe( + "http://localhost:1337", + ); + expect(canonicalOrigin("http://gotify.localhost")).toBe( + "http://gotify.localhost", + ); + }); + it("keeps mDNS and other reserved-looking names grantable when listed exactly", () => { // The input-time floor refuses `.local` and `.internal` unlisted; an // operator naming the exact origin is the override, and a loopback or @@ -120,16 +128,16 @@ describe("parsePrivateOrigins", () => { const onInvalid = vi.fn(); expect( parsePrivateOrigins( - "https://gotify.lan, https://*.lan, http://127.0.0.1:8080, not an origin", + "https://gotify.lan, https://*.lan, http://169.254.169.254, 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", + "http://169.254.169.254", "not an origin", ]); - expect(onInvalid.mock.calls[1][1]).toMatch(/loopback.*cannot be granted/); + expect(onInvalid.mock.calls[1][1]).toMatch(/link-local.*cannot be granted/); expect(onInvalid.mock.calls[0][1]).toMatch(/one exact http\(s\) origin/); }); @@ -204,13 +212,41 @@ describe("evaluateOriginGrant", () => { }); }); + it("approves a listed loopback origin and refuses an unlisted one", () => { + const loopbackGrants = new Set([ + "http://localhost:1337", + "http://[::1]:8080", + ]); + expect( + evaluateOriginGrant("http://localhost:1337", loopbackGrants), + ).toEqual({ + allowed: true, + canonicalOrigin: "http://localhost:1337", + privateOriginApproved: true, + reasonCode: null, + }); + expect( + evaluateOriginGrant("http://[::1]:8080", loopbackGrants), + ).toMatchObject({ allowed: true, privateOriginApproved: true }); + for (const origin of [ + "http://localhost:1338", + "http://127.0.0.1:1337", + "http://[::1]:8081", + ]) { + expect(evaluateOriginGrant(origin, loopbackGrants)).toEqual({ + allowed: false, + canonicalOrigin: origin, + privateOriginApproved: false, + reasonCode: PRIVATE_ORIGIN_NOT_APPROVED_CODE, + }); + } + }); + it.each([ - ["IPv4 loopback", "http://127.0.0.1:8080"], - ["localhost", "http://localhost:8080"], - ["a *.localhost name", "http://gotify.localhost"], ["link-local / metadata", "http://169.254.169.254"], - ["IPv6 loopback", "http://[::1]:8080"], ["unspecified", "http://0.0.0.0:8080"], + ["IPv6 link-local", "http://[fe80::1]:8080"], + ["IPv6 unspecified", "http://[::]:8080"], ])( "answers %s with the not-grantable code, not a request to list it (M1)", (_label, origin) => { @@ -236,10 +272,12 @@ describe("redactGrantEntry / describeGrantRejection", () => { }); it("names the never-grantable class apart from a grammar error", () => { - expect(describeGrantRejection("http://localhost:1337")).toMatch( - /loopback.*LAN address/, + expect(describeGrantRejection("http://169.254.169.254")).toMatch( + /link-local.*cannot be granted/, + ); + expect(describeGrantRejection("http://[fe80::1]:1337")).toMatch( + /link-local/, ); - expect(describeGrantRejection("http://[::1]:1337")).toMatch(/loopback/); expect(describeGrantRejection("https://*.lan")).toMatch(/exact http/); expect(describeGrantRejection("http://10.0.0.4/path")).toMatch( /exact http/, diff --git a/src/lib/__tests__/safe-fetch-dispatcher.test.ts b/src/lib/__tests__/safe-fetch-dispatcher.test.ts index f557905bf..b9076b2f0 100644 --- a/src/lib/__tests__/safe-fetch-dispatcher.test.ts +++ b/src/lib/__tests__/safe-fetch-dispatcher.test.ts @@ -246,6 +246,11 @@ describe("pinnedPublicDispatcher", () => { describe("isOperatorGrantableIp", () => { it("keeps the private ranges an operator may deliberately list", () => { expect(isOperatorGrantableIp("10.0.0.5")).toBe(true); + // Loopback is grantable: a host-networking deployment lists it exactly. + expect(isOperatorGrantableIp("127.0.0.1")).toBe(true); + expect(isOperatorGrantableIp("127.255.0.1")).toBe(true); + expect(isOperatorGrantableIp("::1")).toBe(true); + expect(isOperatorGrantableIp("::ffff:127.0.0.1")).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); @@ -259,17 +264,14 @@ describe("isOperatorGrantableIp", () => { }); 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"], + ["IPv4-mapped unspecified", "::ffff:0.0.0.0"], ["6to4 metadata", "2002:a9fe:a9fe::"], - ["NAT64 loopback", "64:ff9b::7f00:1"], + ["NAT64 metadata", "64:ff9b::a9fe:a9fe"], ["not an address", "gotify.lan"], ["empty", ""], ])("never grants %s, listed or not", (_label, ip) => { @@ -297,10 +299,9 @@ describe("pinnedOperatorApprovedDispatcher", () => { } 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 unspecified", { address: "::", family: 6 }], ["IPv6 link-local", { address: "fe80::1", family: 6 }], ["IPv4-mapped metadata", { address: "::ffff:169.254.169.254", family: 6 }], ])( @@ -348,11 +349,11 @@ describe("pinnedOperatorApprovedDispatcher", () => { 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. + it("drops the metadata answer from a mixed set and dials the loopback one", async () => { + // A real server on loopback proves both halves: the listed name resolves + // to the metadata endpoint AND to loopback; the pinned lookup drops the + // metadata answer and hands undici the loopback survivor, which is + // grantable, so the server receives exactly one request. let requests = 0; const server = http.createServer((_req, res) => { requests += 1; @@ -365,24 +366,18 @@ describe("pinnedOperatorApprovedDispatcher", () => { const port = (server.address() as AddressInfo).port; try { resolveTo([ + { address: "169.254.169.254", family: 4 }, { 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; - } + const res = await undiciFetch( + `http://gotify-listed.example.test:${port}/message`, + { dispatcher, signal: AbortSignal.timeout(5_000) }, + ); - expect(caught).toBeDefined(); - expect(errorCodes(caught)).not.toContain("ENOTFOUND"); - expect(requests).toBe(0); + expect(res.status).toBe(200); + expect(requests).toBe(1); } finally { await new Promise((resolve) => server.close(() => resolve())); } diff --git a/src/lib/nightscout/__tests__/client-pinned.test.ts b/src/lib/nightscout/__tests__/client-pinned.test.ts index d9db5a7f3..51537bf32 100644 --- a/src/lib/nightscout/__tests__/client-pinned.test.ts +++ b/src/lib/nightscout/__tests__/client-pinned.test.ts @@ -5,11 +5,9 @@ * 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. 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`. + * resolved to the loopback server from inside the connector. Loopback is a + * grantable answer under the operator-approved policy (a host-networking + * deployment lists it), so the floor runs for real here. */ import dns from "node:dns"; import http from "node:http"; @@ -24,16 +22,6 @@ 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/notifications/__tests__/egress-policy.test.ts b/src/lib/notifications/__tests__/egress-policy.test.ts index 77e5585a2..85607c116 100644 --- a/src/lib/notifications/__tests__/egress-policy.test.ts +++ b/src/lib/notifications/__tests__/egress-policy.test.ts @@ -60,7 +60,7 @@ describe("configuredNotificationPrivateOrigins", () => { it("logs a malformed entry once, skips it, and keeps the valid ones", () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); process.env.NOTIFICATION_PRIVATE_ORIGINS = - "https://gotify.lan, https://*.lan, http://127.0.0.1:8080"; + "https://gotify.lan, https://*.lan, http://169.254.169.254"; expect(configuredNotificationPrivateOrigins()).toEqual( new Set(["https://gotify.lan"]), @@ -74,8 +74,8 @@ describe("configuredNotificationPrivateOrigins", () => { const lines = warn.mock.calls.map((call) => String(call[0])); expect(lines[0]).toContain("NOTIFICATION_PRIVATE_ORIGINS"); expect(lines[0]).toContain("https://*.lan"); - expect(lines[1]).toContain("http://127.0.0.1:8080"); - expect(lines[1]).toMatch(/loopback.*cannot be granted/); + expect(lines[1]).toContain("http://169.254.169.254"); + expect(lines[1]).toMatch(/link-local.*cannot be granted/); }); it("warns without echoing a query, fragment or userinfo (M3)", () => { @@ -152,20 +152,36 @@ describe("evaluateNotificationTarget", () => { }); }); - it("keeps the raw-string floor: an octal loopback spelling is refused as never grantable", () => { + it("keeps the raw-string floor: an octal loopback spelling is refused, not parsed", () => { expect( evaluateNotificationTarget("http://0177.0.0.1/message"), ).toMatchObject({ allowed: false, - reasonCode: "private_origin_not_grantable", + reasonCode: "private_origin_not_approved", }); }); + it("approves a listed loopback target and refuses an unlisted one", () => { + process.env.NOTIFICATION_PRIVATE_ORIGINS = "http://localhost:8080"; + expect( + evaluateNotificationTarget("http://localhost:8080/message"), + ).toMatchObject({ allowed: true, privateOriginApproved: true }); + for (const url of [ + "http://127.0.0.1:8080/message", + "http://localhost:9090/message", + "http://[::1]:8080/message", + ]) { + expect(evaluateNotificationTarget(url)).toMatchObject({ + allowed: false, + reasonCode: "private_origin_not_approved", + }); + } + }); + it.each([ - ["loopback", "http://127.0.0.1:8080/message"], - ["localhost", "http://localhost:8080/message"], ["metadata", "http://169.254.169.254/latest"], - ["IPv6 loopback", "http://[::1]:8080/message"], + ["unspecified", "http://0.0.0.0:8080/message"], + ["IPv6 link-local", "http://[fe80::1]:8080/message"], ])("answers a %s target with the not-grantable code (M1)", (_l, url) => { process.env.NOTIFICATION_PRIVATE_ORIGINS = "https://gotify.example.com"; expect(evaluateNotificationTarget(url)).toMatchObject({ diff --git a/src/lib/notifications/egress-policy.ts b/src/lib/notifications/egress-policy.ts index 35fd67daa..4c3dfa2a9 100644 --- a/src/lib/notifications/egress-policy.ts +++ b/src/lib/notifications/egress-policy.ts @@ -24,8 +24,8 @@ import { annotate, getEvent } from "@/lib/logging/context"; * NOTIFICATION_PRIVATE_ORIGINS="https://gotify.example.com,http://ntfy.lan:8080" * * A listed origin is still dialled through the pinned resolver with redirects - * forbidden; loopback, link-local and the metadata range stay refused even - * when listed. Nothing not listed widens, and there is no `=true`, CIDR, + * forbidden; link-local, the metadata range and the unspecified address stay + * refused even when listed (loopback is grantable when listed exactly). Nothing not listed widens, and there is no `=true`, CIDR, * wildcard or suffix form. The grant lives in the server environment only — * no request field and no settings toggle can confer it. * diff --git a/src/lib/notifications/senders/__tests__/ntfy.test.ts b/src/lib/notifications/senders/__tests__/ntfy.test.ts index 4e71b47f7..9bb4c17be 100644 --- a/src/lib/notifications/senders/__tests__/ntfy.test.ts +++ b/src/lib/notifications/senders/__tests__/ntfy.test.ts @@ -218,9 +218,9 @@ describe("sendViaNtfy", () => { ); }); - it("reports the not-grantable code for a loopback target, without dialling (M1)", async () => { + it("reports the not-grantable code for a metadata target, without dialling (M1)", async () => { const result = await sendViaNtfy( - { serverUrl: "http://localhost:8080", topic: "health" }, + { serverUrl: "http://169.254.169.254", topic: "health" }, payload(), ); diff --git a/src/lib/notifications/senders/__tests__/webhook.test.ts b/src/lib/notifications/senders/__tests__/webhook.test.ts index e9d8a561e..255f1705e 100644 --- a/src/lib/notifications/senders/__tests__/webhook.test.ts +++ b/src/lib/notifications/senders/__tests__/webhook.test.ts @@ -266,9 +266,9 @@ describe("sendViaWebhook", () => { }); }); - it("reports the not-grantable code for a loopback target, without dialling (M1)", async () => { + it("reports the not-grantable code for a metadata target, without dialling (M1)", async () => { const result = await sendViaWebhook( - { url: "http://127.0.0.1:8080/message" }, + { url: "http://169.254.169.254/latest" }, payload(), ); diff --git a/src/lib/openapi/routes/settings.ts b/src/lib/openapi/routes/settings.ts index 833bc2f4f..7b948794f 100644 --- a/src/lib/openapi/routes/settings.ts +++ b/src/lib/openapi/routes/settings.ts @@ -400,7 +400,7 @@ export const settingsPaths: NonNullable = { ...stdResponses, "422": { description: - "Body matched neither accepted shape, or `enabled` is true while the stored config has no server URL and topic; or the server URL is on a private network the operator has not listed in `NOTIFICATION_PRIVATE_ORIGINS` (`meta.errorCode` = `private_origin_not_approved`), or it is a loopback, link-local or metadata address that no grant can open (`private_origin_not_grantable`). Nothing was stored.", + "Body matched neither accepted shape, or `enabled` is true while the stored config has no server URL and topic; or the server URL is on a private network the operator has not listed in `NOTIFICATION_PRIVATE_ORIGINS` (`meta.errorCode` = `private_origin_not_approved`), or it is a link-local, metadata or unspecified address that no grant can open (`private_origin_not_grantable`). Nothing was stored.", content: { "application/json": { schema: errorEnvelope } }, }, }, @@ -516,7 +516,7 @@ export const settingsPaths: NonNullable = { ...stdResponses, "422": { description: - "Body matched neither accepted shape, or `enabled` is true while no webhook URL is stored; or the URL is on a private network the operator has not listed in `NOTIFICATION_PRIVATE_ORIGINS` (`meta.errorCode` = `private_origin_not_approved`), or it is a loopback, link-local or metadata address that no grant can open (`private_origin_not_grantable`). Nothing was stored.", + "Body matched neither accepted shape, or `enabled` is true while no webhook URL is stored; or the URL is on a private network the operator has not listed in `NOTIFICATION_PRIVATE_ORIGINS` (`meta.errorCode` = `private_origin_not_approved`), or it is a link-local, metadata or unspecified address that no grant can open (`private_origin_not_grantable`). Nothing was stored.", content: { "application/json": { schema: errorEnvelope } }, }, }, @@ -719,7 +719,7 @@ export const settingsPaths: NonNullable = { ...stdResponses, "422": { description: - "The saved server is on a private network the operator has not approved (`meta.errorCode` = `private_origin_not_approved`), or is a loopback, link-local or metadata address that no grant can open (`private_origin_not_grantable`). The resolved address is never echoed.", + "The saved server is on a private network the operator has not approved (`meta.errorCode` = `private_origin_not_approved`), or is a link-local, metadata or unspecified address that no grant can open (`private_origin_not_grantable`). The resolved address is never echoed.", content: { "application/json": { schema: errorEnvelope } }, }, }, @@ -765,7 +765,7 @@ export const settingsPaths: NonNullable = { ...stdResponses, "422": { description: - "The saved URL is on a private network the operator has not approved (`meta.errorCode` = `private_origin_not_approved`), or is a loopback, link-local or metadata address that no grant can open (`private_origin_not_grantable`). The resolved address is never echoed.", + "The saved URL is on a private network the operator has not approved (`meta.errorCode` = `private_origin_not_approved`), or is a link-local, metadata or unspecified address that no grant can open (`private_origin_not_grantable`). The resolved address is never echoed.", content: { "application/json": { schema: errorEnvelope } }, }, }, diff --git a/src/lib/private-origin-policy.ts b/src/lib/private-origin-policy.ts index 1f2c0d8c2..e51f4a2d4 100644 --- a/src/lib/private-origin-policy.ts +++ b/src/lib/private-origin-policy.ts @@ -20,9 +20,9 @@ import { export const PRIVATE_ORIGIN_NOT_APPROVED_CODE = "private_origin_not_approved" as const; /** - * The target is loopback, unspecified, link-local or the metadata range: no - * grant can ever open it, so telling the user to ask the operator would send - * them in a circle. On host networking the LAN address is the answer. + * The target is the unspecified address, link-local or the metadata range: + * no grant can ever open it, so telling the user to ask the operator would + * send them in a circle. */ export const PRIVATE_ORIGIN_NOT_GRANTABLE_CODE = "private_origin_not_grantable" as const; @@ -34,15 +34,14 @@ export type OriginReason = | typeof INVALID_ORIGIN; /** - * A hostname no grant may name: `localhost` / `*.localhost` (loopback by - * definition, RFC 6761) or a literal address outside the grantable ranges. - * DNS names are not judged here — a name that resolves into those ranges is - * dropped at dial time by the pinned dispatcher. + * A hostname no grant may name: a literal address outside the grantable + * ranges (unspecified, link-local, metadata). Loopback and `localhost` are + * grantable when listed exactly — a host-networking deployment lists them — + * and DNS names are not judged here: a name that resolves into a refused + * range is dropped at dial time by the pinned dispatcher. */ export function isNeverGrantableHost(hostname: string): boolean { - const lower = hostname.toLowerCase(); - if (lower === "localhost" || lower.endsWith(".localhost")) return true; - const literal = lower.replace(/^\[|\]$/g, ""); + const literal = hostname.toLowerCase().replace(/^\[|\]$/g, ""); return isIP(literal) !== 0 && !isOperatorGrantableIp(literal); } @@ -68,7 +67,7 @@ export function describeGrantRejection(entry: string): string { (url.protocol === "http:" || url.protocol === "https:") && isNeverGrantableHost(url.hostname) ) { - return "loopback, link-local and metadata addresses cannot be granted; on host networking list the LAN address of the relay instead"; + return "the unspecified address, link-local and metadata addresses cannot be granted; list the address the relay actually listens on"; } } catch { // fall through to the grammar sentence @@ -88,10 +87,11 @@ export interface OriginVerdict { * * 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 — + * suffix, wildcard, capability URL, or prefix. A literal address in 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. + * same floor when a listed DNS name resolves there. Loopback is grantable + * when listed exactly: an operator on host networking lists it on purpose. */ export function canonicalOrigin(value: string): string | null { try { @@ -100,11 +100,11 @@ export function canonicalOrigin(value: string): string | 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; - // Loopback, unspecified, link-local and metadata literals, and - // `localhost` / `*.localhost`, are refused at parse time. Other - // reserved-looking names (`.local` mDNS, `.internal`, `.lan`) stay - // grantable: an explicitly listed origin is what the list is for, and - // the dial-time floor still drops a loopback or metadata answer. + // Unspecified, link-local and metadata literals are refused at parse + // time. Loopback, `localhost` and reserved-looking names (`.local` mDNS, + // `.internal`, `.lan`) stay grantable: an explicitly listed origin is + // what the list is for, and the dial-time floor still drops a metadata + // or link-local answer. if (isNeverGrantableHost(url.hostname)) return null; return url.origin; } catch { diff --git a/src/lib/safe-fetch-dispatcher.ts b/src/lib/safe-fetch-dispatcher.ts index 739ea1348..544278ce0 100644 --- a/src/lib/safe-fetch-dispatcher.ts +++ b/src/lib/safe-fetch-dispatcher.ts @@ -71,9 +71,10 @@ function pinnedLookupWithPolicy( 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. + // name that resolves to link-local, the metadata range or the + // unspecified address is refused here exactly as an unlisted one would + // be. The grant opens the operator's own network (loopback included, on + // host networking), never the cloud host's metadata endpoint. const allowed = addresses.filter((address) => policy === "public" ? isPublicIp(address.address) @@ -176,8 +177,8 @@ export function getPinnedPublicDispatcher(): Agent { * 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`). + * 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/__tests__/nightscout.test.ts b/src/lib/validations/__tests__/nightscout.test.ts index 190eaed04..b6bf2b9cd 100644 --- a/src/lib/validations/__tests__/nightscout.test.ts +++ b/src/lib/validations/__tests__/nightscout.test.ts @@ -121,11 +121,28 @@ describe("Nightscout exact-origin policy", () => { }); }); - it("names the entry and the reason when a loopback grant is listed, without its token (M2)", () => { + it("keeps a loopback Nightscout grant working exactly as before", () => { + // A host-networking self-host lists http://localhost:1337; the shared + // floor must not turn that into a failed sync on upgrade. + expect(parseNightscoutPrivateOrigins("http://localhost:1337")).toEqual( + new Set(["http://localhost:1337"]), + ); + expect( + evaluateNightscoutOrigin( + "http://localhost:1337", + parseNightscoutPrivateOrigins("http://localhost:1337"), + ), + ).toMatchObject({ allowed: true, privateOriginApproved: true }); + expect( + parseNightscoutPrivateOrigins("http://127.0.0.1:1337, http://[::1]:1337"), + ).toEqual(new Set(["http://127.0.0.1:1337", "http://[::1]:1337"])); + }); + + it("names the entry and the reason when a metadata grant is listed, without its token (M2)", () => { let caught: unknown; try { parseNightscoutPrivateOrigins( - "https://cgm.lan, http://localhost:1337/?token=SECRET123", + "https://cgm.lan, http://169.254.169.254:1337/?token=SECRET123", ); } catch (err) { caught = err; @@ -134,9 +151,9 @@ describe("Nightscout exact-origin policy", () => { const message = (caught as Error).message; expect((caught as Error).name).toBe("NightscoutOriginConfigError"); expect(message).toContain( - 'NIGHTSCOUT_PRIVATE_ORIGINS entry "http://localhost:1337/"', + 'NIGHTSCOUT_PRIVATE_ORIGINS entry "http://169.254.169.254:1337/"', ); - expect(message).toMatch(/loopback.*LAN address/); + expect(message).toMatch(/link-local.*cannot be granted/); expect(message).not.toContain("SECRET123"); expect(message).not.toContain("token="); }); diff --git a/src/lib/validations/nightscout.ts b/src/lib/validations/nightscout.ts index 4c21be4a9..cd7cbae26 100644 --- a/src/lib/validations/nightscout.ts +++ b/src/lib/validations/nightscout.ts @@ -17,9 +17,9 @@ export const NIGHTSCOUT_INVALID_ORIGIN_REASON = INVALID_ORIGIN; * Thrown when `NIGHTSCOUT_PRIVATE_ORIGINS` holds an entry that is not a * grant. The message names the entry (scheme and host only) and the reason, * so the sync ledger and the connect form show the operator what to edit - * instead of a bare "invalid entry". Loopback and `localhost` entries - * became invalid with the shared address floor (#947); a host-networking - * deployment lists the LAN address instead. + * instead of a bare "invalid entry". Loopback and `localhost` entries stay + * valid grants exactly as before #947; the shared floor refuses only the + * unspecified address, link-local and the metadata range. */ export class NightscoutOriginConfigError extends Error { constructor(redactedEntry: string, reason: string) { diff --git a/src/lib/validations/notifications.ts b/src/lib/validations/notifications.ts index 98d960e01..15cf9b873 100644 --- a/src/lib/validations/notifications.ts +++ b/src/lib/validations/notifications.ts @@ -234,14 +234,14 @@ export function isPublicIp(ip: string): boolean { * `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. + * RFC1918, CGNAT (a Tailscale address), IPv6 unique-local and loopback stay + * grantable: an operator on host networking who lists an exact loopback + * origin with its port has made the same deliberate decision as one who + * lists a LAN origin. Two classes are refused even when the operator lists a + * name that resolves there, because no relay lives on them: the unspecified + * address is a resolver misfire, and link-local is the cloud-metadata + * endpoint. The transition formats (`::ffff:`, 6to4, NAT64) are unwrapped + * first so an IPv6 spelling of those does not slip past the IPv4 verdict. */ export function isOperatorGrantableIp(ip: string): boolean { if (!ip) return false; @@ -258,12 +258,14 @@ export function isOperatorGrantableIp(ip: string): boolean { 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. + // Unspecified and link-local IPv6. Unique-local (fc00::/7) and loopback + // (::1) deliberately stay out of this list: they are what an operator + // on a private or host network lists. `::1` is answered before the + // embedded-IPv4 unwrap, which would otherwise read it as the + // IPv4-compatible spelling of 0.0.0.1 and refuse it as unspecified. if (bytesEqual(bytes, 0, Array(16).fill(0))) return false; if (bytesEqual(bytes, 0, Array(15).fill(0)) && bytes[15] === 1) { - return false; + return true; } if (bytes[0] === 0xfe && (bytes[1] & 0xc0) === 0x80) return false; @@ -275,10 +277,9 @@ export function isOperatorGrantableIp(ip: string): boolean { return false; } -/** 127/8, 0/8 and 169.254/16: the ranges no operator grant may open. */ +/** 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; From 2380a4e7e6ad057f1387e8000220791fd56abf14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 19:18:16 +0200 Subject: [PATCH 08/11] docs(api): regenerate the contract for the loopback wording The four notification routes describe private_origin_not_grantable as link-local, metadata or unspecified only; the generated YAML follows. Refs #947 --- docs/api/openapi.yaml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 5bac35ca7..4c3f911e6 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -12022,8 +12022,8 @@ paths: "422": description: Body matched neither accepted shape, or `enabled` is true while the stored config has no server URL and topic; or the server URL is on a private network the operator has not listed in - `NOTIFICATION_PRIVATE_ORIGINS` (`meta.errorCode` = `private_origin_not_approved`), or it is a loopback, - link-local or metadata address that no grant can open (`private_origin_not_grantable`). Nothing was stored. + `NOTIFICATION_PRIVATE_ORIGINS` (`meta.errorCode` = `private_origin_not_approved`), or it is a link-local, + metadata or unspecified address that no grant can open (`private_origin_not_grantable`). Nothing was stored. content: application/json: schema: @@ -12150,8 +12150,8 @@ paths: "422": description: Body matched neither accepted shape, or `enabled` is true while no webhook URL is stored; or the URL is on a private network the operator has not listed in `NOTIFICATION_PRIVATE_ORIGINS` (`meta.errorCode` = - `private_origin_not_approved`), or it is a loopback, link-local or metadata address that no grant can open - (`private_origin_not_grantable`). Nothing was stored. + `private_origin_not_approved`), or it is a link-local, metadata or unspecified address that no grant can + open (`private_origin_not_grantable`). Nothing was stored. content: application/json: schema: @@ -12388,7 +12388,7 @@ paths: "401": *a1 "422": description: The saved server is on a private network the operator has not approved (`meta.errorCode` = - `private_origin_not_approved`), or is a loopback, link-local or metadata address that no grant can open + `private_origin_not_approved`), or is a link-local, metadata or unspecified address that no grant can open (`private_origin_not_grantable`). The resolved address is never echoed. content: application/json: @@ -12439,7 +12439,7 @@ paths: "401": *a1 "422": description: The saved URL is on a private network the operator has not approved (`meta.errorCode` = - `private_origin_not_approved`), or is a loopback, link-local or metadata address that no grant can open + `private_origin_not_approved`), or is a link-local, metadata or unspecified address that no grant can open (`private_origin_not_grantable`). The resolved address is never echoed. content: application/json: From 80171436e3309511bccee3504ffbc9f6c09f86dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 19:27:49 +0200 Subject: [PATCH 09/11] =?UTF-8?q?chore(release):=20v1.38.17=20=E2=80=94=20?= =?UTF-8?q?a=20private=20notification=20target=20can=20be=20listed=20by=20?= =?UTF-8?q?the=20operator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump the version, the service-worker fallback and the OpenAPI document, and add the CHANGELOG entry for v1.38.17. --- CHANGELOG.md | 38 ++++++++++++++++++++++++++++++++++++++ docs/api/openapi.yaml | 2 +- package.json | 2 +- public/sw.js | 2 +- 4 files changed, 41 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 90c38799e..7baaaedf9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,43 @@ # Changelog +## [1.38.17] — 2026-09-10 + +A webhook or ntfy target on your own network can be reached again, with the +operator's say-so, and the test button says why a private target was refused. + +### Fixed + +- **A webhook or ntfy target on your own network can be reached again, with + the operator's say-so (#947).** Both senders resolve the target at send + time and refuse a private, loopback, link-local or CGNAT answer; that is + the DNS-rebinding defence from #217, and it stopped a Gotify behind a LAN + reverse proxy exactly as designed, with no way to allow it. + `NOTIFICATION_PRIVATE_ORIGINS` is that way: comma-separated exact origins + such as `https://gotify.example.com,http://ntfy.lan:8080`, read from the + server environment only. A listed origin is still resolved and pinned + inside the connector with redirects forbidden; link-local, the metadata + range and the unspecified address stay refused even when listed, while + loopback is listable for a relay on host networking; a malformed entry is + logged once, without its query or credentials, and grants nothing; nothing + not listed widens. Listing a name also refuses its sub-hosts and other + ports, public or not, because a private name cannot be told from a public + one at save time. The same grammar as `NIGHTSCOUT_PRIVATE_ORIGINS`, which + now shares the parser and the address floor; existing + `NIGHTSCOUT_PRIVATE_ORIGINS` grants, loopback ones on host networking + included, behave exactly as before. Reported by @sreeramachandramurthy. + +- **The test button says why a private target was refused.** Saving a + private address that is not listed is a 422 with + `meta.errorCode = private_origin_not_approved` and a message naming the + variable, and the test button answers the same code instead of a bare 500 + that sent the operator to the log. The save, the test and the scheduled + delivery take one decision, held by a structural test. A granted send + marks the wide event as `notification.egress.private_origin` with the + channel and origin. A link-local, metadata or unspecified target answers + `private_origin_not_grantable` instead, with a message saying no grant can + open it, since telling the user to ask the operator would only send them + in a circle. + ## [1.38.16] — 2026-09-10 The API says what it refused and why, on every route, and the admin page diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 4c3f911e6..859b9d511 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.38.16 + version: 1.38.17 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/package.json b/package.json index 2177036ba..dd10a3656 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.38.16", + "version": "1.38.17", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index 58f9dd341..bce54c3fc 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.38.16"; + /* @sw-version-fallback */ "v1.38.17"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` From 95140e5aa76cd48627ac3cca85e2b35c91246041 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 20:17:38 +0200 Subject: [PATCH 10/11] fix(medications): resolve one occurrence per intake row, the nearest A taken, skipped or auto-missed row counted as resolving every occurrence within six hours of its anchor. The radius was sized for the twice-daily 12 h gap, so on any schedule whose neighbouring doses sit closer than that (19:49 and 23:59, 08:00 and 12:00, every four hours) the first dose's row resolved the second occurrence too: the next-due walk jumped to tomorrow, the card offered tomorrow's first dose, and a skip pressed next landed there. The second dose could not be logged from the card at all. Reproduced with a Europe/Berlin account on 19:49 + 23:59: the 19:49 take left the 23:59 row pending and the skip was written to tomorrow's 19:49. The compliance-journey e2e spec exposed the same thing in every run that started after noon, when its twin slot is 23:59. Rule, in slot-resolution.ts and shared by the list's next-due, the card's overdue search and the compliance current-cycle descriptor: a row resolves exactly one occurrence, the one nearest its anchor, and only within the drift radius. An exact match short-circuits; a drifted anchor is measured against every occurrence the schedule emits within the radius and loses to any nearer one (ties go to the earlier occurrence), so the effective reach is min(radius, half the gap to the neighbour) and a sibling can never be swallowed. Rolling cadences skip the nearest test, their occurrences are at least a day apart. Ad-hoc rows keep the one-minute epsilon, now in the cycle descriptor as well, so the card's due slot and its cycle state agree. The drift tolerance itself stays: it covers a row whose anchor sits off the canonical instant, a pre-snap row or an instant that moved under it across a DST shift, which is what the radius was introduced for in v1.15.10. --- CHANGELOG.md | 13 ++ .../compliance/__tests__/compliance.test.ts | 28 +++ src/lib/analytics/compliance/cycle.ts | 51 +++-- .../scheduling/__tests__/next-due.test.ts | 194 ++++++++++++++++++ src/lib/medications/scheduling/next-due.ts | 68 +++--- .../medications/scheduling/slot-resolution.ts | 82 ++++++++ 6 files changed, 384 insertions(+), 52 deletions(-) create mode 100644 src/lib/medications/scheduling/slot-resolution.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7baaaedf9..e0a632d9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,19 @@ operator's say-so, and the test button says why a private target was refused. open it, since telling the user to ask the operator would only send them in a circle. +- **The second of two close doses can be logged from the card again.** On a + schedule whose neighbouring doses sit closer than twelve hours, 19:49 and + 23:59 say, or 08:00 and 12:00, taking the first dose made the card offer + tomorrow's first dose, and a skip pressed next landed on tomorrow. A + taken, skipped or auto-missed row counted as resolving every occurrence + within six hours of its anchor, a tolerance sized for the twice-daily + gap, so the first row swallowed its sibling and the next-due walk went + past it. A row now resolves exactly one occurrence, the one nearest its + anchor, within that tolerance; the compliance cycle on the card follows + the same rule, so the due slot and the cycle state cannot disagree. The + tolerance still covers a row whose anchor drifted from the canonical + instant, a pre-snap row or a DST shift, which is what it was for. + ## [1.38.16] — 2026-09-10 The API says what it refused and why, on every route, and the admin page diff --git a/src/lib/analytics/compliance/__tests__/compliance.test.ts b/src/lib/analytics/compliance/__tests__/compliance.test.ts index 3dfd77106..10d44d818 100644 --- a/src/lib/analytics/compliance/__tests__/compliance.test.ts +++ b/src/lib/analytics/compliance/__tests__/compliance.test.ts @@ -1673,6 +1673,34 @@ describe("buildComplianceDisplay — two rows, cadence-scaled windows", () => { expect(display.currentCycle.hasClosedCycles).toBe(true); }); + it("currentCycle — taking the first of two close doses leaves the second due", () => { + // 19:49 + 23:59 Berlin (CEST): 17:49Z + 21:59Z. The 19:49 take used to + // resolve the 23:59 slot too (flat ±6 h radius), so the open cycle + // pointed at tomorrow's 19:49 while tonight's dose was still due. + const schedules: ComplianceSchedule[] = [ + { + windowStart: "19:49", + windowEnd: "23:59", + daysOfWeek: null, + rollingIntervalDays: null, + timesOfDay: ["19:49", "23:59"], + }, + ]; + const slot = new Date("2025-06-15T17:49:00Z"); + const takenAt = new Date("2025-06-15T17:49:12Z"); + const events = [{ scheduledFor: slot, takenAt, skipped: false }]; + const display = buildComplianceDisplay( + events, + schedules, + ctx({ lastIntakeAt: takenAt, timeZone: "Europe/Berlin" }), + { now: new Date("2025-06-15T18:00:00Z") }, + ); + expect(display.currentCycle.state).toBe("on_track"); + expect(display.currentCycle.nextDueAt?.toISOString()).toBe( + "2025-06-15T21:59:00.000Z", + ); + }); + it("currentCycle — rolling weekly overdue past grace → missed", () => { // v1.13.x Fix 4 — last shot ~10 days ago (cadence 7) → next due was 3 // days ago, well past grace → the open cycle is `missed` (the only red diff --git a/src/lib/analytics/compliance/cycle.ts b/src/lib/analytics/compliance/cycle.ts index f80a9f2c2..8befa5271 100644 --- a/src/lib/analytics/compliance/cycle.ts +++ b/src/lib/analytics/compliance/cycle.ts @@ -3,8 +3,15 @@ import { nextOccurrenceAfter, + type CanonicalSchedule, type Occurrence, + type RecurrenceContext, } from "@/lib/medications/scheduling/recurrence"; +import { + ADHOC_RESOLVE_EPSILON_MS, + anchorResolvesOccurrence, + RESOLVE_RADIUS_MS, +} from "@/lib/medications/scheduling/slot-resolution"; import { lastNonSkippedTakenAt, type ComplianceIntakeInstant, @@ -64,40 +71,42 @@ export interface CurrentCycle { * selection: a window holding zero expected (closed) doses means the * percentage rows are vacuous and the card should show a neutral state. */ -/** - * v1.15.10 — half-window an intake may sit from a slot's canonical instant - * and still count as "resolving" that slot. The intake write paths snap - * `scheduledFor` to the exact engine slot instant, so an exact (or - * sub-minute) match is the common case; the ±6h tolerance also catches an - * off-time take on a dense intraday cadence (e.g. a 07:00 dose logged 09:13) - * whose snapped slot is the 07:00 row but whose raw instant we compare - * defensively. It is the same ±half-gap radius the cadence pairer uses for a - * 12h-gap (twice-daily) med, floored so a single-dose-a-day cadence still - * matches its one slot. - */ -const OPEN_DOSE_RESOLVE_RADIUS_MS = 6 * 60 * 60 * 1000; - /** * v1.15.10 — true when an intake event has already resolved the slot at * `slotAt`. A resolved slot is one the user took, deliberately skipped, or * the auto-miss cron flagged — any of which means the card must NOT keep - * surfacing that slot as the next dose. Matches on the snapped `scheduledFor` - * first (the canonical, exact path) and falls back to the take/skip instant - * within the resolve radius for rows that predate the snap or drifted. + * surfacing that slot as the next dose. Anchors on the snapped + * `scheduledFor` (the canonical, exact path), falling back to the take + * instant for a legacy row without one. A drifted anchor resolves its + * nearest occurrence only, within the shared drift radius, so a sibling + * slot closer than that radius is never swallowed; an ad-hoc row + * (`scheduledFor === takenAt`) resolves only the slot it sits on. Same rule + * as the list's next-due (`slot-resolution.ts`), so the cycle descriptor + * and the card's due slot cannot disagree. */ function slotIsResolved( slotAt: Date, intakes: ComplianceIntakeInstant[], + schedule: CanonicalSchedule, + ctx: RecurrenceContext, ): boolean { - const slot = slotAt.getTime(); for (const e of intakes) { const isResolved = e.skipped || e.autoMissed === true || e.takenAt !== null; if (!isResolved) continue; const ref = (e.scheduledFor ?? e.takenAt) as Date | undefined; if (!ref) continue; - if (Math.abs(ref.getTime() - slot) <= OPEN_DOSE_RESOLVE_RADIUS_MS) { - return true; - } + const adHoc = + e.scheduledFor !== undefined && + e.takenAt !== null && + e.scheduledFor.getTime() === e.takenAt.getTime(); + const resolves = anchorResolvesOccurrence({ + anchor: ref, + occurrenceAt: slotAt, + radiusMs: adHoc ? ADHOC_RESOLVE_EPSILON_MS : RESOLVE_RADIUS_MS, + schedule, + ctx, + }); + if (resolves) return true; } return false; } @@ -162,7 +171,7 @@ export function buildCurrentCycle( for (let step = 0; step < 64; step++) { const occ = nextOccurrenceAfter(canonical, after, recurrenceCtx); if (!occ) break; - if (!slotIsResolved(occ.at, allIntakes)) { + if (!slotIsResolved(occ.at, allIntakes, canonical, recurrenceCtx)) { picked = occ; break; } diff --git a/src/lib/medications/scheduling/__tests__/next-due.test.ts b/src/lib/medications/scheduling/__tests__/next-due.test.ts index 9ef1241fc..29437694b 100644 --- a/src/lib/medications/scheduling/__tests__/next-due.test.ts +++ b/src/lib/medications/scheduling/__tests__/next-due.test.ts @@ -697,3 +697,197 @@ describe("Mounjaro rolling weekly — canonical overdue band after day change", }); }); }); + +/** + * A resolved row stands for exactly ONE occurrence: the one nearest to its + * anchor, and only within the drift radius. The former flat ±6 h radius + * reached the sibling slot on any cadence closer than twelve hours, so the + * first dose's row resolved the second occurrence too, the walk jumped to + * tomorrow, and the second dose could never be logged from the card. + * + * Berlin is UTC+2 in June / September: 19:49 + 23:59 local are 17:49Z + + * 21:59Z; 08:00 + 12:00 local are 06:00Z + 10:00Z. + */ +describe("resolved-slot marks — a row resolves only its nearest occurrence", () => { + const medication = makeMedication(); + function closeSlots(timesOfDay: string[]): WorkerScheduleRow { + return { + id: "sched-close", + windowStart: timesOfDay[0], + windowEnd: timesOfDay[timesOfDay.length - 1], + daysOfWeek: null, + timesOfDay, + reminderGraceMinutes: null, + rrule: null, + rollingIntervalDays: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + }; + } + + it("19:49 taken leaves 23:59 the same day due, not tomorrow's 19:49", () => { + const now = d("2026-09-10T18:00:00Z"); // 20:00 Berlin + const next = computeNextDueAt({ + medication, + schedules: [closeSlots(["19:49", "23:59"])], + now, + userTz: BERLIN, + lastIntakeAt: d("2026-09-10T17:49:12Z"), + resolvedSlots: [mark(d("2026-09-10T17:49:00Z"))], + }); + expect(next).not.toBeNull(); + expect(next!.toISOString()).toBe("2026-09-10T21:59:00.000Z"); + }); + + it("the card offers the 23:59 slot after the 19:49 take (display due)", () => { + const now = d("2026-09-10T18:00:00Z"); + const due = computeDisplayDue({ + medication, + schedules: [closeSlots(["19:49", "23:59"])], + now, + userTz: BERLIN, + lastIntakeAt: d("2026-09-10T17:49:12Z"), + resolvedSlots: [mark(d("2026-09-10T17:49:00Z"))], + }); + expect(due).not.toBeNull(); + expect(due!.overdue).toBe(false); + expect(due!.at.toISOString()).toBe("2026-09-10T21:59:00.000Z"); + }); + + it("08:00 taken, 12:00 pending → next due is 12:00", () => { + const now = d("2026-06-10T07:00:00Z"); // 09:00 Berlin + const next = computeNextDueAt({ + medication, + schedules: [closeSlots(["08:00", "12:00"])], + now, + userTz: BERLIN, + lastIntakeAt: d("2026-06-10T06:03:00Z"), + resolvedSlots: [mark(d("2026-06-10T06:00:00Z"))], + }); + expect(next).not.toBeNull(); + expect(next!.toISOString()).toBe("2026-06-10T10:00:00.000Z"); + }); + + it("every four hours: the first take leaves the second slot due", () => { + const now = d("2026-06-10T05:00:00Z"); // 07:00 Berlin + const next = computeNextDueAt({ + medication, + schedules: [ + closeSlots(["02:00", "06:00", "10:00", "14:00", "18:00", "22:00"]), + ], + now, + userTz: BERLIN, + lastIntakeAt: d("2026-06-10T04:02:00Z"), + resolvedSlots: [ + mark(d("2026-06-10T00:00:00Z")), // 02:00 Berlin + mark(d("2026-06-10T04:00:00Z")), // 06:00 Berlin + ], + }); + expect(next).not.toBeNull(); + expect(next!.toISOString()).toBe("2026-06-10T08:00:00.000Z"); // 10:00 Berlin + }); + + it("a drifted slot-anchored row still resolves its nearest occurrence", () => { + // The case the radius always protected: a row whose anchor sits off + // the canonical instant (a pre-snap row, or an instant that moved + // under it) resolves the occurrence it is nearest to. + const now = d("2026-06-10T08:00:00Z"); // 10:00 Berlin + const next = computeNextDueAt({ + medication, + schedules: [closeSlots(["08:00", "20:00"])], + now, + userTz: BERLIN, + lastIntakeAt: d("2026-06-10T07:13:00Z"), + // Anchored 09:13 Berlin, 1 h 13 min off the 08:00 slot. + resolvedSlots: [mark(d("2026-06-10T07:13:00Z"))], + }); + expect(next).not.toBeNull(); + expect(next!.toISOString()).toBe("2026-06-10T18:00:00.000Z"); // 20:00 Berlin + }); + + it("a drifted row an hour off across a DST shift still resolves the slot", () => { + // 2026-03-29 Berlin springs forward at 02:00; a 08:00 row computed in + // the old offset sits at 07:00Z where the engine now says 06:00Z. + const now = d("2026-03-29T07:30:00Z"); // 09:30 Berlin (CEST) + const due = computeDisplayDue({ + medication, + schedules: [closeSlots(["08:00", "20:00"])], + now, + userTz: BERLIN, + lastIntakeAt: d("2026-03-29T07:00:00Z"), + resolvedSlots: [mark(d("2026-03-29T07:00:00Z"))], + }); + expect(due).not.toBeNull(); + expect(due!.overdue).toBe(false); + expect(due!.at.toISOString()).toBe("2026-03-29T18:00:00.000Z"); // 20:00 CEST + }); + + it("a drifted row nearer the sibling resolves the sibling, not both", () => { + // 08:00 + 12:00 Berlin. A skip anchored at 10:30 is nearer 12:00 than + // 08:00, so 08:00 stays open overdue at 09:30 (its band runs to 12:00). + const now = d("2026-06-10T07:30:00Z"); // 09:30 Berlin + const due = computeDisplayDue({ + medication, + schedules: [closeSlots(["08:00", "12:00"])], + now, + userTz: BERLIN, + lastIntakeAt: null, + resolvedSlots: [mark(d("2026-06-10T08:30:00Z"))], // 10:30 Berlin + }); + expect(due).not.toBeNull(); + expect(due!.overdue).toBe(true); + expect(due!.at.toISOString()).toBe("2026-06-10T06:00:00.000Z"); // 08:00 Berlin + }); + + it("an ad-hoc row between two close slots resolves neither", () => { + const adhoc = d("2026-06-10T08:30:00Z"); // 10:30 Berlin + const now = d("2026-06-10T09:00:00Z"); // 11:00 Berlin + const next = computeNextDueAt({ + medication, + schedules: [closeSlots(["08:00", "12:00"])], + now, + userTz: BERLIN, + lastIntakeAt: adhoc, + resolvedSlots: [ + mark(d("2026-06-10T06:00:00Z")), + toResolvedSlotMark({ scheduledFor: adhoc, takenAt: adhoc }), + ], + }); + expect(next).not.toBeNull(); + expect(next!.toISOString()).toBe("2026-06-10T10:00:00.000Z"); // 12:00 Berlin + }); + + it("an ad-hoc row seconds off a close slot still resolves that slot only", () => { + const adhoc = d("2026-06-10T09:59:30Z"); // 11:59:30 Berlin + const now = d("2026-06-10T10:30:00Z"); // 12:30 Berlin + const next = computeNextDueAt({ + medication, + schedules: [closeSlots(["08:00", "12:00"])], + now, + userTz: BERLIN, + lastIntakeAt: adhoc, + resolvedSlots: [ + mark(d("2026-06-10T06:00:00Z")), + toResolvedSlotMark({ scheduledFor: adhoc, takenAt: adhoc }), + ], + }); + expect(next).not.toBeNull(); + expect(next!.toISOString()).toBe("2026-06-11T06:00:00.000Z"); // tomorrow 08:00 + }); + + it("the overdue search agrees: 23:59 stays open overdue after midnight", () => { + const now = d("2026-09-10T22:30:00Z"); // 00:30 Berlin, next day + const due = computeDisplayDue({ + medication, + schedules: [closeSlots(["19:49", "23:59"])], + now, + userTz: BERLIN, + lastIntakeAt: d("2026-09-10T17:49:12Z"), + resolvedSlots: [mark(d("2026-09-10T17:49:00Z"))], + }); + expect(due).not.toBeNull(); + expect(due!.overdue).toBe(true); + expect(due!.at.toISOString()).toBe("2026-09-10T21:59:00.000Z"); + }); +}); diff --git a/src/lib/medications/scheduling/next-due.ts b/src/lib/medications/scheduling/next-due.ts index 562d60e11..80052a120 100644 --- a/src/lib/medications/scheduling/next-due.ts +++ b/src/lib/medications/scheduling/next-due.ts @@ -19,27 +19,16 @@ import { import { advanceRollingOccurrence, nextOccurrenceAfter, + type CanonicalSchedule, + type RecurrenceContext, } from "@/lib/medications/scheduling/recurrence"; import { buildBandsForMedication } from "@/lib/medications/scheduling/band-minter"; import { DOSE_WINDOW_DEFAULTS } from "@/lib/medications/scheduling/dose-window-defaults"; - -/** - * v1.15.10 — radius an intake may sit from a slot's canonical instant and - * still count as resolving it. Intake writes snap `scheduledFor` to the exact - * engine slot instant, so the match is normally exact; the ±6h tolerance is - * the same defensive half-gap the compliance open-dose search uses (a 12h-gap - * twice-daily med splits cleanly at ±6h). - */ -const RESOLVE_RADIUS_MS = 6 * 60 * 60 * 1000; - -/** - * v1.16.9 — exact-match slop for an AD-HOC row. An ad-hoc take anchors - * `scheduledFor = takenAt` on its own instant, so it can only resolve a - * slot it actually sits on (sub-minute drift absorbed); letting it use - * the ±6h radius hid genuinely-due slots — a 14:30 ad-hoc take resolved - * tonight's 20:00 dose while the ledger still counted that slot missed. - */ -const ADHOC_RESOLVE_EPSILON_MS = 60 * 1000; +import { + ADHOC_RESOLVE_EPSILON_MS, + anchorResolvesOccurrence, + RESOLVE_RADIUS_MS, +} from "@/lib/medications/scheduling/slot-resolution"; /** * A row that resolves a slot (taken / deliberately skipped / @@ -47,9 +36,10 @@ const ADHOC_RESOLVE_EPSILON_MS = 60 * 1000; * * `slotAnchored: false` marks the ad-hoc shape (`scheduledFor === * takenAt`): such a row resolves a slot only on a near-exact anchor - * match, never across the ±6h radius. Slot-anchored rows (the write + * match, never across the drift radius. Slot-anchored rows (the write * paths snap their `scheduledFor` to the canonical slot instant) keep - * the defensive radius. + * the drift radius, and within it resolve their nearest occurrence only + * (`anchorResolvesOccurrence`). */ export interface ResolvedSlotMark { at: Date; @@ -80,27 +70,40 @@ export function toResolvedSlotMark(row: { }; } +/** + * Predicate over the canonical occurrences of ONE schedule: true when a + * resolved row stands for the occurrence at `slotAt`. Each row resolves + * its nearest occurrence only, so a sibling slot closer than the drift + * radius is never swallowed (see `slot-resolution.ts` for the rule). + */ function buildIsResolved( resolved: ResolvedSlotMark[], - identity?: { scheduleId: string; eraStart?: Date | null }, + identity: { + schedule: CanonicalSchedule; + ctx: RecurrenceContext; + eraStart?: Date | null; + }, ): (slotAt: Date) => boolean { return (slotAt: Date): boolean => { - const slot = slotAt.getTime(); for (const r of resolved) { - if (r.scheduleId !== undefined && r.scheduleId !== identity?.scheduleId) { + if (r.scheduleId !== undefined && r.scheduleId !== identity.schedule.id) { continue; } if ( - identity?.eraStart && + identity.eraStart && r.actionAt && r.actionAt.getTime() < identity.eraStart.getTime() ) { continue; } - const radius = r.slotAnchored - ? RESOLVE_RADIUS_MS - : ADHOC_RESOLVE_EPSILON_MS; - if (Math.abs(r.at.getTime() - slot) <= radius) return true; + const resolves = anchorResolvesOccurrence({ + anchor: r.at, + occurrenceAt: slotAt, + radiusMs: r.slotAnchored ? RESOLVE_RADIUS_MS : ADHOC_RESOLVE_EPSILON_MS, + schedule: identity.schedule, + ctx: identity.ctx, + }); + if (resolves) return true; } return false; }; @@ -165,7 +168,8 @@ function computeNextDueCandidate(input: { for (const schedule of schedules) { const canonical = buildCanonicalSchedule(schedule); const isResolved = buildIsResolved(input.resolvedSlots ?? [], { - scheduleId: canonical.id, + schedule: canonical, + ctx, eraStart: input.eraStart, }); // Walk forward past slots the user has already resolved. Bounded so a @@ -278,13 +282,15 @@ function findOpenOverdueSlot( scheduleId: string; } | null = null; for (const schedule of schedules) { + const canonical = buildCanonicalSchedule(schedule); const isResolved = buildIsResolved(input.resolvedSlots ?? [], { - scheduleId: schedule.id, + schedule: canonical, + ctx, eraStart: input.eraStart, }); const { bands } = buildBandsForMedication({ medication, - schedule: buildCanonicalSchedule(schedule), + schedule: canonical, ctx, userTz, range: { from: floor, to: now }, diff --git a/src/lib/medications/scheduling/slot-resolution.ts b/src/lib/medications/scheduling/slot-resolution.ts new file mode 100644 index 000000000..2b73adb6e --- /dev/null +++ b/src/lib/medications/scheduling/slot-resolution.ts @@ -0,0 +1,82 @@ +/** + * Which occurrence a resolved intake row (taken / deliberately skipped / + * cron-auto-missed) stands for. + * + * The intake write paths snap a slot-anchored row's `scheduledFor` to the + * engine's canonical occurrence instant, so row and occurrence normally meet + * exactly. The tolerance beyond an exact match exists for a row whose anchor + * drifted off the canonical instant: a row written before the snap, or an + * instant that moved under it through a DST or schedule-time shift. From + * v1.15.10 that tolerance was a flat ±6 h radius, sized for the twice-daily + * 12 h gap. On any cadence whose neighbouring slots are closer than twelve + * hours (19:49 + 23:59, 08:00 + 12:00, every four hours) the radius reached + * the sibling: the first dose's row resolved the second occurrence too, the + * next-due walk jumped to tomorrow, the card offered tomorrow's slot, and the + * second dose could never be logged from the card. + * + * Rule: a row resolves exactly ONE occurrence, the one nearest to its anchor, + * and only when that occurrence lies within the row's radius. An exact match + * short-circuits; a drifted row is measured against every occurrence the + * schedule emits within the radius around the anchor, and resolves the slot + * under test only when no other occurrence is nearer (an exact tie goes to + * the earlier occurrence). So the effective reach is `min(radius, half the + * gap to the neighbouring occurrence)`, and a sibling can never be swallowed. + * Rolling cadences emit at most one occurrence per interval day, which is + * farther than any radius here, so the nearest test is skipped for them. + */ +import { + occurrencesBetween, + type CanonicalSchedule, + type RecurrenceContext, +} from "@/lib/medications/scheduling/recurrence"; + +/** + * Drift a slot-anchored row may sit from a slot's canonical instant and + * still count as resolving it, provided the slot is the row's nearest + * occurrence. + */ +export const RESOLVE_RADIUS_MS = 6 * 60 * 60 * 1000; + +/** + * v1.16.9 — exact-match slop for an AD-HOC row. An ad-hoc take anchors + * `scheduledFor = takenAt` on its own instant, so it can only resolve a + * slot it actually sits on (sub-minute drift absorbed); letting it use + * the drift radius hid genuinely-due slots — a 14:30 ad-hoc take resolved + * tonight's 20:00 dose while the ledger still counted that slot missed. + */ +export const ADHOC_RESOLVE_EPSILON_MS = 60 * 1000; + +export function anchorResolvesOccurrence(input: { + /** The resolved row's anchor (`scheduledFor`, or the take instant). */ + anchor: Date; + /** The canonical occurrence instant under test. */ + occurrenceAt: Date; + /** Reach of this row: the drift radius, or the ad-hoc epsilon. */ + radiusMs: number; + schedule: CanonicalSchedule; + ctx: RecurrenceContext; +}): boolean { + const anchor = input.anchor.getTime(); + const slot = input.occurrenceAt.getTime(); + const distance = Math.abs(anchor - slot); + if (distance === 0) return true; + if (distance > input.radiusMs) return false; + if (input.schedule.rollingIntervalDays !== null) return true; + + // Drifted: the row stands for its nearest occurrence only. Any occurrence + // strictly nearer than the slot under test, or equally near but earlier, + // claims the row instead. + const neighbours = occurrencesBetween( + input.schedule, + new Date(anchor - input.radiusMs), + new Date(anchor + input.radiusMs), + input.ctx, + ); + for (const occurrence of neighbours) { + const at = occurrence.at.getTime(); + if (at === slot) continue; + const other = Math.abs(anchor - at); + if (other < distance || (other === distance && at < slot)) return false; + } + return true; +} From 550a7b87d7157dcbf27e12102c5b6352818028a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 20:34:36 +0200 Subject: [PATCH 11/11] fix(medications): measure a row's nearest occurrence across sibling schedules The nearest test enumerated only the schedule under test, and production rows carry no schedule identity. On a medication composed of sibling schedule rows (08:00 daily plus 12:00 daily, the wizard's compose shape) a row snapped exactly to A's 08:00 was also asked about B's 12:00, and B's own occurrences alone never showed that A's 08:00 is nearer: next-due, the display due and the compliance cycle all answered tomorrow's 08:00 at 09:00 local, the same swallow one level up. The neighbour set is now the union of every schedule's occurrences within the radius, memoised per row, since it depends on the row alone and a predicate asks about many slots. The rolling short-circuit still looks at the schedule under test only. The era filter in the next-due predicate compared a field no caller ever populated, so it is gone along with the test that pinned it; the era floor lives in the overdue search's band range and in the forward walk's start at now, as it always did in production. Tests: sibling schedules in both next-due and the compliance cycle, the exact tie between two slots, the 25-hour fall-back day (a pair across the repeated hour, and a row anchored in the old offset), and the ad-hoc epsilon case now stands on the ad-hoc row alone. --- CHANGELOG.md | 3 +- .../compliance/__tests__/compliance.test.ts | 35 ++++++ src/lib/analytics/compliance/cycle.ts | 24 +++- .../scheduling/__tests__/next-due.test.ts | 107 +++++++++++++----- src/lib/medications/scheduling/next-due.ts | 31 +++-- .../medications/scheduling/slot-resolution.ts | 87 +++++++++++--- 6 files changed, 225 insertions(+), 62 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e0a632d9b..c8304e3b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,8 @@ ## [1.38.17] — 2026-09-10 A webhook or ntfy target on your own network can be reached again, with the -operator's say-so, and the test button says why a private target was refused. +operator's say-so, the test button says why a private target was refused, +and the second of two close doses can be logged from the card again. ### Fixed diff --git a/src/lib/analytics/compliance/__tests__/compliance.test.ts b/src/lib/analytics/compliance/__tests__/compliance.test.ts index 10d44d818..e7bebd2d5 100644 --- a/src/lib/analytics/compliance/__tests__/compliance.test.ts +++ b/src/lib/analytics/compliance/__tests__/compliance.test.ts @@ -1701,6 +1701,41 @@ describe("buildComplianceDisplay — two rows, cadence-scaled windows", () => { ); }); + it("currentCycle — a row on sibling schedule A's 08:00 leaves sibling B's 12:00 due", () => { + // Two schedule rows on one medication (08:00 daily plus 12:00 daily). + // Berlin (CEST): 06:00Z + 10:00Z. The row carries no schedule identity; + // B's slot must lose to A's nearer occurrence, not read resolved. + const schedules: ComplianceSchedule[] = [ + { + windowStart: "08:00", + windowEnd: "08:00", + daysOfWeek: null, + rollingIntervalDays: null, + timesOfDay: ["08:00"], + }, + { + windowStart: "12:00", + windowEnd: "12:00", + daysOfWeek: null, + rollingIntervalDays: null, + timesOfDay: ["12:00"], + }, + ]; + const slot = new Date("2025-06-15T06:00:00Z"); + const takenAt = new Date("2025-06-15T06:03:00Z"); + const events = [{ scheduledFor: slot, takenAt, skipped: false }]; + const display = buildComplianceDisplay( + events, + schedules, + ctx({ lastIntakeAt: takenAt, timeZone: "Europe/Berlin" }), + { now: new Date("2025-06-15T07:00:00Z") }, + ); + expect(display.currentCycle.state).toBe("on_track"); + expect(display.currentCycle.nextDueAt?.toISOString()).toBe( + "2025-06-15T10:00:00.000Z", + ); + }); + it("currentCycle — rolling weekly overdue past grace → missed", () => { // v1.13.x Fix 4 — last shot ~10 days ago (cadence 7) → next due was 3 // days ago, well past grace → the open cycle is `missed` (the only red diff --git a/src/lib/analytics/compliance/cycle.ts b/src/lib/analytics/compliance/cycle.ts index 8befa5271..1ed582699 100644 --- a/src/lib/analytics/compliance/cycle.ts +++ b/src/lib/analytics/compliance/cycle.ts @@ -11,6 +11,7 @@ import { ADHOC_RESOLVE_EPSILON_MS, anchorResolvesOccurrence, RESOLVE_RADIUS_MS, + type NearestOccurrenceCache, } from "@/lib/medications/scheduling/slot-resolution"; import { lastNonSkippedTakenAt, @@ -88,7 +89,9 @@ function slotIsResolved( slotAt: Date, intakes: ComplianceIntakeInstant[], schedule: CanonicalSchedule, + schedules: CanonicalSchedule[], ctx: RecurrenceContext, + cache: NearestOccurrenceCache, ): boolean { for (const e of intakes) { const isResolved = e.skipped || e.autoMissed === true || e.takenAt !== null; @@ -104,7 +107,9 @@ function slotIsResolved( occurrenceAt: slotAt, radiusMs: adHoc ? ADHOC_RESOLVE_EPSILON_MS : RESOLVE_RADIUS_MS, schedule, + schedules, ctx, + cache, }); if (resolves) return true; } @@ -143,9 +148,11 @@ export function buildCurrentCycle( } }; - for (let i = 0; i < schedules.length; i++) { - const s = schedules[i]; - const canonical = toCanonicalSchedule(s, `compliance-cycle-${i}`); + const canonicals = schedules.map((s, i) => + toCanonicalSchedule(s, `compliance-cycle-${i}`), + ); + const nearestCache: NearestOccurrenceCache = new Map(); + for (const canonical of canonicals) { if (canonical.scheduleType === "PRN") continue; const n = canonical.rollingIntervalDays; if (n !== null && n > 0) { @@ -171,7 +178,16 @@ export function buildCurrentCycle( for (let step = 0; step < 64; step++) { const occ = nextOccurrenceAfter(canonical, after, recurrenceCtx); if (!occ) break; - if (!slotIsResolved(occ.at, allIntakes, canonical, recurrenceCtx)) { + if ( + !slotIsResolved( + occ.at, + allIntakes, + canonical, + canonicals, + recurrenceCtx, + nearestCache, + ) + ) { picked = occ; break; } diff --git a/src/lib/medications/scheduling/__tests__/next-due.test.ts b/src/lib/medications/scheduling/__tests__/next-due.test.ts index 29437694b..6a6fb298f 100644 --- a/src/lib/medications/scheduling/__tests__/next-due.test.ts +++ b/src/lib/medications/scheduling/__tests__/next-due.test.ts @@ -672,30 +672,6 @@ describe("Mounjaro rolling weekly — canonical overdue band after day change", scheduleId: "mounjaro-sibling", }); }); - - it("does not let an action from the archived era suppress the live-era occurrence", () => { - const liveEraStart = d("2026-07-27T12:00:00.000Z"); - const display = computeDisplayDue({ - medication, - schedules: [schedule], - now: d("2026-07-29T10:00:00.000Z"), - userTz: BERLIN, - lastIntakeAt: lastTaken, - eraStart: liveEraStart, - resolvedSlots: [ - identifiedMark(missedOccurrence, { - scheduleId: "mounjaro-weekly", - actionAt: d("2026-07-27T11:59:59.000Z"), - }), - ], - }); - - expect(display).toMatchObject({ - at: missedOccurrence, - overdue: true, - scheduleId: "mounjaro-weekly", - }); - }); }); /** @@ -859,21 +835,98 @@ describe("resolved-slot marks — a row resolves only its nearest occurrence", ( }); it("an ad-hoc row seconds off a close slot still resolves that slot only", () => { + // Only the ad-hoc row is present: at 12:30 the 12:00 band is still open, + // so the slot reads resolved through the epsilon path alone. const adhoc = d("2026-06-10T09:59:30Z"); // 11:59:30 Berlin const now = d("2026-06-10T10:30:00Z"); // 12:30 Berlin - const next = computeNextDueAt({ + const due = computeDisplayDue({ medication, schedules: [closeSlots(["08:00", "12:00"])], now, userTz: BERLIN, lastIntakeAt: adhoc, resolvedSlots: [ - mark(d("2026-06-10T06:00:00Z")), toResolvedSlotMark({ scheduledFor: adhoc, takenAt: adhoc }), ], }); + expect(due).not.toBeNull(); + expect(due!.overdue).toBe(false); + expect(due!.at.toISOString()).toBe("2026-06-11T06:00:00.000Z"); // tomorrow 08:00 + }); + + it("a row exactly midway between two slots resolves the earlier one", () => { + // 08:00 + 12:00 Berlin, a row anchored at 10:00: the tie goes to 08:00, + // so 12:00 stays due. + const now = d("2026-06-10T07:00:00Z"); // 09:00 Berlin + const next = computeNextDueAt({ + medication, + schedules: [closeSlots(["08:00", "12:00"])], + now, + userTz: BERLIN, + lastIntakeAt: d("2026-06-10T08:00:00Z"), + resolvedSlots: [mark(d("2026-06-10T08:00:00Z"))], // 10:00 Berlin + }); expect(next).not.toBeNull(); - expect(next!.toISOString()).toBe("2026-06-11T06:00:00.000Z"); // tomorrow 08:00 + expect(next!.toISOString()).toBe("2026-06-10T10:00:00.000Z"); // 12:00 Berlin + }); + + it("a row on sibling schedule A's 08:00 does not resolve sibling B's 12:00", () => { + // Two schedule rows on one medication (08:00 daily plus 12:00 daily), the + // wizard's compose shape. Rows carry no schedule identity, so B's slot + // must lose to A's nearer occurrence, not be tested against B alone. + const a: WorkerScheduleRow = { ...closeSlots(["08:00"]), id: "sched-a" }; + const b: WorkerScheduleRow = { ...closeSlots(["12:00"]), id: "sched-b" }; + const now = d("2026-06-10T07:00:00Z"); // 09:00 Berlin + const input = { + medication, + schedules: [a, b], + now, + userTz: BERLIN, + lastIntakeAt: d("2026-06-10T06:00:00Z"), + resolvedSlots: [mark(d("2026-06-10T06:00:00Z"))], // A's 08:00 exactly + }; + expect(computeNextDueAt(input)?.toISOString()).toBe( + "2026-06-10T10:00:00.000Z", + ); + const due = computeDisplayDue(input); + expect(due).not.toBeNull(); + expect(due!.overdue).toBe(false); + expect(due!.at.toISOString()).toBe("2026-06-10T10:00:00.000Z"); + expect(due!.scheduleId).toBe("sched-b"); + }); + + it("on the 25-hour fall-back day a slot across the repeated hour stays due", () => { + // 2026-10-25 Berlin falls back at 03:00 CEST to 02:00 CET. 01:00 local + // is 2026-10-24T23:00Z (CEST); 03:00 local is 02:00Z (CET), three hours + // on. Taking the 01:00 dose must leave 03:00 due. + const now = d("2026-10-25T00:00:00Z"); + const next = computeNextDueAt({ + medication, + schedules: [closeSlots(["01:00", "03:00"])], + now, + userTz: BERLIN, + lastIntakeAt: d("2026-10-24T23:00:00Z"), + resolvedSlots: [mark(d("2026-10-24T23:00:00Z"))], + }); + expect(next).not.toBeNull(); + expect(next!.toISOString()).toBe("2026-10-25T02:00:00.000Z"); + }); + + it("on the fall-back day a row anchored in the old offset still resolves 08:00", () => { + // 08:00 CET on 2026-10-25 is 07:00Z; a row computed in the CEST offset + // sits at 06:00Z, an hour off, and still stands for that slot. + const now = d("2026-10-25T07:30:00Z"); // 08:30 CET + const due = computeDisplayDue({ + medication, + schedules: [closeSlots(["08:00", "20:00"])], + now, + userTz: BERLIN, + lastIntakeAt: d("2026-10-25T06:00:00Z"), + resolvedSlots: [mark(d("2026-10-25T06:00:00Z"))], + }); + expect(due).not.toBeNull(); + expect(due!.overdue).toBe(false); + expect(due!.at.toISOString()).toBe("2026-10-25T19:00:00.000Z"); // 20:00 CET }); it("the overdue search agrees: 23:59 stays open overdue after midnight", () => { diff --git a/src/lib/medications/scheduling/next-due.ts b/src/lib/medications/scheduling/next-due.ts index 80052a120..dac18cbbc 100644 --- a/src/lib/medications/scheduling/next-due.ts +++ b/src/lib/medications/scheduling/next-due.ts @@ -28,6 +28,7 @@ import { ADHOC_RESOLVE_EPSILON_MS, anchorResolvesOccurrence, RESOLVE_RADIUS_MS, + type NearestOccurrenceCache, } from "@/lib/medications/scheduling/slot-resolution"; /** @@ -46,8 +47,6 @@ export interface ResolvedSlotMark { slotAnchored: boolean; /** Canonical schedule owner when attribution could identify it. */ scheduleId?: string; - /** When the action was made; used to isolate replacement schedule eras. */ - actionAt?: Date; /** Diagnostic state carried by exact occurrence-aware callers. */ status?: "taken" | "skipped" | "autoMissed"; } @@ -73,35 +72,34 @@ export function toResolvedSlotMark(row: { /** * Predicate over the canonical occurrences of ONE schedule: true when a * resolved row stands for the occurrence at `slotAt`. Each row resolves - * its nearest occurrence only, so a sibling slot closer than the drift - * radius is never swallowed (see `slot-resolution.ts` for the rule). + * its nearest occurrence only, measured across every schedule of the + * medication, so a sibling slot closer than the drift radius is never + * swallowed (see `slot-resolution.ts` for the rule). The era floor is + * enforced by the band range in the overdue search and by `now` in the + * forward walk, not here. */ function buildIsResolved( resolved: ResolvedSlotMark[], identity: { schedule: CanonicalSchedule; + schedules: CanonicalSchedule[]; ctx: RecurrenceContext; - eraStart?: Date | null; }, ): (slotAt: Date) => boolean { + const cache: NearestOccurrenceCache = new Map(); return (slotAt: Date): boolean => { for (const r of resolved) { if (r.scheduleId !== undefined && r.scheduleId !== identity.schedule.id) { continue; } - if ( - identity.eraStart && - r.actionAt && - r.actionAt.getTime() < identity.eraStart.getTime() - ) { - continue; - } const resolves = anchorResolvesOccurrence({ anchor: r.at, occurrenceAt: slotAt, radiusMs: r.slotAnchored ? RESOLVE_RADIUS_MS : ADHOC_RESOLVE_EPSILON_MS, schedule: identity.schedule, + schedules: identity.schedules, ctx: identity.ctx, + cache, }); if (resolves) return true; } @@ -164,13 +162,13 @@ function computeNextDueCandidate(input: { if (schedules.length === 0) return null; const ctx = buildRecurrenceContext({ medication, userTz, lastIntakeAt }); + const canonicals = schedules.map(buildCanonicalSchedule); let earliest: DueCandidate | null = null; - for (const schedule of schedules) { - const canonical = buildCanonicalSchedule(schedule); + for (const canonical of canonicals) { const isResolved = buildIsResolved(input.resolvedSlots ?? [], { schedule: canonical, + schedules: canonicals, ctx, - eraStart: input.eraStart, }); // Walk forward past slots the user has already resolved. Bounded so a // fully-logged-ahead history can't spin. @@ -276,6 +274,7 @@ function findOpenOverdueSlot( const ctx = buildRecurrenceContext({ medication, userTz, lastIntakeAt }); const intakeInstants = lastIntakeAt ? [lastIntakeAt] : []; + const canonicals = schedules.map(buildCanonicalSchedule); let latest: { at: Date; availableFrom: Date; @@ -285,8 +284,8 @@ function findOpenOverdueSlot( const canonical = buildCanonicalSchedule(schedule); const isResolved = buildIsResolved(input.resolvedSlots ?? [], { schedule: canonical, + schedules: canonicals, ctx, - eraStart: input.eraStart, }); const { bands } = buildBandsForMedication({ medication, diff --git a/src/lib/medications/scheduling/slot-resolution.ts b/src/lib/medications/scheduling/slot-resolution.ts index 2b73adb6e..f1e2c8dfd 100644 --- a/src/lib/medications/scheduling/slot-resolution.ts +++ b/src/lib/medications/scheduling/slot-resolution.ts @@ -21,8 +21,11 @@ * under test only when no other occurrence is nearer (an exact tie goes to * the earlier occurrence). So the effective reach is `min(radius, half the * gap to the neighbouring occurrence)`, and a sibling can never be swallowed. - * Rolling cadences emit at most one occurrence per interval day, which is - * farther than any radius here, so the nearest test is skipped for them. + * The neighbours are every schedule's occurrences, not only the schedule + * under test: a medication composed of sibling schedules (08:00 daily plus + * 12:00 daily) is one cadence to the person taking it. Rolling cadences emit + * at most one occurrence per interval day, which is farther than any radius + * here, so the nearest test is skipped for them. */ import { occurrencesBetween, @@ -46,6 +49,53 @@ export const RESOLVE_RADIUS_MS = 6 * 60 * 60 * 1000; */ export const ADHOC_RESOLVE_EPSILON_MS = 60 * 1000; +/** + * Nearest occurrence to `anchor` within `radiusMs`, across EVERY schedule + * of the medication: `at` and its distance, or null when no schedule emits + * inside the window. On an exact tie the earlier occurrence wins. Memoised + * per anchor in `cache` when one is passed, because the set depends on the + * row alone and a predicate asks about many slots per row. + */ +function nearestOccurrenceWithin(input: { + anchor: number; + radiusMs: number; + schedules: CanonicalSchedule[]; + ctx: RecurrenceContext; + cache?: NearestOccurrenceCache; +}): { at: number; distance: number } | null { + const key = `${input.anchor}:${input.radiusMs}`; + const cached = input.cache?.get(key); + if (cached !== undefined) return cached; + let nearest: { at: number; distance: number } | null = null; + const from = new Date(input.anchor - input.radiusMs); + const to = new Date(input.anchor + input.radiusMs); + for (const schedule of input.schedules) { + for (const occurrence of occurrencesBetween( + schedule, + from, + to, + input.ctx, + )) { + const at = occurrence.at.getTime(); + const distance = Math.abs(input.anchor - at); + if ( + nearest === null || + distance < nearest.distance || + (distance === nearest.distance && at < nearest.at) + ) { + nearest = { at, distance }; + } + } + } + input.cache?.set(key, nearest); + return nearest; +} + +export type NearestOccurrenceCache = Map< + string, + { at: number; distance: number } | null +>; + export function anchorResolvesOccurrence(input: { /** The resolved row's anchor (`scheduledFor`, or the take instant). */ anchor: Date; @@ -53,8 +103,18 @@ export function anchorResolvesOccurrence(input: { occurrenceAt: Date; /** Reach of this row: the drift radius, or the ad-hoc epsilon. */ radiusMs: number; + /** The schedule that emitted `occurrenceAt`. */ schedule: CanonicalSchedule; + /** + * Every schedule of the medication. The nearest test enumerates all of + * them: production rows carry no schedule identity, so a row on sibling + * A's 08:00 is also asked about sibling B's 12:00, and B's own + * occurrences alone would never show that A's 08:00 is nearer. + */ + schedules: CanonicalSchedule[]; ctx: RecurrenceContext; + /** Optional per-predicate memo for the nearest-occurrence enumeration. */ + cache?: NearestOccurrenceCache; }): boolean { const anchor = input.anchor.getTime(); const slot = input.occurrenceAt.getTime(); @@ -66,17 +126,16 @@ export function anchorResolvesOccurrence(input: { // Drifted: the row stands for its nearest occurrence only. Any occurrence // strictly nearer than the slot under test, or equally near but earlier, // claims the row instead. - const neighbours = occurrencesBetween( - input.schedule, - new Date(anchor - input.radiusMs), - new Date(anchor + input.radiusMs), - input.ctx, + const nearest = nearestOccurrenceWithin({ + anchor, + radiusMs: input.radiusMs, + schedules: input.schedules, + ctx: input.ctx, + cache: input.cache, + }); + if (nearest === null || nearest.at === slot) return true; + return !( + nearest.distance < distance || + (nearest.distance === distance && nearest.at < slot) ); - for (const occurrence of neighbours) { - const at = occurrence.at.getTime(); - if (at === slot) continue; - const other = Math.abs(anchor - at); - if (other < distance || (other === distance && at < slot)) return false; - } - return true; }