mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
1 parent
9c306f04e7
commit
94bed12edc
31 files changed
+236
-156
No files matched your search
+1
-1
@@ -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=""
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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/,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
|
||||
|
||||
@@ -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 } },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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=");
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user