Files
HealthLog/src/lib/notifications/egress-policy.ts
T
Marc-André Bombeck 94bed12edc 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
2026-09-10 19:08:04 +02:00

114 lines
4.5 KiB
TypeScript

import {
evaluateOriginGrant,
INVALID_ORIGIN,
originOfUrl,
parsePrivateOrigins,
PRIVATE_ORIGIN_NOT_APPROVED_CODE,
PRIVATE_ORIGIN_NOT_GRANTABLE_CODE,
type OriginVerdict,
} from "@/lib/private-origin-policy";
import { annotate, getEvent } 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; 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.
*
* 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<string> = new Set();
export function configuredNotificationPrivateOrigins(): ReadonlySet<string> {
const raw = process.env.NOTIFICATION_PRIVATE_ORIGINS;
if (raw === cachedRaw) return cachedOrigins;
// 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;
}
/**
* 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_CODE, PRIVATE_ORIGIN_NOT_GRANTABLE_CODE };
/**
* 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.
*
* 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 {
const event = getEvent();
if (!event) return;
if (!event.hasAction()) {
annotate({ action: { name: "notification.egress.private_origin" } });
}
annotate({ meta: { channel, origin } });
}