diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 74e680555..5bac35ca7 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -12022,7 +12022,8 @@ paths: "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`). Nothing was stored. + `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. content: application/json: schema: @@ -12149,7 +12150,8 @@ paths: "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`). Nothing was stored. + `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. content: application/json: schema: @@ -12386,7 +12388,8 @@ paths: "401": *a1 "422": description: The saved server is on a private network the operator has not approved (`meta.errorCode` = - `private_origin_not_approved`). The resolved address is never echoed. + `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. content: application/json: schema: @@ -12436,7 +12439,8 @@ paths: "401": *a1 "422": description: The saved URL is on a private network the operator has not approved (`meta.errorCode` = - `private_origin_not_approved`). The resolved address is never echoed. + `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. content: application/json: schema: @@ -30099,8 +30103,8 @@ components: dashboard_layout_conflict, empty_file, health_score_config_conflict, insights_layout_conflict, invalid_base_updated_at, json_carries_no_intake_time, json_not_an_array, medication_layout_conflict, missing_required_columns, modules_conflict, mood_tag_layout_conflict, not_configured, - notification_prefs_conflict, oidc_only, private_origin_not_approved, rate_limited_self, too_many_rows, - unreadable_json, upstream_invalid_json, vapid_not_configured" + notification_prefs_conflict, oidc_only, private_origin_not_approved, private_origin_not_grantable, + rate_limited_self, too_many_rows, unreadable_json, upstream_invalid_json, vapid_not_configured" type: string additionalProperties: {} required: diff --git a/docs/self-hosting/notifications.md b/docs/self-hosting/notifications.md index 0dc298b51..74c00207c 100644 --- a/docs/self-hosting/notifications.md +++ b/docs/self-hosting/notifications.md @@ -254,14 +254,14 @@ lists it. Restart the app after changing it. What is refused where, so you know which lever to pull: -| Target | Without the grant | With the exact origin listed | -| ----------------------------------------------------------- | ----------------- | --------------------------------------- | -| Public name resolving to a public address | works | works, public pin | -| Public name with a private A record (LAN reverse proxy) | refused at send | works | -| `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 | +| Target | Without the grant | With the exact origin listed | +| ----------------------------------------------------------- | ----------------- | ---------------------------------------------- | +| Public name resolving to a public address | works | works, public pin | +| Public name with a private A record (LAN reverse proxy) | refused at send | works | +| `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`) | A private target that is not listed is refused when the card is saved (`422`, `meta.errorCode` = `private_origin_not_approved`, the message diff --git a/messages/de.json b/messages/de.json index fada64eae..f54c191dc 100644 --- a/messages/de.json +++ b/messages/de.json @@ -5991,6 +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", "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 6d9bc7c0e..3ac231118 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5991,6 +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", "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 ca0e7969b..a3ac9e070 100644 --- a/messages/es.json +++ b/messages/es.json @@ -5991,6 +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", "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 55052bcd3..b7ee3631f 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -5991,6 +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", "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 1ba139607..9f30da14a 100644 --- a/messages/it.json +++ b/messages/it.json @@ -5991,6 +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", "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 d8a2b6581..a67d3418f 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -5991,6 +5991,7 @@ "not_configured": "설정되지 않았어요", "url_not_public": "URL이 공개 엔드포인트가 아니에요", "private_origin_not_approved": "비공개 네트워크 대상이에요. 운영자가 NOTIFICATION_PRIVATE_ORIGINS에 이 출처를 등록해야 해요", + "private_origin_not_grantable": "이 주소는 루프백, 링크 로컬 또는 메타데이터 주소예요. 운영자 등록으로도 열 수 없으니 릴레이의 LAN 주소를 사용해 주세요", "url_invalid": "URL이 올바르지 않아요", "redirected": "엔드포인트가 리디렉션돼요 — URL을 확인해 주세요", "endpoint_not_found": "해당 URL에 엔드포인트가 없어요", diff --git a/messages/pl.json b/messages/pl.json index c4dfafe32..bfeba4653 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -5991,6 +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", "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/src/app/api/nightscout/connect/route.ts b/src/app/api/nightscout/connect/route.ts index 77bc5f383..a0d297e38 100644 --- a/src/app/api/nightscout/connect/route.ts +++ b/src/app/api/nightscout/connect/route.ts @@ -26,6 +26,7 @@ import { configuredNightscoutPrivateOrigins, evaluateNightscoutOrigin, nightscoutConnectSchema, + NightscoutOriginConfigError, } from "@/lib/validations/nightscout"; /** @@ -67,10 +68,22 @@ export const POST = apiHandler(async (request: NextRequest) => { } const { url, token } = result.data; - const policy = evaluateNightscoutOrigin( - url, - configuredNightscoutPrivateOrigins(), - ); + // An operator list that holds a non-grant is an operator problem, not a + // user one: say which entry and why, rather than a 500 with no words. + let privateOrigins: ReadonlySet; + try { + privateOrigins = configuredNightscoutPrivateOrigins(); + } catch (err) { + if (err instanceof NightscoutOriginConfigError) { + annotate({ + action: { name: "nightscout.connect" }, + meta: { refused: "operator_origin_list_invalid" }, + }); + return apiError(err.message, 422); + } + throw err; + } + const policy = evaluateNightscoutOrigin(url, privateOrigins); // SSRF floor at INPUT time, not merely as a side effect of the live probe // below. `fetchSgvEntries` does route through `safeFetch` with the same 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 7ad276f50..fa133af22 100644 --- a/src/app/api/settings/__tests__/private-origin-routes.test.ts +++ b/src/app/api/settings/__tests__/private-origin-routes.test.ts @@ -179,10 +179,10 @@ describe("PUT /api/settings/webhook — private targets", () => { expect(json.meta).toBeUndefined(); }); - it("refuses a loopback grant at save time: listing it does not open it", async () => { - // The parser drops a loopback or localhost grant with a warning, so the - // save is refused with the reason and the operator learns that the list - // is not the way to reach the container itself. + it("answers a loopback 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"; @@ -190,11 +190,16 @@ describe("PUT /api/settings/webhook — private targets", () => { 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", ]) { 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_approved" }); + expect(json.meta).toEqual({ errorCode: "private_origin_not_grantable" }); + expect(json.error).toMatch(/LAN address/); + expect(json.error).not.toContain("NOTIFICATION_PRIVATE_ORIGINS"); + expect(json.details.issues.length).toBeGreaterThan(0); } expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled(); }); @@ -298,6 +303,29 @@ describe("POST /api/settings/{webhook,ntfy}/test — the refusal names itself", }, ); + it.each([ + ["webhook", sendViaWebhookMock], + ["ntfy", sendViaNtfyMock], + ])( + "%s: forwards the not-grantable code with a message that does not say list it (M1)", + async (path, sender) => { + sender.mockResolvedValue({ + ok: false, + hardReject: false, + reason: `${path}_private_origin_refused`, + errorCode: "private_origin_not_grantable", + }); + + const response = await post(path); + + 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).not.toContain("NOTIFICATION_PRIVATE_ORIGINS"); + }, + ); + it.each([ ["webhook", sendViaWebhookMock], ["ntfy", sendViaNtfyMock], diff --git a/src/app/api/settings/ntfy/route.ts b/src/app/api/settings/ntfy/route.ts index 1fb7d455a..ab084384f 100644 --- a/src/app/api/settings/ntfy/route.ts +++ b/src/app/api/settings/ntfy/route.ts @@ -13,7 +13,8 @@ import { import { evaluateNotificationTarget, isAllowedNotificationTarget, - PRIVATE_ORIGIN_NOT_APPROVED, + PRIVATE_ORIGIN_NOT_APPROVED_CODE, + PRIVATE_ORIGIN_NOT_GRANTABLE_CODE, } from "@/lib/notifications/egress-policy"; import { encrypt, decrypt } from "@/lib/crypto"; import { NextRequest } from "next/server"; @@ -30,6 +31,8 @@ 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."; /** * Refusal for an enable attempt on a channel the operator switched off @@ -145,18 +148,26 @@ export const PUT = apiHandler(async (request: NextRequest) => { // adds the code and a message naming the operator's lever (#947). const issues = sanitiseZodIssues(parsed.error.issues); const candidate = (body as { serverUrl?: unknown } | null)?.serverUrl; + const reason = + typeof candidate === "string" + ? evaluateNotificationTarget(candidate).reasonCode + : null; if ( - typeof candidate === "string" && - evaluateNotificationTarget(candidate).reasonCode === - PRIVATE_ORIGIN_NOT_APPROVED + reason === PRIVATE_ORIGIN_NOT_APPROVED_CODE || + reason === PRIVATE_ORIGIN_NOT_GRANTABLE_CODE ) { annotate({ action: { name: "settings.ntfy.update" }, - meta: { refused: PRIVATE_ORIGIN_NOT_APPROVED }, - }); - return apiValidationError(PRIVATE_ORIGIN_REFUSAL, issues, 422, { - errorCode: PRIVATE_ORIGIN_NOT_APPROVED, + meta: { refused: reason }, }); + return apiValidationError( + reason === PRIVATE_ORIGIN_NOT_GRANTABLE_CODE + ? NOT_GRANTABLE_REFUSAL + : PRIVATE_ORIGIN_REFUSAL, + issues, + 422, + { errorCode: reason }, + ); } return apiValidationError("Invalid data", issues, 422); } diff --git a/src/app/api/settings/ntfy/test/route.ts b/src/app/api/settings/ntfy/test/route.ts index 3805f9f8f..ad1371206 100644 --- a/src/app/api/settings/ntfy/test/route.ts +++ b/src/app/api/settings/ntfy/test/route.ts @@ -51,7 +51,9 @@ export const POST = apiHandler(async () => { meta: { success: false, refused: result.errorCode }, }); return apiError( - "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.", + 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 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 4d4fba24e..c4f3ded09 100644 --- a/src/app/api/settings/webhook/route.ts +++ b/src/app/api/settings/webhook/route.ts @@ -13,7 +13,8 @@ import { import { evaluateNotificationTarget, isAllowedNotificationTarget, - PRIVATE_ORIGIN_NOT_APPROVED, + PRIVATE_ORIGIN_NOT_APPROVED_CODE, + PRIVATE_ORIGIN_NOT_GRANTABLE_CODE, } from "@/lib/notifications/egress-policy"; import { encrypt, decrypt } from "@/lib/crypto"; import { NextRequest } from "next/server"; @@ -32,6 +33,8 @@ 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."; /** * Generic-webhook channel config (v1.17.1). @@ -139,18 +142,26 @@ export const PUT = apiHandler(async (request: NextRequest) => { // adds the code and a message naming the operator's lever (#947). const issues = sanitiseZodIssues(parsed.error.issues); const candidate = (body as { url?: unknown } | null)?.url; + const reason = + typeof candidate === "string" + ? evaluateNotificationTarget(candidate).reasonCode + : null; if ( - typeof candidate === "string" && - evaluateNotificationTarget(candidate).reasonCode === - PRIVATE_ORIGIN_NOT_APPROVED + reason === PRIVATE_ORIGIN_NOT_APPROVED_CODE || + reason === PRIVATE_ORIGIN_NOT_GRANTABLE_CODE ) { annotate({ action: { name: "settings.webhook.update" }, - meta: { refused: PRIVATE_ORIGIN_NOT_APPROVED }, - }); - return apiValidationError(PRIVATE_ORIGIN_REFUSAL, issues, 422, { - errorCode: PRIVATE_ORIGIN_NOT_APPROVED, + meta: { refused: reason }, }); + return apiValidationError( + reason === PRIVATE_ORIGIN_NOT_GRANTABLE_CODE + ? NOT_GRANTABLE_REFUSAL + : PRIVATE_ORIGIN_REFUSAL, + issues, + 422, + { errorCode: reason }, + ); } return apiValidationError("Invalid data", issues, 422); } diff --git a/src/app/api/settings/webhook/test/route.ts b/src/app/api/settings/webhook/test/route.ts index 267345482..964c74141 100644 --- a/src/app/api/settings/webhook/test/route.ts +++ b/src/app/api/settings/webhook/test/route.ts @@ -50,7 +50,9 @@ export const POST = apiHandler(async () => { meta: { success: false, refused: result.errorCode }, }); return apiError( - "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.", + 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 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/components/settings/ntfy-card.tsx b/src/components/settings/ntfy-card.tsx index c1bbe9dfa..349cf3631 100644 --- a/src/components/settings/ntfy-card.tsx +++ b/src/components/settings/ntfy-card.tsx @@ -72,8 +72,20 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) { }), }); if (!res.ok) { - const json = await res.json(); - throw new Error(json.error || t("common.error")); + const json = (await res.json()) as { + error?: string; + meta?: { errorCode?: string }; + }; + // A coded refusal (the private-origin policy) translates like the + // test button's; anything else shows the server sentence. + const code = json.meta?.errorCode; + const key = code ? `settings.testConnection.errors.${code}` : null; + const translated = key ? t(key) : null; + throw new Error( + translated && translated !== key + ? translated + : json.error || t("common.error"), + ); } }, onSuccess: () => { diff --git a/src/components/settings/webhook-card.tsx b/src/components/settings/webhook-card.tsx index ebfe9f2bf..bc34ee243 100644 --- a/src/components/settings/webhook-card.tsx +++ b/src/components/settings/webhook-card.tsx @@ -72,8 +72,20 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) { }), }); if (!res.ok) { - const json = await res.json(); - throw new Error(json.error || t("common.error")); + const json = (await res.json()) as { + error?: string; + meta?: { errorCode?: string }; + }; + // A coded refusal (the private-origin policy) translates like the + // test button's; anything else shows the server sentence. + const code = json.meta?.errorCode; + const key = code ? `settings.testConnection.errors.${code}` : null; + const translated = key ? t(key) : null; + throw new Error( + translated && translated !== key + ? translated + : json.error || t("common.error"), + ); } }, onSuccess: () => { diff --git a/src/lib/__tests__/private-origin-policy.test.ts b/src/lib/__tests__/private-origin-policy.test.ts index 7cd57b174..3976d5116 100644 --- a/src/lib/__tests__/private-origin-policy.test.ts +++ b/src/lib/__tests__/private-origin-policy.test.ts @@ -16,9 +16,12 @@ import { describe, expect, it, vi } from "vitest"; import { canonicalOrigin, + describeGrantRejection, evaluateOriginGrant, parsePrivateOrigins, - PRIVATE_ORIGIN_NOT_APPROVED, + PRIVATE_ORIGIN_NOT_APPROVED_CODE, + PRIVATE_ORIGIN_NOT_GRANTABLE_CODE, + redactGrantEntry, } from "../private-origin-policy"; describe("canonicalOrigin", () => { @@ -113,7 +116,7 @@ describe("parsePrivateOrigins", () => { expect(onInvalid).not.toHaveBeenCalled(); }); - it("hands every malformed entry to the caller and keeps the valid ones", () => { + it("hands every malformed entry to the caller, with a reason, and keeps the valid ones", () => { const onInvalid = vi.fn(); expect( parsePrivateOrigins( @@ -126,6 +129,25 @@ describe("parsePrivateOrigins", () => { "http://127.0.0.1:8080", "not an origin", ]); + expect(onInvalid.mock.calls[1][1]).toMatch(/loopback.*cannot be granted/); + expect(onInvalid.mock.calls[0][1]).toMatch(/one exact http\(s\) origin/); + }); + + it("never hands a query, fragment or userinfo to the caller (M3)", () => { + const onInvalid = vi.fn(); + parsePrivateOrigins( + "http://10.0.0.4/message?token=AbC123, http://user:pw@gotify.lan/, https://ntfy.lan/topic#frag", + onInvalid, + ); + const seen = onInvalid.mock.calls.map(([entry]) => entry); + expect(seen).toEqual([ + "http://10.0.0.4/message", + "http://gotify.lan/", + "https://ntfy.lan/topic", + ]); + expect(JSON.stringify(onInvalid.mock.calls)).not.toMatch( + /AbC123|token=|user:pw|#frag/, + ); }); it("lets the caller turn a malformed entry into a throw", () => { @@ -160,7 +182,7 @@ describe("evaluateOriginGrant", () => { allowed: false, canonicalOrigin: origin, privateOriginApproved: false, - reasonCode: PRIVATE_ORIGIN_NOT_APPROVED, + reasonCode: PRIVATE_ORIGIN_NOT_APPROVED_CODE, }); }); @@ -178,7 +200,49 @@ describe("evaluateOriginGrant", () => { allowed: false, canonicalOrigin: "http://192.168.1.9:8080", privateOriginApproved: false, - reasonCode: PRIVATE_ORIGIN_NOT_APPROVED, + 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"], + ])( + "answers %s with the not-grantable code, not a request to list it (M1)", + (_label, origin) => { + expect(evaluateOriginGrant(origin, grants)).toEqual({ + allowed: false, + canonicalOrigin: origin, + privateOriginApproved: false, + reasonCode: PRIVATE_ORIGIN_NOT_GRANTABLE_CODE, + }); + }, + ); +}); + +describe("redactGrantEntry / describeGrantRejection", () => { + it("keeps scheme and host, drops userinfo, query and fragment", () => { + expect(redactGrantEntry("http://user:pw@gotify.lan/?token=x#y")).toBe( + "http://gotify.lan/", + ); + expect(redactGrantEntry("https://gotify.example.com")).toBe( + "https://gotify.example.com", + ); + expect(redactGrantEntry("not an origin?token=x")).toBe("not an origin"); + }); + + it("names the never-grantable class apart from a grammar error", () => { + expect(describeGrantRejection("http://localhost:1337")).toMatch( + /loopback.*LAN address/, + ); + 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 aa1446ab7..f557905bf 100644 --- a/src/lib/__tests__/safe-fetch-dispatcher.test.ts +++ b/src/lib/__tests__/safe-fetch-dispatcher.test.ts @@ -24,6 +24,21 @@ const EMBEDDED_PRIVATE_DNS_ANSWERS = [ ["RFC8215 local-use metadata", "64:ff9b:1:a9fe:0:a9fe::"], ] as const; +/** True when the policy marker the dispatcher stamps is anywhere in the chain. */ +function hasPrivateHostMarker(error: unknown): boolean { + if (!error || typeof error !== "object") return false; + const value = error as { + safeFetchKind?: unknown; + cause?: unknown; + errors?: unknown[]; + }; + return ( + value.safeFetchKind === "private_host" || + hasPrivateHostMarker(value.cause) || + (value.errors ?? []).some(hasPrivateHostMarker) + ); +} + function errorCodes(error: unknown): string[] { if (!error || typeof error !== "object") return []; const value = error as { @@ -306,6 +321,9 @@ describe("pinnedOperatorApprovedDispatcher", () => { expect(caught).toBeDefined(); expect(errorCodes(caught)).toContain("ENOTFOUND"); + // A real NXDOMAIN would also be ENOTFOUND; the policy marker is what + // proves the refusal came from the floor and not from the resolver. + expect(hasPrivateHostMarker(caught)).toBe(true); }, 5_000, ); diff --git a/src/lib/notifications/__tests__/egress-policy.test.ts b/src/lib/notifications/__tests__/egress-policy.test.ts index 50631967e..77e5585a2 100644 --- a/src/lib/notifications/__tests__/egress-policy.test.ts +++ b/src/lib/notifications/__tests__/egress-policy.test.ts @@ -75,6 +75,21 @@ describe("configuredNotificationPrivateOrigins", () => { 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/); + }); + + it("warns without echoing a query, fragment or userinfo (M3)", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + process.env.NOTIFICATION_PRIVATE_ORIGINS = + "http://10.0.0.4/message?token=AbC123, http://user:pw@gotify.lan/, https://ntfy.lan/topic#frag"; + + expect(configuredNotificationPrivateOrigins()).toEqual(new Set()); + + const lines = warn.mock.calls.map((call) => String(call[0])); + expect(lines).toHaveLength(3); + expect(lines[0]).toContain('"http://10.0.0.4/message"'); + expect(lines[1]).toContain('"http://gotify.lan/"'); + expect(lines.join("\n")).not.toMatch(/AbC123|token=|user:pw|#frag/); }); it("never widens on a wholly malformed value", () => { @@ -137,12 +152,25 @@ describe("evaluateNotificationTarget", () => { }); }); - it("keeps the raw-string floor: an octal loopback spelling is refused, not parsed", () => { + it("keeps the raw-string floor: an octal loopback spelling is refused as never grantable", () => { expect( evaluateNotificationTarget("http://0177.0.0.1/message"), ).toMatchObject({ allowed: false, - reasonCode: "private_origin_not_approved", + reasonCode: "private_origin_not_grantable", + }); + }); + + 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"], + ])("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({ + allowed: false, + reasonCode: "private_origin_not_grantable", }); }); diff --git a/src/lib/notifications/egress-policy.ts b/src/lib/notifications/egress-policy.ts index aab9a14f5..35fd67daa 100644 --- a/src/lib/notifications/egress-policy.ts +++ b/src/lib/notifications/egress-policy.ts @@ -3,7 +3,8 @@ import { INVALID_ORIGIN, originOfUrl, parsePrivateOrigins, - PRIVATE_ORIGIN_NOT_APPROVED, + PRIVATE_ORIGIN_NOT_APPROVED_CODE, + PRIVATE_ORIGIN_NOT_GRANTABLE_CODE, type OriginVerdict, } from "@/lib/private-origin-policy"; import { annotate, getEvent } from "@/lib/logging/context"; @@ -43,10 +44,11 @@ let cachedOrigins: ReadonlySet = new Set(); export function configuredNotificationPrivateOrigins(): ReadonlySet { const raw = process.env.NOTIFICATION_PRIVATE_ORIGINS; if (raw === cachedRaw) return cachedOrigins; - cachedOrigins = parsePrivateOrigins(raw, (entry) => { - console.warn( - `${ENV_NAME}: ignoring entry "${entry}" — a grant is one exact http(s) origin (scheme://host[:port]) on a private network; loopback, link-local and metadata addresses cannot be granted`, - ); + // The entry arrives reduced to scheme and host: this line goes to stdout, + // not through the wide-event redactors, and a malformed entry is where a + // pasted token or userinfo would sit. + cachedOrigins = parsePrivateOrigins(raw, (redactedEntry, reason) => { + console.warn(`${ENV_NAME}: ignoring entry "${redactedEntry}": ${reason}`); }); cachedRaw = raw; return cachedOrigins; @@ -85,7 +87,7 @@ export function isAllowedNotificationTarget(url: string): boolean { return evaluateNotificationTarget(url).allowed; } -export { PRIVATE_ORIGIN_NOT_APPROVED }; +export { PRIVATE_ORIGIN_NOT_APPROVED_CODE, PRIVATE_ORIGIN_NOT_GRANTABLE_CODE }; /** * Wide-event mark for a send that used the grant: one row per private diff --git a/src/lib/notifications/senders/__tests__/ntfy.test.ts b/src/lib/notifications/senders/__tests__/ntfy.test.ts index 770a6fdae..4e71b47f7 100644 --- a/src/lib/notifications/senders/__tests__/ntfy.test.ts +++ b/src/lib/notifications/senders/__tests__/ntfy.test.ts @@ -217,4 +217,19 @@ describe("sendViaNtfy", () => { }, ); }); + + it("reports the not-grantable code for a loopback target, without dialling (M1)", async () => { + const result = await sendViaNtfy( + { serverUrl: "http://localhost:8080", topic: "health" }, + payload(), + ); + + expect(safeFetchMock).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + ok: false, + hardReject: false, + reason: "ntfy_private_origin_refused", + errorCode: "private_origin_not_grantable", + }); + }); }); diff --git a/src/lib/notifications/senders/__tests__/webhook.test.ts b/src/lib/notifications/senders/__tests__/webhook.test.ts index 410ce3896..e9d8a561e 100644 --- a/src/lib/notifications/senders/__tests__/webhook.test.ts +++ b/src/lib/notifications/senders/__tests__/webhook.test.ts @@ -265,4 +265,19 @@ describe("sendViaWebhook", () => { expect(opts.operatorApprovedPrivateOrigin).toBe("http://10.0.0.9:8080"); }); }); + + it("reports the not-grantable code for a loopback target, without dialling (M1)", async () => { + const result = await sendViaWebhook( + { url: "http://127.0.0.1:8080/message" }, + payload(), + ); + + expect(safeFetchMock).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + ok: false, + hardReject: false, + reason: "webhook_private_origin_refused", + errorCode: "private_origin_not_grantable", + }); + }); }); diff --git a/src/lib/notifications/senders/ntfy.ts b/src/lib/notifications/senders/ntfy.ts index 7c601420c..1692112b8 100644 --- a/src/lib/notifications/senders/ntfy.ts +++ b/src/lib/notifications/senders/ntfy.ts @@ -13,8 +13,9 @@ import { isUrgentPayload } from "@/lib/notifications/types"; import { annotatePrivateOriginEgress, evaluateNotificationTarget, - PRIVATE_ORIGIN_NOT_APPROVED, + PRIVATE_ORIGIN_NOT_APPROVED_CODE, } from "@/lib/notifications/egress-policy"; +import type { OriginReason } from "@/lib/private-origin-policy"; /** * Send notification via ntfy (simple HTTP POST). @@ -31,6 +32,10 @@ export async function sendViaNtfy( ): Promise { const userId = payload.recipientUserId ?? payload.userId; const start = performance.now(); + // Set when the policy itself refuses, so the outcome can say which of the + // two refusals it was: an origin the operator could list, or one no grant + // can open. + let policyReason: OriginReason | null = null; try { const url = `${config.serverUrl.replace(/\/$/, "")}/${encodeURIComponent(config.topic)}`; @@ -74,6 +79,7 @@ export async function sendViaNtfy( // the settings routes and the webhook sender. const policy = evaluateNotificationTarget(url); if (!policy.allowed || !policy.canonicalOrigin) { + policyReason = policy.reasonCode; throw new SafeFetchError( "ntfy server refused by the notification origin policy", "private_host", @@ -156,7 +162,9 @@ export async function sendViaNtfy( hardReject: false, reason, message, - ...(policyRefused ? { errorCode: PRIVATE_ORIGIN_NOT_APPROVED } : {}), + ...(policyRefused + ? { errorCode: policyReason ?? PRIVATE_ORIGIN_NOT_APPROVED_CODE } + : {}), }; } } diff --git a/src/lib/notifications/senders/webhook.ts b/src/lib/notifications/senders/webhook.ts index 74483c9b0..c9d480a7e 100644 --- a/src/lib/notifications/senders/webhook.ts +++ b/src/lib/notifications/senders/webhook.ts @@ -12,8 +12,9 @@ import { stripHtml } from "@/lib/notifications/strip-html"; import { annotatePrivateOriginEgress, evaluateNotificationTarget, - PRIVATE_ORIGIN_NOT_APPROVED, + PRIVATE_ORIGIN_NOT_APPROVED_CODE, } from "@/lib/notifications/egress-policy"; +import type { OriginReason } from "@/lib/private-origin-policy"; /** * Send a notification via a generic outbound webhook (v1.17.1). @@ -45,6 +46,10 @@ export async function sendViaWebhook( ): Promise { const userId = payload.recipientUserId ?? payload.userId; const start = performance.now(); + // Set when the policy itself refuses, so the outcome can say which of the + // two refusals it was: an origin the operator could list, or one no grant + // can open. + let policyReason: OriginReason | null = null; try { const headers: Record = { "Content-Type": "application/json", @@ -75,6 +80,7 @@ export async function sendViaWebhook( const policy = evaluateNotificationTarget(config.url); if (!policy.allowed || !policy.canonicalOrigin) { + policyReason = policy.reasonCode; throw new SafeFetchError( "webhook target refused by the notification origin policy", "private_host", @@ -159,7 +165,9 @@ export async function sendViaWebhook( hardReject: false, reason, message, - ...(policyRefused ? { errorCode: PRIVATE_ORIGIN_NOT_APPROVED } : {}), + ...(policyRefused + ? { errorCode: policyReason ?? PRIVATE_ORIGIN_NOT_APPROVED_CODE } + : {}), }; } } diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts index 042a10e8e..0c00ef15b 100644 --- a/src/lib/openapi/error-codes.ts +++ b/src/lib/openapi/error-codes.ts @@ -334,6 +334,7 @@ export const ERROR_CODE_CATALOGUE: Readonly> = "notification_prefs_conflict", "oidc_only", "private_origin_not_approved", + "private_origin_not_grantable", "rate_limited_self", "too_many_rows", "unreadable_json", diff --git a/src/lib/openapi/routes/settings.ts b/src/lib/openapi/routes/settings.ts index f1e30a86a..833bc2f4f 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`). 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 loopback, link-local or metadata 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`). 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 loopback, link-local or metadata 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`). 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 loopback, link-local or metadata 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`). 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 loopback, link-local or metadata 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 df27715c6..1f2c0d8c2 100644 --- a/src/lib/private-origin-policy.ts +++ b/src/lib/private-origin-policy.ts @@ -17,12 +17,64 @@ import { * several origins. */ -export const PRIVATE_ORIGIN_NOT_APPROVED = +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. + */ +export const PRIVATE_ORIGIN_NOT_GRANTABLE_CODE = + "private_origin_not_grantable" as const; export const INVALID_ORIGIN = "invalid_origin" as const; export type OriginReason = - typeof PRIVATE_ORIGIN_NOT_APPROVED | typeof INVALID_ORIGIN; + | typeof PRIVATE_ORIGIN_NOT_APPROVED_CODE + | typeof PRIVATE_ORIGIN_NOT_GRANTABLE_CODE + | 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. + */ +export function isNeverGrantableHost(hostname: string): boolean { + const lower = hostname.toLowerCase(); + if (lower === "localhost" || lower.endsWith(".localhost")) return true; + const literal = lower.replace(/^\[|\]$/g, ""); + return isIP(literal) !== 0 && !isOperatorGrantableIp(literal); +} + +/** + * The entry as it may be logged or put in an error message: scheme and host + * only. A malformed entry is malformed precisely when it carries the shapes + * that hold a secret — a query with a token, userinfo, a capability URL + * pasted whole — and this text goes to stdout, not through the wide-event + * redactors. + */ +export function redactGrantEntry(entry: string): string { + return entry + .split(/[?#]/, 1)[0] + .replace(/\/\/[^/@]*@/, "//") + .slice(0, 200); +} + +/** Why an entry is not a grant, in operator-readable words. */ +export function describeGrantRejection(entry: string): string { + try { + const url = new URL(entry); + if ( + (url.protocol === "http:" || url.protocol === "https:") && + isNeverGrantableHost(url.hostname) + ) { + return "loopback, link-local and metadata addresses cannot be granted; on host networking list the LAN address of the relay instead"; + } + } catch { + // fall through to the grammar sentence + } + return "a grant is one exact http(s) origin (scheme://host[:port]) with no path, query, credentials or wildcard"; +} export interface OriginVerdict { allowed: boolean; @@ -48,17 +100,12 @@ 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; - // `localhost` and `*.localhost` are loopback by definition (RFC 6761); - // refuse them at parse time like a literal loopback address. Other + // 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. - const hostname = url.hostname.toLowerCase(); - if (hostname === "localhost" || hostname.endsWith(".localhost")) { - return null; - } - const literal = hostname.replace(/^\[|\]$/g, ""); - if (isIP(literal) !== 0 && !isOperatorGrantableIp(literal)) return null; + if (isNeverGrantableHost(url.hostname)) return null; return url.origin; } catch { return null; @@ -102,15 +149,16 @@ function sharesApprovedPrivateHostname( /** * Parse a server-only comma-separated exact-origin allowlist. * - * Every malformed non-empty entry is handed to `onInvalid`, which decides the - * failure mode: the Nightscout list throws (a connection form can afford to + * Every malformed non-empty entry is handed to `onInvalid` with the entry + * already reduced to scheme and host (`redactGrantEntry`) and the reason in + * words (`describeGrantRejection`); the callback decides the failure mode: the Nightscout list throws (a connection form can afford to * fail loudly), the notification list logs once and skips the entry (a typo * must not take every public webhook down with it). Either way a malformed * entry grants nothing — the set only ever shrinks. */ export function parsePrivateOrigins( raw: string | undefined, - onInvalid: (entry: string) => void, + onInvalid: (redactedEntry: string, reason: string) => void, ): ReadonlySet { const origins = new Set(); for (const entry of (raw ?? "").split(",")) { @@ -118,7 +166,7 @@ export function parsePrivateOrigins( if (!trimmed) continue; const origin = canonicalOrigin(trimmed); if (!origin) { - onInvalid(trimmed); + onInvalid(redactGrantEntry(trimmed), describeGrantRejection(trimmed)); continue; } origins.add(origin); @@ -158,7 +206,7 @@ export function evaluateOriginGrant( allowed: false, canonicalOrigin: origin, privateOriginApproved: false, - reasonCode: PRIVATE_ORIGIN_NOT_APPROVED, + reasonCode: PRIVATE_ORIGIN_NOT_APPROVED_CODE, }; } @@ -171,10 +219,14 @@ export function evaluateOriginGrant( }; } + // Two refusals that read differently to the person who gets them: a + // private address the operator could list, and one no grant can open. return { allowed: false, canonicalOrigin: origin, privateOriginApproved: false, - reasonCode: PRIVATE_ORIGIN_NOT_APPROVED, + reasonCode: isNeverGrantableHost(new URL(origin).hostname) + ? PRIVATE_ORIGIN_NOT_GRANTABLE_CODE + : PRIVATE_ORIGIN_NOT_APPROVED_CODE, }; } diff --git a/src/lib/validations/__tests__/nightscout.test.ts b/src/lib/validations/__tests__/nightscout.test.ts index 6cc5127b8..190eaed04 100644 --- a/src/lib/validations/__tests__/nightscout.test.ts +++ b/src/lib/validations/__tests__/nightscout.test.ts @@ -120,4 +120,24 @@ describe("Nightscout exact-origin policy", () => { reasonCode: "invalid_origin", }); }); + + it("names the entry and the reason when a loopback grant is listed, without its token (M2)", () => { + let caught: unknown; + try { + parseNightscoutPrivateOrigins( + "https://cgm.lan, http://localhost:1337/?token=SECRET123", + ); + } catch (err) { + caught = err; + } + expect(caught).toBeInstanceOf(Error); + const message = (caught as Error).message; + expect((caught as Error).name).toBe("NightscoutOriginConfigError"); + expect(message).toContain( + 'NIGHTSCOUT_PRIVATE_ORIGINS entry "http://localhost:1337/"', + ); + expect(message).toMatch(/loopback.*LAN address/); + 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 c7df58694..4c21be4a9 100644 --- a/src/lib/validations/nightscout.ts +++ b/src/lib/validations/nightscout.ts @@ -4,14 +4,32 @@ import { evaluateOriginGrant, INVALID_ORIGIN, parsePrivateOrigins, - PRIVATE_ORIGIN_NOT_APPROVED, + PRIVATE_ORIGIN_NOT_APPROVED_CODE, type OriginReason, type OriginVerdict, } from "@/lib/private-origin-policy"; -export const NIGHTSCOUT_PRIVATE_ORIGIN_REASON = PRIVATE_ORIGIN_NOT_APPROVED; +export const NIGHTSCOUT_PRIVATE_ORIGIN_REASON = + PRIVATE_ORIGIN_NOT_APPROVED_CODE; 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. + */ +export class NightscoutOriginConfigError extends Error { + constructor(redactedEntry: string, reason: string) { + super( + `Invalid NIGHTSCOUT_PRIVATE_ORIGINS entry "${redactedEntry}": ${reason}`, + ); + this.name = "NightscoutOriginConfigError"; + } +} + export type NightscoutOriginReason = OriginReason; export type NightscoutOriginVerdict = OriginVerdict; @@ -28,8 +46,8 @@ export type NightscoutOriginVerdict = OriginVerdict; export function parseNightscoutPrivateOrigins( raw: string | undefined, ): ReadonlySet { - return parsePrivateOrigins(raw, () => { - throw new Error("Invalid NIGHTSCOUT_PRIVATE_ORIGINS entry"); + return parsePrivateOrigins(raw, (redactedEntry, reason) => { + throw new NightscoutOriginConfigError(redactedEntry, reason); }); }