mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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
233 lines
8.1 KiB
TypeScript
233 lines
8.1 KiB
TypeScript
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_CODE =
|
|
"private_origin_not_approved" as const;
|
|
/**
|
|
* 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;
|
|
export const INVALID_ORIGIN = "invalid_origin" as const;
|
|
|
|
export type OriginReason =
|
|
| typeof PRIVATE_ORIGIN_NOT_APPROVED_CODE
|
|
| typeof PRIVATE_ORIGIN_NOT_GRANTABLE_CODE
|
|
| typeof INVALID_ORIGIN;
|
|
|
|
/**
|
|
* 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 literal = hostname.toLowerCase().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 "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
|
|
}
|
|
return "a grant is one exact http(s) origin (scheme://host[:port]) with no path, query, credentials or wildcard";
|
|
}
|
|
|
|
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 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. Loopback is grantable
|
|
* when listed exactly: an operator on host networking lists it on purpose.
|
|
*/
|
|
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;
|
|
// 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 {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The origin of a target URL that may legitimately carry a path (a Gotify
|
|
* `/message` endpoint, an ntfy topic), for comparison against the grant set.
|
|
*/
|
|
export function originOfUrl(value: string): string | null {
|
|
try {
|
|
const url = new URL(value);
|
|
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
|
|
if (!url.hostname) return null;
|
|
return url.origin;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function sharesApprovedPrivateHostname(
|
|
origin: string,
|
|
privateOrigins: ReadonlySet<string>,
|
|
): boolean {
|
|
const hostname = new URL(origin).hostname;
|
|
|
|
for (const approvedOrigin of privateOrigins) {
|
|
const approvedHostname = new URL(approvedOrigin).hostname;
|
|
if (
|
|
hostname === approvedHostname ||
|
|
hostname.endsWith(`.${approvedHostname}`)
|
|
) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Parse a server-only comma-separated exact-origin allowlist.
|
|
*
|
|
* Every malformed non-empty entry is handed to `onInvalid` 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: (redactedEntry: string, reason: string) => void,
|
|
): ReadonlySet<string> {
|
|
const origins = new Set<string>();
|
|
for (const entry of (raw ?? "").split(",")) {
|
|
const trimmed = entry.trim();
|
|
if (!trimmed) continue;
|
|
const origin = canonicalOrigin(trimmed);
|
|
if (!origin) {
|
|
onInvalid(redactGrantEntry(trimmed), describeGrantRejection(trimmed));
|
|
continue;
|
|
}
|
|
origins.add(origin);
|
|
}
|
|
return origins;
|
|
}
|
|
|
|
/**
|
|
* Resolve one canonical origin against the grant set.
|
|
*
|
|
* Exact operator membership wins before the ordinary public-host verdict so a
|
|
* private DNS name can be trusted without granting its suffix or sibling
|
|
* ports. Public origins remain supported without configuration. `publicCheck`
|
|
* defaults to the origin itself; a caller holding the full URL passes it so
|
|
* the raw-string guards in `isPublicUrl` (octal and hex address spellings)
|
|
* see the text the user typed.
|
|
*/
|
|
export function evaluateOriginGrant(
|
|
origin: string,
|
|
privateOrigins: ReadonlySet<string>,
|
|
publicCheck: string = origin,
|
|
): OriginVerdict {
|
|
if (privateOrigins.has(origin)) {
|
|
return {
|
|
allowed: true,
|
|
canonicalOrigin: origin,
|
|
privateOriginApproved: true,
|
|
reasonCode: null,
|
|
};
|
|
}
|
|
|
|
// An exact grant must not accidentally become a hostname suffix grant.
|
|
// Deny related hosts before the generic public-host fallback: private DNS
|
|
// names such as gotify.lan may otherwise look syntactically public here.
|
|
if (sharesApprovedPrivateHostname(origin, privateOrigins)) {
|
|
return {
|
|
allowed: false,
|
|
canonicalOrigin: origin,
|
|
privateOriginApproved: false,
|
|
reasonCode: PRIVATE_ORIGIN_NOT_APPROVED_CODE,
|
|
};
|
|
}
|
|
|
|
if (isPublicUrl(publicCheck)) {
|
|
return {
|
|
allowed: true,
|
|
canonicalOrigin: origin,
|
|
privateOriginApproved: false,
|
|
reasonCode: null,
|
|
};
|
|
}
|
|
|
|
// 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: isNeverGrantableHost(new URL(origin).hostname)
|
|
? PRIVATE_ORIGIN_NOT_GRANTABLE_CODE
|
|
: PRIVATE_ORIGIN_NOT_APPROVED_CODE,
|
|
};
|
|
}
|