diff --git a/.env.example b/.env.example index 1564836ab..82733aeda 100644 --- a/.env.example +++ b/.env.example @@ -98,7 +98,7 @@ API_TOKEN_HMAC_KEY="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32" # Webhook (Gotify, Home Assistant, n8n) and ntfy targets on your own network, # comma-separated exact origins, e.g. "https://gotify.example.com,http://ntfy.lan:8080". # A target resolving to a private address is otherwise refused at send time. -# Loopback, link-local and metadata addresses stay refused even when listed. +# Link-local, metadata and unspecified addresses stay refused even when listed. # See docs/self-hosting/notifications.md. # NOTIFICATION_PRIVATE_ORIGINS="" diff --git a/.env.production.example b/.env.production.example index 190e26dd7..0d5f7a2c5 100644 --- a/.env.production.example +++ b/.env.production.example @@ -79,7 +79,7 @@ API_TOKEN_HMAC_KEY="" # path). A target that resolves to a private address is otherwise refused at # send time; the test button reports it as private_origin_not_approved. A # listed origin is still dialled through the pinned resolver with redirects -# forbidden; loopback, link-local and metadata addresses stay refused even +# forbidden; link-local, metadata and unspecified addresses stay refused even # when listed, and a malformed entry is logged once and grants nothing. # See docs/self-hosting/notifications.md. # NOTIFICATION_PRIVATE_ORIGINS="" diff --git a/docker-compose.yml b/docker-compose.yml index 434cfdd27..e55a024af 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -208,8 +208,8 @@ x-healthlog-env: &healthlog_env # Webhook (Gotify, Home Assistant, n8n) and ntfy targets on your own # network, comma-separated exact origins (e.g. "https://gotify.example.com, # http://ntfy.lan:8080"). Without it a target that resolves to a private - # address is refused at send time as an SSRF target. Loopback, link-local - # and metadata addresses stay refused even when listed. See + # address is refused at send time as an SSRF target. Link-local, metadata + # and unspecified addresses stay refused even when listed. See # docs/self-hosting/notifications.md. NOTIFICATION_PRIVATE_ORIGINS: "${NOTIFICATION_PRIVATE_ORIGINS:-}" # Open-Meteo endpoints for the environment/weather correlation. Default to diff --git a/docs/integrations/nightscout.md b/docs/integrations/nightscout.md index 3a75f8ef4..f90b09f07 100644 --- a/docs/integrations/nightscout.md +++ b/docs/integrations/nightscout.md @@ -28,10 +28,10 @@ Each entry is an exact normalized `scheme://host:port` trust unit: rejected; - approving one hostname does not approve subdomains, sibling hosts, or a different port; and -- loopback (`127.0.0.0/8`, `::1`, `localhost`), the unspecified address, - link-local and the cloud-metadata range cannot be listed, and a listed - name that resolves there is refused at connect time — the grant opens - your network, never the container or the host it runs on; and +- the unspecified address, link-local and the cloud-metadata range cannot + be listed, and a listed name that resolves there is refused at connect + time; loopback (`http://localhost:1337`, `http://127.0.0.1:1337`) stays a + valid grant for a host-networking deployment, exactly as before; and - any malformed non-empty entry fails evaluation instead of silently weakening the configured policy. diff --git a/docs/self-hosting/notifications.md b/docs/self-hosting/notifications.md index 74c00207c..a3dd06de1 100644 --- a/docs/self-hosting/notifications.md +++ b/docs/self-hosting/notifications.md @@ -235,14 +235,14 @@ Each entry is one `scheme://host[:port]`, the same grammar as a range grant would let a DNS rebinding to any address in the range pass the pin, so an operator with several relays lists several origins; - listing a host does not list its sub-hosts or sibling ports; -- loopback (`127.0.0.0/8`, `::1`, `localhost`), the unspecified address, - link-local and the cloud-metadata range (`169.254.0.0/16`, `fe80::/10`) - cannot be granted: a literal entry is logged once and ignored, and a - listed name that resolves there is refused at dial time. On a - host-network deployment, list the LAN address of the relay instead of - `localhost`; -- RFC1918, IPv6 unique-local and CGNAT (a Tailscale address or MagicDNS - name) are what the list is for; +- the unspecified address (`0.0.0.0`, `::`), link-local and the + cloud-metadata range (`169.254.0.0/16`, `fe80::/10`) cannot be granted: a + literal entry is logged once and ignored, and a listed name that resolves + there is refused at dial time; +- RFC1918, IPv6 unique-local, CGNAT (a Tailscale address or MagicDNS name) + and, on a host-network deployment, loopback (`http://127.0.0.1:8080`, + `http://localhost:8080`, `::1`) are what the list is for — an exact + loopback origin with its port is as deliberate a decision as a LAN one; - a malformed entry is logged once at first use and grants nothing; the valid entries beside it keep working. @@ -261,11 +261,13 @@ What is refused where, so you know which lever to pull: | `gotify.lan`, `gotify.home.arpa`, a Docker service name | refused at send | works | | `gotify.local` (mDNS) | refused at save | allowed, if the container resolves mDNS | | Literal `192.168.x.x`, `10.x.x.x`, `100.64.x.x`, `fd00::/8` | refused at save | works | -| `localhost`, `127.0.0.1`, `169.254.169.254`, `::1` | refused at save | still refused (`private_origin_not_grantable`) | +| `localhost`, `127.0.0.1`, `::1` (host networking) | refused at save | works | +| `0.0.0.0`, `169.254.169.254`, `fe80::…` | refused at save | still refused (`private_origin_not_grantable`) | A private target that is not listed is refused when the card is saved (`422`, `meta.errorCode` = `private_origin_not_approved`, the message -names the variable) or, for a DNS name the save cannot classify, when the +names the variable; a link-local, metadata or unspecified target answers +`private_origin_not_grantable` instead, because no grant can open it) or, for a DNS name the save cannot classify, when the test button or a reminder dials it. The resolved address is never shown to the user. Telegram, email and Web Push are not affected: their hosts are not user-supplied. diff --git a/messages/de.json b/messages/de.json index f54c191dc..caa40fff9 100644 --- a/messages/de.json +++ b/messages/de.json @@ -5991,7 +5991,7 @@ "not_configured": "Nicht konfiguriert", "url_not_public": "URL ist kein öffentlicher Endpunkt", "private_origin_not_approved": "Ziel im privaten Netz. Der Betreiber muss diesen Origin in NOTIFICATION_PRIVATE_ORIGINS eintragen", - "private_origin_not_grantable": "Diese Adresse ist Loopback, Link-local oder eine Metadaten-Adresse; keine Betreiber-Freigabe kann sie öffnen. Bitte die LAN-Adresse des Relays verwenden", + "private_origin_not_grantable": "Diese Adresse ist Link-local, eine Metadaten- oder eine unspezifizierte Adresse; keine Betreiber-Freigabe kann sie öffnen. Bitte die Adresse verwenden, auf der das Relay lauscht", "url_invalid": "URL ist ungültig", "redirected": "Endpunkt leitet weiter — URL prüfen", "endpoint_not_found": "Endpunkt unter der URL nicht gefunden", diff --git a/messages/en.json b/messages/en.json index 3ac231118..d58ec0a2c 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5991,7 +5991,7 @@ "not_configured": "Not configured", "url_not_public": "URL is not a public endpoint", "private_origin_not_approved": "Private network target. The operator has to list this origin in NOTIFICATION_PRIVATE_ORIGINS", - "private_origin_not_grantable": "This address is loopback, link-local or a metadata address; no operator grant can open it. Use the relay's LAN address", + "private_origin_not_grantable": "This address is link-local, a metadata or an unspecified address; no operator grant can open it. Use the address the relay listens on", "url_invalid": "URL is invalid", "redirected": "Endpoint redirects — check the URL", "endpoint_not_found": "Endpoint not found at the URL", diff --git a/messages/es.json b/messages/es.json index a3ac9e070..4a8802a5f 100644 --- a/messages/es.json +++ b/messages/es.json @@ -5991,7 +5991,7 @@ "not_configured": "No configurado", "url_not_public": "La URL no es un endpoint público", "private_origin_not_approved": "Destino en una red privada. El operador debe añadir este origen a NOTIFICATION_PRIVATE_ORIGINS", - "private_origin_not_grantable": "Esta dirección es loopback, de enlace local o de metadatos; ninguna autorización del operador puede abrirla. Usa la dirección LAN del relay", + "private_origin_not_grantable": "Esta dirección es de enlace local, de metadatos o no especificada; ninguna autorización del operador puede abrirla. Usa la dirección en la que escucha el relay", "url_invalid": "La URL no es válida", "redirected": "El endpoint redirige — comprueba la URL", "endpoint_not_found": "No se encontró el endpoint en la URL", diff --git a/messages/fr.json b/messages/fr.json index b7ee3631f..b30e60b9a 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -5991,7 +5991,7 @@ "not_configured": "Non configuré", "url_not_public": "L’URL n’est pas un point de terminaison public", "private_origin_not_approved": "Cible sur un réseau privé. L’opérateur doit ajouter cette origine à NOTIFICATION_PRIVATE_ORIGINS", - "private_origin_not_grantable": "Cette adresse est une adresse de bouclage, de lien local ou de métadonnées ; aucune autorisation de l’opérateur ne peut l’ouvrir. Utilisez l’adresse LAN du relais", + "private_origin_not_grantable": "Cette adresse est une adresse de lien local, de métadonnées ou non spécifiée ; aucune autorisation de l’opérateur ne peut l’ouvrir. Utilisez l’adresse sur laquelle le relais écoute", "url_invalid": "L’URL n’est pas valide", "redirected": "Le point de terminaison redirige — vérifiez l’URL", "endpoint_not_found": "Point de terminaison introuvable à l’URL", diff --git a/messages/it.json b/messages/it.json index 9f30da14a..407352537 100644 --- a/messages/it.json +++ b/messages/it.json @@ -5991,7 +5991,7 @@ "not_configured": "Non configurato", "url_not_public": "L’URL non è un endpoint pubblico", "private_origin_not_approved": "Destinazione su una rete privata. L’operatore deve aggiungere questa origine a NOTIFICATION_PRIVATE_ORIGINS", - "private_origin_not_grantable": "Questo indirizzo è di loopback, link-local o dei metadati; nessuna autorizzazione dell’operatore può aprirlo. Usa l’indirizzo LAN del relay", + "private_origin_not_grantable": "Questo indirizzo è link-local, dei metadati o non specificato; nessuna autorizzazione dell’operatore può aprirlo. Usa l’indirizzo su cui il relay è in ascolto", "url_invalid": "L’URL non è valido", "redirected": "L’endpoint reindirizza — controlla l’URL", "endpoint_not_found": "Endpoint non trovato all’URL", diff --git a/messages/ko.json b/messages/ko.json index a67d3418f..2e364231e 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -5991,7 +5991,7 @@ "not_configured": "설정되지 않았어요", "url_not_public": "URL이 공개 엔드포인트가 아니에요", "private_origin_not_approved": "비공개 네트워크 대상이에요. 운영자가 NOTIFICATION_PRIVATE_ORIGINS에 이 출처를 등록해야 해요", - "private_origin_not_grantable": "이 주소는 루프백, 링크 로컬 또는 메타데이터 주소예요. 운영자 등록으로도 열 수 없으니 릴레이의 LAN 주소를 사용해 주세요", + "private_origin_not_grantable": "이 주소는 링크 로컬, 메타데이터 또는 미지정 주소예요. 운영자 등록으로도 열 수 없으니 릴레이가 실제로 수신하는 주소를 사용해 주세요", "url_invalid": "URL이 올바르지 않아요", "redirected": "엔드포인트가 리디렉션돼요 — URL을 확인해 주세요", "endpoint_not_found": "해당 URL에 엔드포인트가 없어요", diff --git a/messages/pl.json b/messages/pl.json index bfeba4653..c09076960 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -5991,7 +5991,7 @@ "not_configured": "Nieskonfigurowano", "url_not_public": "Adres URL nie jest publicznym punktem końcowym", "private_origin_not_approved": "Cel w sieci prywatnej. Operator musi dodać to źródło do NOTIFICATION_PRIVATE_ORIGINS", - "private_origin_not_grantable": "Ten adres to loopback, link-local lub adres metadanych; żadna zgoda operatora nie może go otworzyć. Użyj adresu LAN przekaźnika", + "private_origin_not_grantable": "Ten adres to link-local, adres metadanych lub nieokreślony; żadna zgoda operatora nie może go otworzyć. Użyj adresu, na którym nasłuchuje przekaźnik", "url_invalid": "Adres URL jest nieprawidłowy", "redirected": "Punkt końcowy przekierowuje — sprawdź adres URL", "endpoint_not_found": "Nie znaleziono punktu końcowego pod adresem URL", diff --git a/scripts/env-manifest.json b/scripts/env-manifest.json index 910134538..5c251bfe1 100644 --- a/scripts/env-manifest.json +++ b/scripts/env-manifest.json @@ -233,7 +233,7 @@ "variables": [ { "name": "NOTIFICATION_PRIVATE_ORIGINS", - "purpose": "Comma-separated exact origins (scheme://host[:port], no path, no wildcard, no CIDR), e.g. \"https://gotify.example.com,http://ntfy.lan:8080\". Loopback, link-local and metadata addresses stay refused even when listed; a malformed entry is logged once and grants nothing." + "purpose": "Comma-separated exact origins (scheme://host[:port], no path, no wildcard, no CIDR), e.g. \"https://gotify.example.com,http://ntfy.lan:8080\". Link-local, metadata and unspecified addresses stay refused even when listed; a malformed entry is logged once and grants nothing." } ] }, diff --git a/src/app/api/settings/__tests__/private-origin-routes.test.ts b/src/app/api/settings/__tests__/private-origin-routes.test.ts index fa133af22..4bc8d5b77 100644 --- a/src/app/api/settings/__tests__/private-origin-routes.test.ts +++ b/src/app/api/settings/__tests__/private-origin-routes.test.ts @@ -179,25 +179,47 @@ describe("PUT /api/settings/webhook — private targets", () => { expect(json.meta).toBeUndefined(); }); - it("answers a loopback target with the not-grantable code, listed or not (M1)", async () => { + it("saves a listed loopback target and refuses an unlisted one with the listable code", async () => { + // Host networking: an exact loopback origin with its port is a grant like + // any other. Unlisted, it is refused with the code that says "list it", + // because listing it does open it. + process.env.NOTIFICATION_PRIVATE_ORIGINS = "http://localhost:8080"; + + const refused = await put("webhook", { + url: "http://127.0.0.1:8080/message", + enabled: true, + }); + expect(refused.status).toBe(422); + await expect(refused.json()).resolves.toMatchObject({ + meta: { errorCode: "private_origin_not_approved" }, + }); + + const saved = await put("webhook", { + url: "http://localhost:8080/message", + enabled: true, + }); + expect(saved.status).toBe(200); + expect(prisma.notificationChannel.upsert).toHaveBeenCalledTimes(1); + }); + + it("answers a metadata or unspecified target with the not-grantable code, listed or not (M1)", async () => { // This used to pin a dead end: the route said "list it", the parser // dropped the listing, and the next save said "list it" again. A target // no grant can open gets its own code and a message that says so. vi.spyOn(console, "warn").mockImplementation(() => {}); process.env.NOTIFICATION_PRIVATE_ORIGINS = - "http://127.0.0.1:8080, http://localhost:8080"; + "http://169.254.169.254, http://0.0.0.0:8080"; for (const url of [ - "http://127.0.0.1:8080/message", - "http://localhost:8080/message", "http://169.254.169.254/latest", - "http://[::1]:8080/message", + "http://0.0.0.0:8080/message", + "http://[fe80::1]:8080/message", ]) { const response = await put("webhook", { url, enabled: true }); expect(response.status).toBe(422); const json = await response.json(); expect(json.meta).toEqual({ errorCode: "private_origin_not_grantable" }); - expect(json.error).toMatch(/LAN address/); + expect(json.error).toMatch(/no operator grant can open/); expect(json.error).not.toContain("NOTIFICATION_PRIVATE_ORIGINS"); expect(json.details.issues.length).toBeGreaterThan(0); } @@ -321,7 +343,7 @@ describe("POST /api/settings/{webhook,ntfy}/test — the refusal names itself", expect(response.status).toBe(422); const json = await response.json(); expect(json.meta).toEqual({ errorCode: "private_origin_not_grantable" }); - expect(json.error).toMatch(/LAN address/); + expect(json.error).toMatch(/no operator grant can open/); expect(json.error).not.toContain("NOTIFICATION_PRIVATE_ORIGINS"); }, ); diff --git a/src/app/api/settings/ntfy/route.ts b/src/app/api/settings/ntfy/route.ts index ab084384f..0aba06dda 100644 --- a/src/app/api/settings/ntfy/route.ts +++ b/src/app/api/settings/ntfy/route.ts @@ -32,7 +32,7 @@ const ntfySettingsSchema = ntfySettingsSchemaWith(isAllowedNotificationTarget); const PRIVATE_ORIGIN_REFUSAL = "This server is on a private network. The operator has to list its exact origin (scheme://host:port) in NOTIFICATION_PRIVATE_ORIGINS before HealthLog can send to it."; const NOT_GRANTABLE_REFUSAL = - "This server is loopback, link-local or a metadata address, which no operator grant can open. Use the relay's LAN address instead."; + "This server is a link-local, metadata or unspecified address, which no operator grant can open. Use the address the relay actually listens on."; /** * Refusal for an enable attempt on a channel the operator switched off diff --git a/src/app/api/settings/ntfy/test/route.ts b/src/app/api/settings/ntfy/test/route.ts index ad1371206..b260f1643 100644 --- a/src/app/api/settings/ntfy/test/route.ts +++ b/src/app/api/settings/ntfy/test/route.ts @@ -52,7 +52,7 @@ export const POST = apiHandler(async () => { }); return apiError( result.errorCode === "private_origin_not_grantable" - ? "The server is loopback, link-local or a metadata address, which no operator grant can open. Use the relay's LAN address instead." + ? "The server is a link-local, metadata or unspecified address, which no operator grant can open. Use the address the relay actually listens on." : "The server is on a private network the operator has not approved. List its exact origin (scheme://host:port) in NOTIFICATION_PRIVATE_ORIGINS on the server.", 422, { errorCode: result.errorCode }, diff --git a/src/app/api/settings/webhook/route.ts b/src/app/api/settings/webhook/route.ts index c4f3ded09..1f1b072b3 100644 --- a/src/app/api/settings/webhook/route.ts +++ b/src/app/api/settings/webhook/route.ts @@ -34,7 +34,7 @@ const webhookSettingsSchema = webhookSettingsSchemaWith( const PRIVATE_ORIGIN_REFUSAL = "This address is on a private network. The operator has to list its exact origin (scheme://host:port) in NOTIFICATION_PRIVATE_ORIGINS before HealthLog can send to it."; const NOT_GRANTABLE_REFUSAL = - "This address is loopback, link-local or a metadata address, which no operator grant can open. Use the relay's LAN address instead."; + "This address is a link-local, metadata or unspecified address, which no operator grant can open. Use the address the relay actually listens on."; /** * Generic-webhook channel config (v1.17.1). diff --git a/src/app/api/settings/webhook/test/route.ts b/src/app/api/settings/webhook/test/route.ts index 964c74141..cc6bfa6dc 100644 --- a/src/app/api/settings/webhook/test/route.ts +++ b/src/app/api/settings/webhook/test/route.ts @@ -51,7 +51,7 @@ export const POST = apiHandler(async () => { }); return apiError( result.errorCode === "private_origin_not_grantable" - ? "The target is loopback, link-local or a metadata address, which no operator grant can open. Use the relay's LAN address instead." + ? "The target is a link-local, metadata or unspecified address, which no operator grant can open. Use the address the relay actually listens on." : "The target is on a private network the operator has not approved. List its exact origin (scheme://host:port) in NOTIFICATION_PRIVATE_ORIGINS on the server.", 422, { errorCode: result.errorCode }, diff --git a/src/lib/__tests__/private-origin-policy.test.ts b/src/lib/__tests__/private-origin-policy.test.ts index 3976d5116..10826c41a 100644 --- a/src/lib/__tests__/private-origin-policy.test.ts +++ b/src/lib/__tests__/private-origin-policy.test.ts @@ -51,21 +51,29 @@ describe("canonicalOrigin", () => { }); it.each([ - ["IPv4 loopback", "http://127.0.0.1:8080"], - ["IPv4 loopback, non-canonical", "http://127.10.20.30"], ["IPv4 unspecified", "http://0.0.0.0:8080"], ["link-local / metadata", "http://169.254.169.254"], - ["IPv6 loopback", "http://[::1]:8080"], ["IPv6 unspecified", "http://[::]:8080"], ["IPv6 link-local", "http://[fe80::1]:8080"], - ["IPv4-mapped loopback", "http://[::ffff:127.0.0.1]:8080"], ["IPv4-mapped metadata", "http://[::ffff:169.254.169.254]"], - ["localhost", "http://localhost:8080"], - ["a *.localhost name", "http://gotify.localhost"], ])("refuses %s as a grant", (_label, value) => { expect(canonicalOrigin(value)).toBeNull(); }); + it("keeps loopback grantable: an exact loopback origin is a deliberate host-networking decision", () => { + expect(canonicalOrigin("http://127.0.0.1:8080")).toBe( + "http://127.0.0.1:8080", + ); + expect(canonicalOrigin("http://127.10.20.30")).toBe("http://127.10.20.30"); + expect(canonicalOrigin("http://[::1]:8080")).toBe("http://[::1]:8080"); + expect(canonicalOrigin("http://localhost:1337")).toBe( + "http://localhost:1337", + ); + expect(canonicalOrigin("http://gotify.localhost")).toBe( + "http://gotify.localhost", + ); + }); + it("keeps mDNS and other reserved-looking names grantable when listed exactly", () => { // The input-time floor refuses `.local` and `.internal` unlisted; an // operator naming the exact origin is the override, and a loopback or @@ -120,16 +128,16 @@ describe("parsePrivateOrigins", () => { const onInvalid = vi.fn(); expect( parsePrivateOrigins( - "https://gotify.lan, https://*.lan, http://127.0.0.1:8080, not an origin", + "https://gotify.lan, https://*.lan, http://169.254.169.254, not an origin", onInvalid, ), ).toEqual(new Set(["https://gotify.lan"])); expect(onInvalid.mock.calls.map(([entry]) => entry)).toEqual([ "https://*.lan", - "http://127.0.0.1:8080", + "http://169.254.169.254", "not an origin", ]); - expect(onInvalid.mock.calls[1][1]).toMatch(/loopback.*cannot be granted/); + expect(onInvalid.mock.calls[1][1]).toMatch(/link-local.*cannot be granted/); expect(onInvalid.mock.calls[0][1]).toMatch(/one exact http\(s\) origin/); }); @@ -204,13 +212,41 @@ describe("evaluateOriginGrant", () => { }); }); + it("approves a listed loopback origin and refuses an unlisted one", () => { + const loopbackGrants = new Set([ + "http://localhost:1337", + "http://[::1]:8080", + ]); + expect( + evaluateOriginGrant("http://localhost:1337", loopbackGrants), + ).toEqual({ + allowed: true, + canonicalOrigin: "http://localhost:1337", + privateOriginApproved: true, + reasonCode: null, + }); + expect( + evaluateOriginGrant("http://[::1]:8080", loopbackGrants), + ).toMatchObject({ allowed: true, privateOriginApproved: true }); + for (const origin of [ + "http://localhost:1338", + "http://127.0.0.1:1337", + "http://[::1]:8081", + ]) { + expect(evaluateOriginGrant(origin, loopbackGrants)).toEqual({ + allowed: false, + canonicalOrigin: origin, + privateOriginApproved: false, + reasonCode: PRIVATE_ORIGIN_NOT_APPROVED_CODE, + }); + } + }); + it.each([ - ["IPv4 loopback", "http://127.0.0.1:8080"], - ["localhost", "http://localhost:8080"], - ["a *.localhost name", "http://gotify.localhost"], ["link-local / metadata", "http://169.254.169.254"], - ["IPv6 loopback", "http://[::1]:8080"], ["unspecified", "http://0.0.0.0:8080"], + ["IPv6 link-local", "http://[fe80::1]:8080"], + ["IPv6 unspecified", "http://[::]:8080"], ])( "answers %s with the not-grantable code, not a request to list it (M1)", (_label, origin) => { @@ -236,10 +272,12 @@ describe("redactGrantEntry / describeGrantRejection", () => { }); it("names the never-grantable class apart from a grammar error", () => { - expect(describeGrantRejection("http://localhost:1337")).toMatch( - /loopback.*LAN address/, + expect(describeGrantRejection("http://169.254.169.254")).toMatch( + /link-local.*cannot be granted/, + ); + expect(describeGrantRejection("http://[fe80::1]:1337")).toMatch( + /link-local/, ); - expect(describeGrantRejection("http://[::1]:1337")).toMatch(/loopback/); expect(describeGrantRejection("https://*.lan")).toMatch(/exact http/); expect(describeGrantRejection("http://10.0.0.4/path")).toMatch( /exact http/, diff --git a/src/lib/__tests__/safe-fetch-dispatcher.test.ts b/src/lib/__tests__/safe-fetch-dispatcher.test.ts index f557905bf..b9076b2f0 100644 --- a/src/lib/__tests__/safe-fetch-dispatcher.test.ts +++ b/src/lib/__tests__/safe-fetch-dispatcher.test.ts @@ -246,6 +246,11 @@ describe("pinnedPublicDispatcher", () => { describe("isOperatorGrantableIp", () => { it("keeps the private ranges an operator may deliberately list", () => { expect(isOperatorGrantableIp("10.0.0.5")).toBe(true); + // Loopback is grantable: a host-networking deployment lists it exactly. + expect(isOperatorGrantableIp("127.0.0.1")).toBe(true); + expect(isOperatorGrantableIp("127.255.0.1")).toBe(true); + expect(isOperatorGrantableIp("::1")).toBe(true); + expect(isOperatorGrantableIp("::ffff:127.0.0.1")).toBe(true); expect(isOperatorGrantableIp("172.16.0.9")).toBe(true); expect(isOperatorGrantableIp("192.168.1.20")).toBe(true); expect(isOperatorGrantableIp("100.64.0.7")).toBe(true); @@ -259,17 +264,14 @@ describe("isOperatorGrantableIp", () => { }); it.each([ - ["IPv4 loopback", "127.0.0.1"], - ["IPv4 loopback, high octet", "127.255.0.1"], ["IPv4 unspecified", "0.0.0.0"], ["IPv4 link-local / metadata", "169.254.169.254"], - ["IPv6 loopback", "::1"], ["IPv6 unspecified", "::"], ["IPv6 link-local", "fe80::1"], - ["IPv4-mapped loopback", "::ffff:127.0.0.1"], ["IPv4-mapped metadata", "::ffff:169.254.169.254"], + ["IPv4-mapped unspecified", "::ffff:0.0.0.0"], ["6to4 metadata", "2002:a9fe:a9fe::"], - ["NAT64 loopback", "64:ff9b::7f00:1"], + ["NAT64 metadata", "64:ff9b::a9fe:a9fe"], ["not an address", "gotify.lan"], ["empty", ""], ])("never grants %s, listed or not", (_label, ip) => { @@ -297,10 +299,9 @@ describe("pinnedOperatorApprovedDispatcher", () => { } it.each([ - ["loopback", { address: "127.0.0.1", family: 4 }], ["link-local / metadata", { address: "169.254.169.254", family: 4 }], ["unspecified", { address: "0.0.0.0", family: 4 }], - ["IPv6 loopback", { address: "::1", family: 6 }], + ["IPv6 unspecified", { address: "::", family: 6 }], ["IPv6 link-local", { address: "fe80::1", family: 6 }], ["IPv4-mapped metadata", { address: "::ffff:169.254.169.254", family: 6 }], ])( @@ -348,11 +349,11 @@ describe("pinnedOperatorApprovedDispatcher", () => { expect(errorCodes(caught)).not.toContain("ENOTFOUND"); }, 20_000); - it("drops the loopback answer from a mixed set and dials only the private one", async () => { - // A real server on loopback proves the point: the listed name resolves to - // loopback AND to an unroutable private address; the pinned lookup must - // hand undici only the private survivor, so the loopback server never - // sees a request and the dial fails at the socket, not at the resolver. + it("drops the metadata answer from a mixed set and dials the loopback one", async () => { + // A real server on loopback proves both halves: the listed name resolves + // to the metadata endpoint AND to loopback; the pinned lookup drops the + // metadata answer and hands undici the loopback survivor, which is + // grantable, so the server receives exactly one request. let requests = 0; const server = http.createServer((_req, res) => { requests += 1; @@ -365,24 +366,18 @@ describe("pinnedOperatorApprovedDispatcher", () => { const port = (server.address() as AddressInfo).port; try { resolveTo([ + { address: "169.254.169.254", family: 4 }, { address: "127.0.0.1", family: 4 }, - { address: "10.255.255.1", family: 4 }, ]); const dispatcher = getPinnedOperatorApprovedDispatcher(); - let caught: unknown; - try { - await undiciFetch(`http://gotify-listed.example.test:${port}/message`, { - dispatcher, - signal: AbortSignal.timeout(500), - }); - } catch (error) { - caught = error; - } + const res = await undiciFetch( + `http://gotify-listed.example.test:${port}/message`, + { dispatcher, signal: AbortSignal.timeout(5_000) }, + ); - expect(caught).toBeDefined(); - expect(errorCodes(caught)).not.toContain("ENOTFOUND"); - expect(requests).toBe(0); + expect(res.status).toBe(200); + expect(requests).toBe(1); } finally { await new Promise((resolve) => server.close(() => resolve())); } diff --git a/src/lib/nightscout/__tests__/client-pinned.test.ts b/src/lib/nightscout/__tests__/client-pinned.test.ts index d9db5a7f3..51537bf32 100644 --- a/src/lib/nightscout/__tests__/client-pinned.test.ts +++ b/src/lib/nightscout/__tests__/client-pinned.test.ts @@ -5,11 +5,9 @@ * an operator-approved private origin reaches the pinned Undici connector. * This test keeps the Nightscout client, SafeFetch, dispatcher, socket, and * HTTP server real. Only DNS is deterministic: the approved test hostname is - * resolved to the loopback server from inside the connector. Because the - * operator-approved dispatcher refuses a loopback answer by policy (the grant - * opens the operator's network, never the container), the grantable-address - * predicate is stubbed to admit loopback for this harness only; the policy - * itself is proven in `safe-fetch-dispatcher.test.ts`. + * resolved to the loopback server from inside the connector. Loopback is a + * grantable answer under the operator-approved policy (a host-networking + * deployment lists it), so the floor runs for real here. */ import dns from "node:dns"; import http from "node:http"; @@ -24,16 +22,6 @@ import { vi, } from "vitest"; -vi.mock("@/lib/validations/notifications", async (importOriginal) => { - const actual = - await importOriginal(); - return { - ...actual, - isOperatorGrantableIp: (ip: string) => - ip === "127.0.0.1" || actual.isOperatorGrantableIp(ip), - }; -}); - import { _resetPinnedDispatcherForTests } from "@/lib/safe-fetch-dispatcher"; import { fetchSgvEntries } from "../client"; diff --git a/src/lib/notifications/__tests__/egress-policy.test.ts b/src/lib/notifications/__tests__/egress-policy.test.ts index 77e5585a2..85607c116 100644 --- a/src/lib/notifications/__tests__/egress-policy.test.ts +++ b/src/lib/notifications/__tests__/egress-policy.test.ts @@ -60,7 +60,7 @@ describe("configuredNotificationPrivateOrigins", () => { it("logs a malformed entry once, skips it, and keeps the valid ones", () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); process.env.NOTIFICATION_PRIVATE_ORIGINS = - "https://gotify.lan, https://*.lan, http://127.0.0.1:8080"; + "https://gotify.lan, https://*.lan, http://169.254.169.254"; expect(configuredNotificationPrivateOrigins()).toEqual( new Set(["https://gotify.lan"]), @@ -74,8 +74,8 @@ describe("configuredNotificationPrivateOrigins", () => { const lines = warn.mock.calls.map((call) => String(call[0])); expect(lines[0]).toContain("NOTIFICATION_PRIVATE_ORIGINS"); expect(lines[0]).toContain("https://*.lan"); - expect(lines[1]).toContain("http://127.0.0.1:8080"); - expect(lines[1]).toMatch(/loopback.*cannot be granted/); + expect(lines[1]).toContain("http://169.254.169.254"); + expect(lines[1]).toMatch(/link-local.*cannot be granted/); }); it("warns without echoing a query, fragment or userinfo (M3)", () => { @@ -152,20 +152,36 @@ describe("evaluateNotificationTarget", () => { }); }); - it("keeps the raw-string floor: an octal loopback spelling is refused as never grantable", () => { + it("keeps the raw-string floor: an octal loopback spelling is refused, not parsed", () => { expect( evaluateNotificationTarget("http://0177.0.0.1/message"), ).toMatchObject({ allowed: false, - reasonCode: "private_origin_not_grantable", + reasonCode: "private_origin_not_approved", }); }); + it("approves a listed loopback target and refuses an unlisted one", () => { + process.env.NOTIFICATION_PRIVATE_ORIGINS = "http://localhost:8080"; + expect( + evaluateNotificationTarget("http://localhost:8080/message"), + ).toMatchObject({ allowed: true, privateOriginApproved: true }); + for (const url of [ + "http://127.0.0.1:8080/message", + "http://localhost:9090/message", + "http://[::1]:8080/message", + ]) { + expect(evaluateNotificationTarget(url)).toMatchObject({ + allowed: false, + reasonCode: "private_origin_not_approved", + }); + } + }); + it.each([ - ["loopback", "http://127.0.0.1:8080/message"], - ["localhost", "http://localhost:8080/message"], ["metadata", "http://169.254.169.254/latest"], - ["IPv6 loopback", "http://[::1]:8080/message"], + ["unspecified", "http://0.0.0.0:8080/message"], + ["IPv6 link-local", "http://[fe80::1]:8080/message"], ])("answers a %s target with the not-grantable code (M1)", (_l, url) => { process.env.NOTIFICATION_PRIVATE_ORIGINS = "https://gotify.example.com"; expect(evaluateNotificationTarget(url)).toMatchObject({ diff --git a/src/lib/notifications/egress-policy.ts b/src/lib/notifications/egress-policy.ts index 35fd67daa..4c3dfa2a9 100644 --- a/src/lib/notifications/egress-policy.ts +++ b/src/lib/notifications/egress-policy.ts @@ -24,8 +24,8 @@ import { annotate, getEvent } from "@/lib/logging/context"; * NOTIFICATION_PRIVATE_ORIGINS="https://gotify.example.com,http://ntfy.lan:8080" * * A listed origin is still dialled through the pinned resolver with redirects - * forbidden; loopback, link-local and the metadata range stay refused even - * when listed. Nothing not listed widens, and there is no `=true`, CIDR, + * forbidden; link-local, the metadata range and the unspecified address stay + * refused even when listed (loopback is grantable when listed exactly). Nothing not listed widens, and there is no `=true`, CIDR, * wildcard or suffix form. The grant lives in the server environment only — * no request field and no settings toggle can confer it. * diff --git a/src/lib/notifications/senders/__tests__/ntfy.test.ts b/src/lib/notifications/senders/__tests__/ntfy.test.ts index 4e71b47f7..9bb4c17be 100644 --- a/src/lib/notifications/senders/__tests__/ntfy.test.ts +++ b/src/lib/notifications/senders/__tests__/ntfy.test.ts @@ -218,9 +218,9 @@ describe("sendViaNtfy", () => { ); }); - it("reports the not-grantable code for a loopback target, without dialling (M1)", async () => { + it("reports the not-grantable code for a metadata target, without dialling (M1)", async () => { const result = await sendViaNtfy( - { serverUrl: "http://localhost:8080", topic: "health" }, + { serverUrl: "http://169.254.169.254", topic: "health" }, payload(), ); diff --git a/src/lib/notifications/senders/__tests__/webhook.test.ts b/src/lib/notifications/senders/__tests__/webhook.test.ts index e9d8a561e..255f1705e 100644 --- a/src/lib/notifications/senders/__tests__/webhook.test.ts +++ b/src/lib/notifications/senders/__tests__/webhook.test.ts @@ -266,9 +266,9 @@ describe("sendViaWebhook", () => { }); }); - it("reports the not-grantable code for a loopback target, without dialling (M1)", async () => { + it("reports the not-grantable code for a metadata target, without dialling (M1)", async () => { const result = await sendViaWebhook( - { url: "http://127.0.0.1:8080/message" }, + { url: "http://169.254.169.254/latest" }, payload(), ); diff --git a/src/lib/openapi/routes/settings.ts b/src/lib/openapi/routes/settings.ts index 833bc2f4f..7b948794f 100644 --- a/src/lib/openapi/routes/settings.ts +++ b/src/lib/openapi/routes/settings.ts @@ -400,7 +400,7 @@ export const settingsPaths: NonNullable = { ...stdResponses, "422": { description: - "Body matched neither accepted shape, or `enabled` is true while the stored config has no server URL and topic; or the server URL is on a private network the operator has not listed in `NOTIFICATION_PRIVATE_ORIGINS` (`meta.errorCode` = `private_origin_not_approved`), or it is a loopback, link-local or metadata address that no grant can open (`private_origin_not_grantable`). Nothing was stored.", + "Body matched neither accepted shape, or `enabled` is true while the stored config has no server URL and topic; or the server URL is on a private network the operator has not listed in `NOTIFICATION_PRIVATE_ORIGINS` (`meta.errorCode` = `private_origin_not_approved`), or it is a link-local, metadata or unspecified address that no grant can open (`private_origin_not_grantable`). Nothing was stored.", content: { "application/json": { schema: errorEnvelope } }, }, }, @@ -516,7 +516,7 @@ export const settingsPaths: NonNullable = { ...stdResponses, "422": { description: - "Body matched neither accepted shape, or `enabled` is true while no webhook URL is stored; or the URL is on a private network the operator has not listed in `NOTIFICATION_PRIVATE_ORIGINS` (`meta.errorCode` = `private_origin_not_approved`), or it is a loopback, link-local or metadata address that no grant can open (`private_origin_not_grantable`). Nothing was stored.", + "Body matched neither accepted shape, or `enabled` is true while no webhook URL is stored; or the URL is on a private network the operator has not listed in `NOTIFICATION_PRIVATE_ORIGINS` (`meta.errorCode` = `private_origin_not_approved`), or it is a link-local, metadata or unspecified address that no grant can open (`private_origin_not_grantable`). Nothing was stored.", content: { "application/json": { schema: errorEnvelope } }, }, }, @@ -719,7 +719,7 @@ export const settingsPaths: NonNullable = { ...stdResponses, "422": { description: - "The saved server is on a private network the operator has not approved (`meta.errorCode` = `private_origin_not_approved`), or is a loopback, link-local or metadata address that no grant can open (`private_origin_not_grantable`). The resolved address is never echoed.", + "The saved server is on a private network the operator has not approved (`meta.errorCode` = `private_origin_not_approved`), or is a link-local, metadata or unspecified address that no grant can open (`private_origin_not_grantable`). The resolved address is never echoed.", content: { "application/json": { schema: errorEnvelope } }, }, }, @@ -765,7 +765,7 @@ export const settingsPaths: NonNullable = { ...stdResponses, "422": { description: - "The saved URL is on a private network the operator has not approved (`meta.errorCode` = `private_origin_not_approved`), or is a loopback, link-local or metadata address that no grant can open (`private_origin_not_grantable`). The resolved address is never echoed.", + "The saved URL is on a private network the operator has not approved (`meta.errorCode` = `private_origin_not_approved`), or is a link-local, metadata or unspecified address that no grant can open (`private_origin_not_grantable`). The resolved address is never echoed.", content: { "application/json": { schema: errorEnvelope } }, }, }, diff --git a/src/lib/private-origin-policy.ts b/src/lib/private-origin-policy.ts index 1f2c0d8c2..e51f4a2d4 100644 --- a/src/lib/private-origin-policy.ts +++ b/src/lib/private-origin-policy.ts @@ -20,9 +20,9 @@ import { export const PRIVATE_ORIGIN_NOT_APPROVED_CODE = "private_origin_not_approved" as const; /** - * The target is loopback, unspecified, link-local or the metadata range: no - * grant can ever open it, so telling the user to ask the operator would send - * them in a circle. On host networking the LAN address is the answer. + * The target is the unspecified address, link-local or the metadata range: + * no grant can ever open it, so telling the user to ask the operator would + * send them in a circle. */ export const PRIVATE_ORIGIN_NOT_GRANTABLE_CODE = "private_origin_not_grantable" as const; @@ -34,15 +34,14 @@ export type OriginReason = | typeof INVALID_ORIGIN; /** - * A hostname no grant may name: `localhost` / `*.localhost` (loopback by - * definition, RFC 6761) or a literal address outside the grantable ranges. - * DNS names are not judged here — a name that resolves into those ranges is - * dropped at dial time by the pinned dispatcher. + * A hostname no grant may name: a literal address outside the grantable + * ranges (unspecified, link-local, metadata). Loopback and `localhost` are + * grantable when listed exactly — a host-networking deployment lists them — + * and DNS names are not judged here: a name that resolves into a refused + * range is dropped at dial time by the pinned dispatcher. */ export function isNeverGrantableHost(hostname: string): boolean { - const lower = hostname.toLowerCase(); - if (lower === "localhost" || lower.endsWith(".localhost")) return true; - const literal = lower.replace(/^\[|\]$/g, ""); + const literal = hostname.toLowerCase().replace(/^\[|\]$/g, ""); return isIP(literal) !== 0 && !isOperatorGrantableIp(literal); } @@ -68,7 +67,7 @@ export function describeGrantRejection(entry: string): string { (url.protocol === "http:" || url.protocol === "https:") && isNeverGrantableHost(url.hostname) ) { - return "loopback, link-local and metadata addresses cannot be granted; on host networking list the LAN address of the relay instead"; + return "the unspecified address, link-local and metadata addresses cannot be granted; list the address the relay actually listens on"; } } catch { // fall through to the grammar sentence @@ -88,10 +87,11 @@ export interface OriginVerdict { * * Credentials, paths, queries, and fragments are deliberately forbidden: the * operator grants a complete canonical scheme/host/port trust unit, never a - * suffix, wildcard, capability URL, or prefix. A literal address in loopback, - * the unspecified range, link-local or the metadata range is refused too — + * suffix, wildcard, capability URL, or prefix. A literal address in the + * unspecified range, link-local or the metadata range is refused too — * `isOperatorGrantableIp` says why — and the pinned dispatcher applies the - * same floor when a listed DNS name resolves there. + * same floor when a listed DNS name resolves there. Loopback is grantable + * when listed exactly: an operator on host networking lists it on purpose. */ export function canonicalOrigin(value: string): string | null { try { @@ -100,11 +100,11 @@ export function canonicalOrigin(value: string): string | null { if (url.username || url.password) return null; if (url.pathname !== "/" || url.search || url.hash) return null; if (!url.hostname || url.hostname.includes("*")) return null; - // Loopback, unspecified, link-local and metadata literals, and - // `localhost` / `*.localhost`, are refused at parse time. Other - // reserved-looking names (`.local` mDNS, `.internal`, `.lan`) stay - // grantable: an explicitly listed origin is what the list is for, and - // the dial-time floor still drops a loopback or metadata answer. + // Unspecified, link-local and metadata literals are refused at parse + // time. Loopback, `localhost` and reserved-looking names (`.local` mDNS, + // `.internal`, `.lan`) stay grantable: an explicitly listed origin is + // what the list is for, and the dial-time floor still drops a metadata + // or link-local answer. if (isNeverGrantableHost(url.hostname)) return null; return url.origin; } catch { diff --git a/src/lib/safe-fetch-dispatcher.ts b/src/lib/safe-fetch-dispatcher.ts index 739ea1348..544278ce0 100644 --- a/src/lib/safe-fetch-dispatcher.ts +++ b/src/lib/safe-fetch-dispatcher.ts @@ -71,9 +71,10 @@ function pinnedLookupWithPolicy( return; } // The operator-approved policy is not "anything that parses": a listed - // name that resolves to loopback, link-local or the metadata range is - // refused here exactly as an unlisted one would be. The grant opens the - // operator's own network, never the container or the cloud host. + // name that resolves to link-local, the metadata range or the + // unspecified address is refused here exactly as an unlisted one would + // be. The grant opens the operator's own network (loopback included, on + // host networking), never the cloud host's metadata endpoint. const allowed = addresses.filter((address) => policy === "public" ? isPublicIp(address.address) @@ -176,8 +177,8 @@ export function getPinnedPublicDispatcher(): Agent { * the complete canonical scheme/host/port trust grant. Resolution is still * performed once inside Undici's connector and the vetted answer set is * pinned to the socket, so a later DNS answer cannot replace the destination. - * Loopback, unspecified, link-local and metadata answers are dropped - * regardless of the grant (`isOperatorGrantableIp`). + * Unspecified, link-local and metadata answers are dropped regardless of the + * grant (`isOperatorGrantableIp`). */ export function getPinnedOperatorApprovedDispatcher(): Agent { cachedOperatorApproved ??= createPinnedDispatcher("operator-approved"); diff --git a/src/lib/validations/__tests__/nightscout.test.ts b/src/lib/validations/__tests__/nightscout.test.ts index 190eaed04..b6bf2b9cd 100644 --- a/src/lib/validations/__tests__/nightscout.test.ts +++ b/src/lib/validations/__tests__/nightscout.test.ts @@ -121,11 +121,28 @@ describe("Nightscout exact-origin policy", () => { }); }); - it("names the entry and the reason when a loopback grant is listed, without its token (M2)", () => { + it("keeps a loopback Nightscout grant working exactly as before", () => { + // A host-networking self-host lists http://localhost:1337; the shared + // floor must not turn that into a failed sync on upgrade. + expect(parseNightscoutPrivateOrigins("http://localhost:1337")).toEqual( + new Set(["http://localhost:1337"]), + ); + expect( + evaluateNightscoutOrigin( + "http://localhost:1337", + parseNightscoutPrivateOrigins("http://localhost:1337"), + ), + ).toMatchObject({ allowed: true, privateOriginApproved: true }); + expect( + parseNightscoutPrivateOrigins("http://127.0.0.1:1337, http://[::1]:1337"), + ).toEqual(new Set(["http://127.0.0.1:1337", "http://[::1]:1337"])); + }); + + it("names the entry and the reason when a metadata grant is listed, without its token (M2)", () => { let caught: unknown; try { parseNightscoutPrivateOrigins( - "https://cgm.lan, http://localhost:1337/?token=SECRET123", + "https://cgm.lan, http://169.254.169.254:1337/?token=SECRET123", ); } catch (err) { caught = err; @@ -134,9 +151,9 @@ describe("Nightscout exact-origin policy", () => { const message = (caught as Error).message; expect((caught as Error).name).toBe("NightscoutOriginConfigError"); expect(message).toContain( - 'NIGHTSCOUT_PRIVATE_ORIGINS entry "http://localhost:1337/"', + 'NIGHTSCOUT_PRIVATE_ORIGINS entry "http://169.254.169.254:1337/"', ); - expect(message).toMatch(/loopback.*LAN address/); + expect(message).toMatch(/link-local.*cannot be granted/); expect(message).not.toContain("SECRET123"); expect(message).not.toContain("token="); }); diff --git a/src/lib/validations/nightscout.ts b/src/lib/validations/nightscout.ts index 4c21be4a9..cd7cbae26 100644 --- a/src/lib/validations/nightscout.ts +++ b/src/lib/validations/nightscout.ts @@ -17,9 +17,9 @@ export const NIGHTSCOUT_INVALID_ORIGIN_REASON = INVALID_ORIGIN; * Thrown when `NIGHTSCOUT_PRIVATE_ORIGINS` holds an entry that is not a * grant. The message names the entry (scheme and host only) and the reason, * so the sync ledger and the connect form show the operator what to edit - * instead of a bare "invalid entry". Loopback and `localhost` entries - * became invalid with the shared address floor (#947); a host-networking - * deployment lists the LAN address instead. + * instead of a bare "invalid entry". Loopback and `localhost` entries stay + * valid grants exactly as before #947; the shared floor refuses only the + * unspecified address, link-local and the metadata range. */ export class NightscoutOriginConfigError extends Error { constructor(redactedEntry: string, reason: string) { diff --git a/src/lib/validations/notifications.ts b/src/lib/validations/notifications.ts index 98d960e01..15cf9b873 100644 --- a/src/lib/validations/notifications.ts +++ b/src/lib/validations/notifications.ts @@ -234,14 +234,14 @@ export function isPublicIp(ip: string): boolean { * `NIGHTSCOUT_PRIVATE_ORIGINS`). * * A grant exists so a relay on the operator's own network can be reached, so - * RFC1918, CGNAT (a Tailscale address) and IPv6 unique-local stay grantable. - * Four ranges are refused even when the operator lists a name that resolves - * there, because no notification relay lives on them and each one is a - * different admin surface: loopback is the app itself inside the container - * (and on host networking, every admin port of the host), the unspecified - * address is a resolver misfire, link-local is the cloud-metadata endpoint. - * The transition formats (`::ffff:`, 6to4, NAT64) are unwrapped first so an - * IPv6 spelling of loopback does not slip past the IPv4 verdict. + * RFC1918, CGNAT (a Tailscale address), IPv6 unique-local and loopback stay + * grantable: an operator on host networking who lists an exact loopback + * origin with its port has made the same deliberate decision as one who + * lists a LAN origin. Two classes are refused even when the operator lists a + * name that resolves there, because no relay lives on them: the unspecified + * address is a resolver misfire, and link-local is the cloud-metadata + * endpoint. The transition formats (`::ffff:`, 6to4, NAT64) are unwrapped + * first so an IPv6 spelling of those does not slip past the IPv4 verdict. */ export function isOperatorGrantableIp(ip: string): boolean { if (!ip) return false; @@ -258,12 +258,14 @@ export function isOperatorGrantableIp(ip: string): boolean { const bytes = parseIpv6Bytes(lower); if (!bytes) return false; - // Unspecified, loopback and link-local IPv6. Unique-local (fc00::/7) - // deliberately stays out of this list: it is the IPv6 analogue of - // RFC1918 and exactly what an operator lists. + // Unspecified and link-local IPv6. Unique-local (fc00::/7) and loopback + // (::1) deliberately stay out of this list: they are what an operator + // on a private or host network lists. `::1` is answered before the + // embedded-IPv4 unwrap, which would otherwise read it as the + // IPv4-compatible spelling of 0.0.0.1 and refuse it as unspecified. if (bytesEqual(bytes, 0, Array(16).fill(0))) return false; if (bytesEqual(bytes, 0, Array(15).fill(0)) && bytes[15] === 1) { - return false; + return true; } if (bytes[0] === 0xfe && (bytes[1] & 0xc0) === 0x80) return false; @@ -275,10 +277,9 @@ export function isOperatorGrantableIp(ip: string): boolean { return false; } -/** 127/8, 0/8 and 169.254/16: the ranges no operator grant may open. */ +/** 0/8 and 169.254/16: the ranges no operator grant may open. */ function isNeverGrantableIpv4(ip: [number, number, number, number]): boolean { const [a, b] = ip; - if (a === 127) return true; if (a === 0) return true; if (a === 169 && b === 254) return true; return false;