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
This commit is contained in:
Marc-André Bombeck committed 2026-09-10 19:08:04 +02:00
1 parent 9c306f04e7
commit 94bed12edc
31 files changed
+236 -156

No files matched your search

+1 -1
View File
@@ -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=""
+1 -1
View File
@@ -79,7 +79,7 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# 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=""
+2 -2
View File
@@ -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
+4 -4
View File
@@ -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.
+12 -10
View File
@@ -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.
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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에 엔드포인트가 없어요",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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."
}
]
},
@@ -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");
},
);
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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 },
+1 -1
View File
@@ -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).
+1 -1
View File
@@ -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 },
+54 -16
View File
@@ -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/,
+20 -25
View File
@@ -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<void>((resolve) => server.close(() => resolve()));
}
@@ -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<typeof import("@/lib/validations/notifications")>();
return {
...actual,
isOperatorGrantableIp: (ip: string) =>
ip === "127.0.0.1" || actual.isOperatorGrantableIp(ip),
};
});
import { _resetPinnedDispatcherForTests } from "@/lib/safe-fetch-dispatcher";
import { fetchSgvEntries } from "../client";
@@ -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({
+2 -2
View File
@@ -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.
*
@@ -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(),
);
@@ -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(),
);
+4 -4
View File
@@ -400,7 +400,7 @@ export const settingsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
...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<ZodOpenApiObject["paths"]> = {
...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<ZodOpenApiObject["paths"]> = {
...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<ZodOpenApiObject["paths"]> = {
...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 } },
},
},
+19 -19
View File
@@ -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 {
+6 -5
View File
@@ -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");
@@ -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=");
});
+3 -3
View File
@@ -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) {
+15 -14
View File
@@ -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;