diff --git a/.env.example b/.env.example index 31cee47cc..82733aeda 100644 --- a/.env.example +++ b/.env.example @@ -92,6 +92,17 @@ API_TOKEN_HMAC_KEY="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32" # TELEGRAM_WEBHOOK_SECRET="" +# ----------------------------------------------------------------------------- +# Notification targets on a private network (optional) +# ----------------------------------------------------------------------------- +# 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. +# Link-local, metadata and unspecified addresses stay refused even when listed. +# See docs/self-hosting/notifications.md. +# NOTIFICATION_PRIVATE_ORIGINS="" + + # ----------------------------------------------------------------------------- # AI Provider (optional) # ----------------------------------------------------------------------------- diff --git a/.env.production.example b/.env.production.example index 27486de2b..0d5f7a2c5 100644 --- a/.env.production.example +++ b/.env.production.example @@ -71,6 +71,20 @@ API_TOKEN_HMAC_KEY="" # NIGHTSCOUT_PRIVATE_ORIGINS="" +# ----------------------------------------------------------------------------- +# Notification targets on a private network -- optional +# ----------------------------------------------------------------------------- +# Webhook (Gotify, Home Assistant, n8n, a Discord relay) and ntfy targets on +# your own network, comma-separated exact origins (scheme://host[:port], no +# 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; 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="" + + # ----------------------------------------------------------------------------- # WHOOP -- optional integration # ----------------------------------------------------------------------------- diff --git a/CHANGELOG.md b/CHANGELOG.md index 90c38799e..c8304e3b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,57 @@ # Changelog +## [1.38.17] — 2026-09-10 + +A webhook or ntfy target on your own network can be reached again, with the +operator's say-so, the test button says why a private target was refused, +and the second of two close doses can be logged from the card again. + +### Fixed + +- **A webhook or ntfy target on your own network can be reached again, with + the operator's say-so (#947).** Both senders resolve the target at send + time and refuse a private, loopback, link-local or CGNAT answer; that is + the DNS-rebinding defence from #217, and it stopped a Gotify behind a LAN + reverse proxy exactly as designed, with no way to allow it. + `NOTIFICATION_PRIVATE_ORIGINS` is that way: comma-separated exact origins + such as `https://gotify.example.com,http://ntfy.lan:8080`, read from the + server environment only. A listed origin is still resolved and pinned + inside the connector with redirects forbidden; link-local, the metadata + range and the unspecified address stay refused even when listed, while + loopback is listable for a relay on host networking; a malformed entry is + logged once, without its query or credentials, and grants nothing; nothing + not listed widens. Listing a name also refuses its sub-hosts and other + ports, public or not, because a private name cannot be told from a public + one at save time. The same grammar as `NIGHTSCOUT_PRIVATE_ORIGINS`, which + now shares the parser and the address floor; existing + `NIGHTSCOUT_PRIVATE_ORIGINS` grants, loopback ones on host networking + included, behave exactly as before. Reported by @sreeramachandramurthy. + +- **The test button says why a private target was refused.** Saving a + private address that is not listed is a 422 with + `meta.errorCode = private_origin_not_approved` and a message naming the + variable, and the test button answers the same code instead of a bare 500 + that sent the operator to the log. The save, the test and the scheduled + delivery take one decision, held by a structural test. A granted send + marks the wide event as `notification.egress.private_origin` with the + channel and origin. A link-local, metadata or unspecified target answers + `private_origin_not_grantable` instead, with a message saying no grant can + open it, since telling the user to ask the operator would only send them + in a circle. + +- **The second of two close doses can be logged from the card again.** On a + schedule whose neighbouring doses sit closer than twelve hours, 19:49 and + 23:59 say, or 08:00 and 12:00, taking the first dose made the card offer + tomorrow's first dose, and a skip pressed next landed on tomorrow. A + taken, skipped or auto-missed row counted as resolving every occurrence + within six hours of its anchor, a tolerance sized for the twice-daily + gap, so the first row swallowed its sibling and the next-due walk went + past it. A row now resolves exactly one occurrence, the one nearest its + anchor, within that tolerance; the compliance cycle on the card follows + the same rule, so the due slot and the cycle state cannot disagree. The + tolerance still covers a row whose anchor drifted from the canonical + instant, a pre-snap row or a DST shift, which is what it was for. + ## [1.38.16] — 2026-09-10 The API says what it refused and why, on every route, and the admin page diff --git a/docker-compose.yml b/docker-compose.yml index f09232067..e55a024af 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -205,6 +205,13 @@ x-healthlog-env: &healthlog_env # validator should accept, comma-separated (e.g. "http://nightscout.lan"). # Without it a LAN/private Nightscout URL is refused as an SSRF target. NIGHTSCOUT_PRIVATE_ORIGINS: "${NIGHTSCOUT_PRIVATE_ORIGINS:-}" + # 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. 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 # the hosted service; point them at your own Open-Meteo deployment to keep # the coordinate lookups off a third party. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index af9e3dc57..859b9d511 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.38.16 + version: 1.38.17 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -12021,7 +12021,9 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": description: Body matched neither accepted shape, or `enabled` is true while the stored config has no server URL and - topic. + 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: @@ -12146,7 +12148,10 @@ paths: "413": *a19 "415": *a20 "422": - description: Body matched neither accepted shape, or `enabled` is true while no webhook URL is stored. + 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 link-local, metadata or unspecified address that no grant can + open (`private_origin_not_grantable`). Nothing was stored. content: application/json: schema: @@ -12370,7 +12375,9 @@ paths: summary: Send a test message to the saved ntfy topic description: Publishes one message to the account's configured ntfy server and topic. Takes no body. Rate-limited 5 per 5 minutes per user. Refuses with 400 when no ntfy channel is saved or when the saved config is missing a server - URL or topic; a publish failure is a 500. + URL or topic; a publish failure is a 500. A server on a private network is refused with 422 and `meta.errorCode` + = `private_origin_not_approved` unless the operator listed its exact origin in `NOTIFICATION_PRIVATE_ORIGINS`; + the test and the scheduled delivery take the same decision. responses: "200": description: The provider accepted the message. Not proof it was delivered. @@ -12379,7 +12386,14 @@ paths: schema: $ref: "#/components/schemas/NtfyTestEnvelope" "401": *a1 - "422": *a4 + "422": + description: 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: + $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 /api/settings/telegram/test: post: @@ -12412,7 +12426,9 @@ paths: description: Posts one payload to the account's configured webhook URL. Takes no body. Rate-limited 5 per 5 minutes per user. Refuses with 400 when no webhook channel is saved or the saved config has no URL; a delivery failure is a 500. The URL was checked against the SSRF floor when it was saved, so a test cannot be used to reach an internal - host. + host. A URL on a private network is refused with 422 and `meta.errorCode` = `private_origin_not_approved` unless + the operator listed its exact origin in `NOTIFICATION_PRIVATE_ORIGINS`; the test and the scheduled delivery take + the same decision. responses: "200": description: The provider accepted the message. Not proof it was delivered. @@ -12421,7 +12437,14 @@ paths: schema: $ref: "#/components/schemas/WebhookTestEnvelope" "401": *a1 - "422": *a4 + "422": + description: 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: + $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 /api/settings/data: delete: @@ -25837,9 +25860,10 @@ components: required: - enabled description: "ntfy channel config. `serverUrl` must pass the SSRF floor — a URL resolving into a private range is - refused at input time. An EMPTY OR OMITTED `authToken` preserves the stored one rather than clearing it: the GET - never returns the secret, so a client round-tripping the form would otherwise wipe it on every unrelated save. - Sending a non-empty value replaces it. There is no way to clear a stored auth token through this endpoint." + refused at input time unless the operator listed its exact origin in `NOTIFICATION_PRIVATE_ORIGINS`. An EMPTY OR + OMITTED `authToken` preserves the stored one rather than clearing it: the GET never returns the secret, so a + client round-tripping the form would otherwise wipe it on every unrelated save. Sending a non-empty value + replaces it. There is no way to clear a stored auth token through this endpoint." TelegramSettingsPutRequest: anyOf: - $ref: "#/components/schemas/NotificationChannelEnabledRequest" @@ -25897,8 +25921,9 @@ components: - enabled description: "Generic-webhook channel config — one channel covering Gotify, Discord, Slack, a Matrix bridge, Home Assistant, or any relay accepting an inbound JSON POST. `url` must pass the SSRF floor at input time and is - re-checked at dispatch time. An EMPTY OR OMITTED `headerValue` preserves the stored one, for the same reason as - ntfy's `authToken`: the GET never returns it. There is no way to clear a stored header value through this + re-checked at dispatch time; a private origin passes both only when the operator listed it in + `NOTIFICATION_PRIVATE_ORIGINS`. An EMPTY OR OMITTED `headerValue` preserves the stored one, for the same reason + as ntfy's `authToken`: the GET never returns it. There is no way to clear a stored header value through this endpoint." DeleteAccountRequest: type: object @@ -30078,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/integrations/nightscout.md b/docs/integrations/nightscout.md index 37f32f340..f90b09f07 100644 --- a/docs/integrations/nightscout.md +++ b/docs/integrations/nightscout.md @@ -28,9 +28,16 @@ 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 +- 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. +The grammar is shared with `NOTIFICATION_PRIVATE_ORIGINS`, the same grant +for webhook and ntfy targets (`docs/self-hosting/notifications.md`). + Do not put a Nightscout API token, capability URL, health data, or a URL containing credentials in the allowlist. Restart the application after changing the environment. diff --git a/docs/self-hosting/notifications.md b/docs/self-hosting/notifications.md index 6f7e01ab1..a3dd06de1 100644 --- a/docs/self-hosting/notifications.md +++ b/docs/self-hosting/notifications.md @@ -180,10 +180,12 @@ HealthLog publishes to that topic. server if you self-host ntfy) and **Topic**, then save. If your ntfy server requires auth, an access token can be attached; the -server URL is validated as a public host and dialled through the egress -guard (`src/lib/notifications/senders/ntfy.ts`). Urgent events are sent at -ntfy's top priority (`5` / max) so they bypass batching and surface on the -lock screen. +server URL is validated at save time and dialled through the egress guard +(`src/lib/notifications/senders/ntfy.ts`). A self-hosted ntfy on your own +network needs the operator grant described under [Notification targets on +a private network](#notification-targets-on-a-private-network). Urgent +events are sent at ntfy's top priority (`5` / max) so they bypass batching +and surface on the lock screen. ## Webhook @@ -197,11 +199,78 @@ optionally, one custom header (e.g. an auth token). **header value**. 2. Save. A Test button fires a one-off POST so you can confirm routing. -The URL is validated as a public host and dialled through -`safeFetch({ requirePublicHost: true })` — the SSRF guard blocks private -and loopback ranges because the target is user-supplied -(`src/lib/notifications/senders/webhook.ts`). The webhook carries the -event's urgency flag so you can route urgent events differently. +The URL is validated at save time and dialled through `safeFetch` with +the connect-time resolver pin — the SSRF guard refuses a target that +resolves to a private or loopback address because the target is +user-supplied (`src/lib/notifications/senders/webhook.ts`). A Gotify or +Home Assistant on your own network needs the operator grant described in +the next section. The webhook carries the event's urgency flag so you can +route urgent events differently. + +## Notification targets on a private network + +Every webhook and ntfy target is resolved at send time and refused when +the answer is a private, loopback, link-local or CGNAT address. That is +the DNS-rebinding defence, and it applies to a public-looking name with a +LAN A record (a Gotify behind Nginx Proxy Manager with a DNS-01 +certificate) exactly as it applies to `http://192.168.1.20`. The test +button and the scheduled delivery take the same decision, so a test that +fails with "Private network target" means nothing would have arrived +either. + +The operator opens a target by listing its exact origin in the server +environment: + +```env +NOTIFICATION_PRIVATE_ORIGINS="https://gotify.example.com,http://ntfy.lan:8080" +``` + +Each entry is one `scheme://host[:port]`, the same grammar as +`NIGHTSCOUT_PRIVATE_ORIGINS`: + +- only `http` and `https`; scheme, host and effective port must all match + what the user saves (`https://gotify.example.com:8443` and + `https://gotify.example.com` are two different grants); +- no path, query, credentials, wildcard (`*.lan`) or CIDR (`10.0.0.0/8`) — + 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; +- 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. + +A listed origin is still dialled through the pinned resolver with +redirects forbidden, so a relay that answers with a redirect to another +host is refused. The variable has to be on the compose `environment:` +whitelist to reach the container; the published `docker-compose.yml` +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`, `::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; 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. ## Email (SMTP) diff --git a/messages/de.json b/messages/de.json index 33e7f6962..caa40fff9 100644 --- a/messages/de.json +++ b/messages/de.json @@ -5990,6 +5990,8 @@ "connection_failed": "Verbindung fehlgeschlagen", "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 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 d738b0f63..d58ec0a2c 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5990,6 +5990,8 @@ "connection_failed": "Connection failed", "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 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 927dac0ce..4a8802a5f 100644 --- a/messages/es.json +++ b/messages/es.json @@ -5990,6 +5990,8 @@ "connection_failed": "Falló la conexión", "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 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 f9dac3822..b30e60b9a 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -5990,6 +5990,8 @@ "connection_failed": "Échec de la connexion", "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 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 d1ac0f2bd..407352537 100644 --- a/messages/it.json +++ b/messages/it.json @@ -5990,6 +5990,8 @@ "connection_failed": "Connessione non riuscita", "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 è 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 d781c8569..2e364231e 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -5990,6 +5990,8 @@ "connection_failed": "연결에 실패했어요", "not_configured": "설정되지 않았어요", "url_not_public": "URL이 공개 엔드포인트가 아니에요", + "private_origin_not_approved": "비공개 네트워크 대상이에요. 운영자가 NOTIFICATION_PRIVATE_ORIGINS에 이 출처를 등록해야 해요", + "private_origin_not_grantable": "이 주소는 링크 로컬, 메타데이터 또는 미지정 주소예요. 운영자 등록으로도 열 수 없으니 릴레이가 실제로 수신하는 주소를 사용해 주세요", "url_invalid": "URL이 올바르지 않아요", "redirected": "엔드포인트가 리디렉션돼요 — URL을 확인해 주세요", "endpoint_not_found": "해당 URL에 엔드포인트가 없어요", diff --git a/messages/pl.json b/messages/pl.json index 460328651..c09076960 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -5990,6 +5990,8 @@ "connection_failed": "Połączenie nie powiodło się", "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 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/package.json b/package.json index 2177036ba..dd10a3656 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.38.16", + "version": "1.38.17", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index 58f9dd341..bce54c3fc 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.38.16"; + /* @sw-version-fallback */ "v1.38.17"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/scripts/env-manifest.json b/scripts/env-manifest.json index b1d95efa4..5c251bfe1 100644 --- a/scripts/env-manifest.json +++ b/scripts/env-manifest.json @@ -226,6 +226,17 @@ } ] }, + { + "name": "Notification targets on a private network", + "description": "Operator grant for webhook (Gotify, Home Assistant, n8n) and ntfy targets on the operator's own network. Unset, a target that resolves to a private address is refused at send time and the test button reports private_origin_not_approved. Optional; public targets never need it.", + "required": false, + "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\". Link-local, metadata and unspecified addresses stay refused even when listed; a malformed entry is logged once and grants nothing." + } + ] + }, { "name": "Off-host backups", "description": "Required ALL-OR-NONE: any of these without the rest disables backups silently.", 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 new file mode 100644 index 000000000..4bc8d5b77 --- /dev/null +++ b/src/app/api/settings/__tests__/private-origin-routes.test.ts @@ -0,0 +1,431 @@ +/** + * #947 — a webhook or ntfy target on a private network, at the four routes + * that meet it. + * + * The save routes evaluate the same policy the senders do, so a private + * target that is not listed in NOTIFICATION_PRIVATE_ORIGINS is a 422 that + * names the reason (`meta.errorCode = private_origin_not_approved`) instead + * of a generic "Invalid data", and a listed literal address saves although + * the plain public floor would refuse it. The test routes forward the + * sender's own refusal as a 422 with the same code instead of the bare 500 + * that made the reporter read the wide-event log to learn it was policy. + * + * The last block is the structural half: the test button and the dispatcher + * must reach the SAME sender function, so the decision cannot differ between + * the button that says "works" and the reminder that then does not arrive. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + notificationChannel: { + findUnique: vi.fn(), + updateMany: vi.fn(), + upsert: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/crypto", () => ({ + encrypt: vi.fn((value: string) => `encrypted:${value}`), + decrypt: vi.fn((value: string) => value.replace(/^encrypted:/, "")), +})); + +vi.mock("@/lib/app-settings", () => ({ + isChannelGloballyEnabled: vi.fn().mockResolvedValue(true), +})); + +const sendViaWebhookMock = vi.fn(); +const sendViaNtfyMock = vi.fn(); +vi.mock("@/lib/notifications/senders/webhook", () => ({ + sendViaWebhook: (...args: unknown[]) => sendViaWebhookMock(...args), +})); +vi.mock("@/lib/notifications/senders/ntfy", () => ({ + sendViaNtfy: (...args: unknown[]) => sendViaNtfyMock(...args), +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn().mockResolvedValue({ + allowed: true, + remaining: 4, + resetAt: Date.now() + 60_000, + }), + rateLimitHeaders: () => ({}), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { PUT as putWebhook } from "../webhook/route"; +import { PUT as putNtfy } from "../ntfy/route"; +import { POST as testWebhook } from "../webhook/test/route"; +import { POST as testNtfy } from "../ntfy/test/route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "testuser", role: "USER" as const }, +}; + +type Route = (request: Request) => Promise; + +function put(path: string, body: unknown): Promise { + const route = (path === "webhook" ? putWebhook : putNtfy) as Route; + return route( + new Request(`http://localhost/api/settings/${path}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }), + ); +} + +function post(path: string): Promise { + const route = (path === "webhook" ? testWebhook : testNtfy) as Route; + return route( + new Request(`http://localhost/api/settings/${path}/test`, { + method: "POST", + }), + ); +} + +const ORIGINAL_PRIVATE_ORIGINS = process.env.NOTIFICATION_PRIVATE_ORIGINS; + +beforeEach(() => { + vi.clearAllMocks(); + delete process.env.NOTIFICATION_PRIVATE_ORIGINS; + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue( + null as never, + ); + vi.mocked(prisma.notificationChannel.upsert).mockResolvedValue({} as never); +}); + +afterEach(() => { + if (ORIGINAL_PRIVATE_ORIGINS === undefined) { + delete process.env.NOTIFICATION_PRIVATE_ORIGINS; + } else { + process.env.NOTIFICATION_PRIVATE_ORIGINS = ORIGINAL_PRIVATE_ORIGINS; + } +}); + +describe("PUT /api/settings/webhook — private targets", () => { + it("refuses an unlisted private address with the reason, and stores nothing", async () => { + const response = await put("webhook", { + url: "http://10.0.0.5:8080/message", + enabled: true, + }); + + expect(response.status).toBe(422); + const json = await response.json(); + expect(json.data).toBeNull(); + expect(json.meta).toEqual({ errorCode: "private_origin_not_approved" }); + expect(json.error).toContain("NOTIFICATION_PRIVATE_ORIGINS"); + expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled(); + }); + + it("refuses a sub-host of a listed origin: a grant names one origin only", async () => { + process.env.NOTIFICATION_PRIVATE_ORIGINS = "https://gotify.example.com"; + + const response = await put("webhook", { + url: "https://push.gotify.example.com/message", + enabled: true, + }); + + expect(response.status).toBe(422); + const json = await response.json(); + expect(json.meta).toEqual({ errorCode: "private_origin_not_approved" }); + expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled(); + }); + + it("saves a listed private address the public floor alone would refuse", async () => { + process.env.NOTIFICATION_PRIVATE_ORIGINS = "http://10.0.0.5:8080"; + + const response = await put("webhook", { + url: "http://10.0.0.5:8080/message", + enabled: true, + }); + + expect(response.status).toBe(200); + expect(prisma.notificationChannel.upsert).toHaveBeenCalledTimes(1); + const call = vi.mocked(prisma.notificationChannel.upsert).mock.calls[0][0]; + expect(call.create.config).toBe( + 'encrypted:{"url":"http://10.0.0.5:8080/message"}', + ); + }); + + it("keeps the plain shape refusal for a body that is not a private-origin case", async () => { + const response = await put("webhook", { url: 42, enabled: true }); + + expect(response.status).toBe(422); + const json = await response.json(); + expect(json.error).toBe("Invalid data"); + expect(json.meta).toBeUndefined(); + }); + + 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://169.254.169.254, http://0.0.0.0:8080"; + + for (const url of [ + "http://169.254.169.254/latest", + "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(/no operator grant can open/); + expect(json.error).not.toContain("NOTIFICATION_PRIVATE_ORIGINS"); + expect(json.details.issues.length).toBeGreaterThan(0); + } + expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled(); + }); + + it("saves an mDNS name the input floor refuses, once the operator lists it", async () => { + process.env.NOTIFICATION_PRIVATE_ORIGINS = "http://gotify.local"; + + const refused = await put("webhook", { + url: "http://ntfy.local/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://gotify.local/message", + enabled: true, + }); + expect(saved.status).toBe(200); + expect(prisma.notificationChannel.upsert).toHaveBeenCalledTimes(1); + }); +}); + +describe("PUT /api/settings/ntfy — private servers", () => { + it("refuses an unlisted private server with the reason, and stores nothing", async () => { + const response = await put("ntfy", { + serverUrl: "http://192.168.1.20:8080", + topic: "health", + enabled: true, + }); + + expect(response.status).toBe(422); + const json = await response.json(); + expect(json.meta).toEqual({ errorCode: "private_origin_not_approved" }); + expect(json.error).toContain("NOTIFICATION_PRIVATE_ORIGINS"); + expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled(); + }); + + it("saves a listed private server", async () => { + process.env.NOTIFICATION_PRIVATE_ORIGINS = "http://192.168.1.20:8080"; + + const response = await put("ntfy", { + serverUrl: "http://192.168.1.20:8080", + topic: "health", + enabled: true, + }); + + expect(response.status).toBe(200); + expect(prisma.notificationChannel.upsert).toHaveBeenCalledTimes(1); + }); + + it("refuses a sibling port of a listed server", async () => { + process.env.NOTIFICATION_PRIVATE_ORIGINS = "http://192.168.1.20:8080"; + + const response = await put("ntfy", { + serverUrl: "http://192.168.1.20:9090", + topic: "health", + enabled: true, + }); + + expect(response.status).toBe(422); + const json = await response.json(); + expect(json.meta).toEqual({ errorCode: "private_origin_not_approved" }); + }); +}); + +describe("POST /api/settings/{webhook,ntfy}/test — the refusal names itself", () => { + beforeEach(() => { + vi.mocked(prisma.notificationChannel.findUnique).mockImplementation( + (async (args: { where: { userId_type: { type: string } } }) => + args.where.userId_type.type === "WEBHOOK" + ? { config: 'encrypted:{"url":"https://gotify.lan/message"}' } + : { + config: + 'encrypted:{"serverUrl":"https://ntfy.lan","topic":"health"}', + }) as never, + ); + }); + + it.each([ + ["webhook", sendViaWebhookMock], + ["ntfy", sendViaNtfyMock], + ])( + "%s: answers a private-origin refusal with 422 and the code, not a 500", + async (path, sender) => { + sender.mockResolvedValue({ + ok: false, + hardReject: false, + reason: `${path}_private_origin_refused`, + errorCode: "private_origin_not_approved", + }); + + const response = await post(path); + + expect(response.status).toBe(422); + const json = await response.json(); + expect(json.meta).toEqual({ errorCode: "private_origin_not_approved" }); + expect(json.error).toContain("NOTIFICATION_PRIVATE_ORIGINS"); + }, + ); + + 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(/no operator grant can open/); + expect(json.error).not.toContain("NOTIFICATION_PRIVATE_ORIGINS"); + }, + ); + + it.each([ + ["webhook", sendViaWebhookMock], + ["ntfy", sendViaNtfyMock], + ])("%s: a plain delivery failure is still a 500", async (path, sender) => { + sender.mockResolvedValue({ + ok: false, + hardReject: false, + reason: `${path}_503`, + statusCode: 503, + }); + + const response = await post(path); + + expect(response.status).toBe(500); + const json = await response.json(); + expect(json.meta).toBeUndefined(); + }); + + it.each([ + ["webhook", sendViaWebhookMock], + ["ntfy", sendViaNtfyMock], + ])("%s: a delivered test is a 200", async (path, sender) => { + sender.mockResolvedValue({ ok: true, statusCode: 200 }); + + const response = await post(path); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ + data: { sent: true }, + error: null, + }); + }); +}); + +describe("the test button and the dispatcher share one sender", () => { + const SRC = join(process.cwd(), "src"); + const read = (rel: string) => readFileSync(join(SRC, rel), "utf8"); + + it.each([ + [ + "webhook", + "app/api/settings/webhook/test/route.ts", + /import \{ sendViaWebhook \} from "@\/lib\/notifications\/senders\/webhook"/, + /sendViaWebhook\(config, payload\)/, + ], + [ + "ntfy", + "app/api/settings/ntfy/test/route.ts", + /import \{ sendViaNtfy \} from "@\/lib\/notifications\/senders\/ntfy"/, + /sendViaNtfy\(config, payload\)/, + ], + ])( + "%s: the route and the dispatcher import the same sender module", + (_channel, routeFile, importPattern, dispatcherCall) => { + expect(read(routeFile)).toMatch(importPattern); + const dispatcher = read("lib/notifications/dispatcher.ts"); + expect(dispatcher).toMatch(importPattern); + expect(dispatcher).toMatch(dispatcherCall); + }, + ); + + it("neither sender decides the private-origin verdict on its own", () => { + // The senders take the verdict from `evaluateNotificationTarget`, the + // function the save routes use too. No sender may carry a second policy. + for (const rel of [ + "lib/notifications/senders/webhook.ts", + "lib/notifications/senders/ntfy.ts", + ]) { + const src = read(rel); + expect(src).toMatch(/evaluateNotificationTarget\(/); + expect(src).not.toMatch(/process\.env\.NOTIFICATION_PRIVATE_ORIGINS/); + expect(src).not.toMatch(/isPublicUrl\(/); + } + for (const rel of [ + "app/api/settings/webhook/route.ts", + "app/api/settings/ntfy/route.ts", + ]) { + expect(read(rel)).toMatch(/isAllowedNotificationTarget/); + } + }); +}); diff --git a/src/app/api/settings/ntfy/route.ts b/src/app/api/settings/ntfy/route.ts index eb0f4b05a..0aba06dda 100644 --- a/src/app/api/settings/ntfy/route.ts +++ b/src/app/api/settings/ntfy/route.ts @@ -8,14 +8,32 @@ import { } from "@/lib/api-response"; import { notificationChannelEnabledSchema, - ntfySettingsSchema, + ntfySettingsSchemaWith, } from "@/lib/validations/notifications"; +import { + evaluateNotificationTarget, + isAllowedNotificationTarget, + 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"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { isChannelGloballyEnabled } from "@/lib/app-settings"; import { annotate } from "@/lib/logging/context"; +/** + * The save-time schema evaluates the same policy the sender does: the public + * floor, plus the operator's exact-origin grant (#947). A listed private + * server therefore saves; an unlisted one is refused here with the reason. + */ +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 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 * instance-wide (`AppSettings.ntfyGlobal`). A toggle that appeared to work @@ -125,12 +143,34 @@ export const PUT = apiHandler(async (request: NextRequest) => { } const parsed = ntfySettingsSchema.safeParse(body); - if (!parsed.success) - return apiValidationError( - "Invalid data", - sanitiseZodIssues(parsed.error.issues), - 422, - ); + if (!parsed.success) { + // Every shape refusal carries the issue list; the private-origin case + // 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 ( + reason === PRIVATE_ORIGIN_NOT_APPROVED_CODE || + reason === PRIVATE_ORIGIN_NOT_GRANTABLE_CODE + ) { + annotate({ + action: { name: "settings.ntfy.update" }, + 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); + } const { serverUrl, topic, authToken, enabled } = parsed.data; diff --git a/src/app/api/settings/ntfy/test/route.ts b/src/app/api/settings/ntfy/test/route.ts index 0d6fa08d9..b260f1643 100644 --- a/src/app/api/settings/ntfy/test/route.ts +++ b/src/app/api/settings/ntfy/test/route.ts @@ -42,6 +42,22 @@ export const POST = apiHandler(async () => { }); if (!result.ok) { + // A private-origin refusal is a policy decision, not a delivery fault: + // say so, with the code the card translates, instead of a bare 500 that + // sends the operator to the wide-event log to learn why (#947). + if (result.errorCode) { + annotate({ + action: { name: "settings.ntfy.test" }, + meta: { success: false, refused: result.errorCode }, + }); + return apiError( + result.errorCode === "private_origin_not_grantable" + ? "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 }, + ); + } return apiError("Failed to send test message", 500); } diff --git a/src/app/api/settings/webhook/route.ts b/src/app/api/settings/webhook/route.ts index 0b8c5082f..1f1b072b3 100644 --- a/src/app/api/settings/webhook/route.ts +++ b/src/app/api/settings/webhook/route.ts @@ -8,22 +8,45 @@ import { } from "@/lib/api-response"; import { notificationChannelEnabledSchema, - webhookSettingsSchema, + webhookSettingsSchemaWith, } from "@/lib/validations/notifications"; +import { + evaluateNotificationTarget, + isAllowedNotificationTarget, + 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"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; +/** + * The save-time schema evaluates the same policy the sender does: the public + * floor, plus the operator's exact-origin grant (#947). A listed private + * address therefore saves; an unlisted one is refused here with the reason, + * before anything is encrypted onto the row. + */ +const webhookSettingsSchema = webhookSettingsSchemaWith( + isAllowedNotificationTarget, +); + +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 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). * GET: current config (header value redacted — only presence flagged). * PUT: upsert config. * * Covers Gotify / Discord / Slack / Matrix-bridge / Home Assistant in one - * channel: the user supplies a public URL and an optional shared-secret - * header. SSRF is enforced at input time (`isPublicUrl` in the schema) and - * again at dispatch time (`safeFetch({ requirePublicHost: true })`). + * channel: the user supplies a URL and an optional shared-secret header. SSRF + * is enforced at input time (the schema's target predicate) and again at + * dispatch time (`safeFetch` with the connect-time pin); a private origin + * passes both only when the operator listed it in + * `NOTIFICATION_PRIVATE_ORIGINS`. */ export const GET = apiHandler(async () => { const { user } = await requireAuth(); @@ -114,12 +137,34 @@ export const PUT = apiHandler(async (request: NextRequest) => { } const parsed = webhookSettingsSchema.safeParse(body); - if (!parsed.success) - return apiValidationError( - "Invalid data", - sanitiseZodIssues(parsed.error.issues), - 422, - ); + if (!parsed.success) { + // Every shape refusal carries the issue list; the private-origin case + // 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 ( + reason === PRIVATE_ORIGIN_NOT_APPROVED_CODE || + reason === PRIVATE_ORIGIN_NOT_GRANTABLE_CODE + ) { + annotate({ + action: { name: "settings.webhook.update" }, + 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); + } const { url, headerName, headerValue, enabled } = parsed.data; diff --git a/src/app/api/settings/webhook/test/route.ts b/src/app/api/settings/webhook/test/route.ts index 96fce255b..cc6bfa6dc 100644 --- a/src/app/api/settings/webhook/test/route.ts +++ b/src/app/api/settings/webhook/test/route.ts @@ -41,6 +41,22 @@ export const POST = apiHandler(async () => { }); if (!result.ok) { + // A private-origin refusal is a policy decision, not a delivery fault: + // say so, with the code the card translates, instead of a bare 500 that + // sends the operator to the wide-event log to learn why (#947). + if (result.errorCode) { + annotate({ + action: { name: "settings.webhook.test" }, + meta: { success: false, refused: result.errorCode }, + }); + return apiError( + result.errorCode === "private_origin_not_grantable" + ? "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 }, + ); + } return apiError("Failed to send test message", 500); } 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 new file mode 100644 index 000000000..10826c41a --- /dev/null +++ b/src/lib/__tests__/private-origin-policy.test.ts @@ -0,0 +1,286 @@ +/** + * The exact-origin grant grammar shared by every operator allowlist that opens + * a private destination (`NIGHTSCOUT_PRIVATE_ORIGINS`, + * `NOTIFICATION_PRIVATE_ORIGINS`). + * + * Two properties are pinned here and nowhere else: + * + * 1. A grant names one canonical `scheme://host[:port]` and nothing wider — + * no path, credentials, wildcard, suffix, sibling port or sub-host. + * 2. A grant can never name loopback, the unspecified address, link-local or + * the cloud-metadata range. Those are refused at parse time when written + * as a literal, and the pinned dispatcher refuses them again when a + * listed DNS name resolves there (`safe-fetch-dispatcher.test.ts`). + */ +import { describe, expect, it, vi } from "vitest"; + +import { + canonicalOrigin, + describeGrantRejection, + evaluateOriginGrant, + parsePrivateOrigins, + PRIVATE_ORIGIN_NOT_APPROVED_CODE, + PRIVATE_ORIGIN_NOT_GRANTABLE_CODE, + redactGrantEntry, +} from "../private-origin-policy"; + +describe("canonicalOrigin", () => { + it("normalises case and drops a default port", () => { + expect(canonicalOrigin("HTTPS://Gotify.Example.COM:443")).toBe( + "https://gotify.example.com", + ); + expect(canonicalOrigin("http://ntfy.lan:8080")).toBe( + "http://ntfy.lan:8080", + ); + expect(canonicalOrigin("https://[fd00::4]:8443")).toBe( + "https://[fd00::4]:8443", + ); + }); + + it.each([ + ["credentials", "http://operator:secret@10.0.0.4:1337"], + ["non-root path", "https://gotify.example.com/message"], + ["query", "http://10.0.0.4:1337?token=secret"], + ["fragment", "http://10.0.0.4:1337#admin"], + ["wrong scheme", "ftp://10.0.0.4:1337"], + ["wildcard", "https://*.gotify.lan"], + ["suffix", ".gotify.lan"], + ["malformed", "not an origin"], + ])("refuses a %s as a grant", (_label, value) => { + expect(canonicalOrigin(value)).toBeNull(); + }); + + it.each([ + ["IPv4 unspecified", "http://0.0.0.0:8080"], + ["link-local / metadata", "http://169.254.169.254"], + ["IPv6 unspecified", "http://[::]:8080"], + ["IPv6 link-local", "http://[fe80::1]:8080"], + ["IPv4-mapped metadata", "http://[::ffff:169.254.169.254]"], + ])("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 + // metadata answer is still dropped at dial time. + expect(canonicalOrigin("http://gotify.local")).toBe("http://gotify.local"); + expect(canonicalOrigin("http://ntfy.internal:8080")).toBe( + "http://ntfy.internal:8080", + ); + }); + + it("keeps RFC1918, CGNAT and ULA grantable — that is the point of the list", () => { + expect(canonicalOrigin("http://10.0.0.4:1337")).toBe( + "http://10.0.0.4:1337", + ); + expect(canonicalOrigin("http://192.168.1.20")).toBe("http://192.168.1.20"); + expect(canonicalOrigin("http://172.16.0.9:8080")).toBe( + "http://172.16.0.9:8080", + ); + expect(canonicalOrigin("http://100.64.0.7")).toBe("http://100.64.0.7"); + expect(canonicalOrigin("http://[fd12:3456::1]:8080")).toBe( + "http://[fd12:3456::1]:8080", + ); + }); +}); + +describe("parsePrivateOrigins", () => { + it("parses a comma-separated setting into canonical exact origins", () => { + const onInvalid = vi.fn(); + expect( + parsePrivateOrigins( + " http://10.0.0.4:1337, HTTPS://GOTIFY.LAN:443 ,https://[fd00::4]:8443 ", + onInvalid, + ), + ).toEqual( + new Set([ + "http://10.0.0.4:1337", + "https://gotify.lan", + "https://[fd00::4]:8443", + ]), + ); + expect(onInvalid).not.toHaveBeenCalled(); + }); + + it("grants nothing for an empty setting", () => { + const onInvalid = vi.fn(); + expect(parsePrivateOrigins(undefined, onInvalid)).toEqual(new Set()); + expect(parsePrivateOrigins(" , ", onInvalid)).toEqual(new Set()); + expect(onInvalid).not.toHaveBeenCalled(); + }); + + it("hands every malformed entry to the caller, with a reason, and keeps the valid ones", () => { + const onInvalid = vi.fn(); + expect( + parsePrivateOrigins( + "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://169.254.169.254", + "not an origin", + ]); + expect(onInvalid.mock.calls[1][1]).toMatch(/link-local.*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", () => { + expect(() => + parsePrivateOrigins("https://gotify.lan, junk", (entry) => { + throw new Error(`bad entry: ${entry}`); + }), + ).toThrow("bad entry: junk"); + }); +}); + +describe("evaluateOriginGrant", () => { + const grants = new Set(["http://10.0.0.4:1337", "https://gotify.lan:8443"]); + + it("approves exactly the listed scheme, host and port", () => { + expect(evaluateOriginGrant("http://10.0.0.4:1337", grants)).toEqual({ + allowed: true, + canonicalOrigin: "http://10.0.0.4:1337", + privateOriginApproved: true, + reasonCode: null, + }); + }); + + it.each([ + ["scheme", "https://10.0.0.4:1337"], + ["port", "http://10.0.0.4:8080"], + ["neighbouring address", "http://10.0.0.40:1337"], + ["sub-host", "https://sub.gotify.lan:8443"], + ["sibling port on a listed name", "https://gotify.lan"], + ])("refuses a %s that differs from the grant", (_label, origin) => { + expect(evaluateOriginGrant(origin, grants)).toEqual({ + allowed: false, + canonicalOrigin: origin, + privateOriginApproved: false, + reasonCode: PRIVATE_ORIGIN_NOT_APPROVED_CODE, + }); + }); + + it("keeps a public origin functional without any grant", () => { + expect(evaluateOriginGrant("https://ntfy.sh", new Set())).toEqual({ + allowed: true, + canonicalOrigin: "https://ntfy.sh", + privateOriginApproved: false, + reasonCode: null, + }); + }); + + it("refuses an unlisted private origin", () => { + expect(evaluateOriginGrant("http://192.168.1.9:8080", grants)).toEqual({ + allowed: false, + canonicalOrigin: "http://192.168.1.9:8080", + privateOriginApproved: false, + reasonCode: PRIVATE_ORIGIN_NOT_APPROVED_CODE, + }); + }); + + 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([ + ["link-local / metadata", "http://169.254.169.254"], + ["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) => { + 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://169.254.169.254")).toMatch( + /link-local.*cannot be granted/, + ); + expect(describeGrantRejection("http://[fe80::1]:1337")).toMatch( + /link-local/, + ); + 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 aa1d2d639..b9076b2f0 100644 --- a/src/lib/__tests__/safe-fetch-dispatcher.test.ts +++ b/src/lib/__tests__/safe-fetch-dispatcher.test.ts @@ -1,8 +1,14 @@ import { describe, it, expect, afterEach, vi } from "vitest"; import dns from "node:dns"; +import http from "node:http"; +import type { AddressInfo } from "node:net"; import { fetch as undiciFetch } from "undici"; -import { isPublicIp } from "@/lib/validations/notifications"; import { + isOperatorGrantableIp, + isPublicIp, +} from "@/lib/validations/notifications"; +import { + getPinnedOperatorApprovedDispatcher, getPinnedPublicDispatcher, _resetPinnedDispatcherForTests, } from "../safe-fetch-dispatcher"; @@ -18,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 { @@ -221,3 +242,144 @@ describe("pinnedPublicDispatcher", () => { 5_000, ); }); + +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); + expect(isOperatorGrantableIp("fd12:3456:789a::1")).toBe(true); + expect(isOperatorGrantableIp("::ffff:10.0.0.5")).toBe(true); + }); + + it("keeps public addresses grantable (a listed public origin is harmless)", () => { + expect(isOperatorGrantableIp("203.0.113.5")).toBe(true); + expect(isOperatorGrantableIp("2001:db8::1")).toBe(true); + }); + + it.each([ + ["IPv4 unspecified", "0.0.0.0"], + ["IPv4 link-local / metadata", "169.254.169.254"], + ["IPv6 unspecified", "::"], + ["IPv6 link-local", "fe80::1"], + ["IPv4-mapped metadata", "::ffff:169.254.169.254"], + ["IPv4-mapped unspecified", "::ffff:0.0.0.0"], + ["6to4 metadata", "2002:a9fe:a9fe::"], + ["NAT64 metadata", "64:ff9b::a9fe:a9fe"], + ["not an address", "gotify.lan"], + ["empty", ""], + ])("never grants %s, listed or not", (_label, ip) => { + expect(isOperatorGrantableIp(ip)).toBe(false); + }); +}); + +describe("pinnedOperatorApprovedDispatcher", () => { + afterEach(() => { + vi.restoreAllMocks(); + _resetPinnedDispatcherForTests(); + }); + + function resolveTo(addresses: dns.LookupAddress[]): void { + vi.spyOn(dns, "lookup").mockImplementation((( + _hostname: string, + _opts: dns.LookupAllOptions, + callback: ( + err: NodeJS.ErrnoException | null, + addrs: dns.LookupAddress[], + ) => void, + ) => { + callback(null, addresses); + }) as unknown as typeof dns.lookup); + } + + it.each([ + ["link-local / metadata", { address: "169.254.169.254", family: 4 }], + ["unspecified", { address: "0.0.0.0", family: 4 }], + ["IPv6 unspecified", { address: "::", family: 6 }], + ["IPv6 link-local", { address: "fe80::1", family: 6 }], + ["IPv4-mapped metadata", { address: "::ffff:169.254.169.254", family: 6 }], + ])( + "refuses a listed name that resolves to %s even under the approved policy", + async (_label, answer) => { + resolveTo([answer]); + + const dispatcher = getPinnedOperatorApprovedDispatcher(); + let caught: unknown; + try { + await undiciFetch("http://gotify-listed.example.test:8089/message", { + dispatcher, + signal: AbortSignal.timeout(750), + }); + } catch (error) { + caught = error; + } + + 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, + ); + + it("forwards an RFC1918 answer to the connector (the grant does its job)", async () => { + // 10.255.255.1 is private and, on this harness, unroutable: the pinned + // lookup must ACCEPT it (no ENOTFOUND) and the failure, if any, comes from + // the socket below. + resolveTo([{ address: "10.255.255.1", family: 4 }]); + + const dispatcher = getPinnedOperatorApprovedDispatcher(); + let caught: unknown; + try { + await undiciFetch("http://gotify-listed.example.test:8089/message", { + dispatcher, + signal: AbortSignal.timeout(500), + }); + } catch (error) { + caught = error; + } + + expect(errorCodes(caught)).not.toContain("ENOTFOUND"); + }, 20_000); + + 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; + res.writeHead(200); + res.end(); + }); + await new Promise((resolve) => + server.listen(0, "127.0.0.1", () => resolve()), + ); + const port = (server.address() as AddressInfo).port; + try { + resolveTo([ + { address: "169.254.169.254", family: 4 }, + { address: "127.0.0.1", family: 4 }, + ]); + + const dispatcher = getPinnedOperatorApprovedDispatcher(); + const res = await undiciFetch( + `http://gotify-listed.example.test:${port}/message`, + { dispatcher, signal: AbortSignal.timeout(5_000) }, + ); + + expect(res.status).toBe(200); + expect(requests).toBe(1); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }, 20_000); +}); diff --git a/src/lib/__tests__/ssrf-pin-inventory.test.ts b/src/lib/__tests__/ssrf-pin-inventory.test.ts index b5b7d67c5..21e4886db 100644 --- a/src/lib/__tests__/ssrf-pin-inventory.test.ts +++ b/src/lib/__tests__/ssrf-pin-inventory.test.ts @@ -58,14 +58,30 @@ describe("SSRF requirePublicHost pin inventory", () => { ); }); - // v1.11.2 product-lead M2 (optional) — lock the PRE-EXISTING user/operator - // webhook pins too, so the full inventory is CI-guarded and a future edit - // that drops one of these reds here rather than shipping silently. - it("ntfy sender pins the user/operator-supplied server outbound", () => { - expect(read("notifications/senders/ntfy.ts")).toMatch( - /requirePublicHost:\s*true/, - ); - }); + // v1.11.2 locked the ntfy pin as an unconditional `true`. Since #947 both + // user-supplied notification targets carry the same conditional shape as + // Nightscout: the public pin unless the operator listed the exact origin + // in NOTIFICATION_PRIVATE_ORIGINS, in which case the operator-approved pin + // takes over. Neither a bare `true` (the grant would be dead) nor a bare + // `false` (the pin would be gone) may reappear. + it.each([ + ["webhook", "notifications/senders/webhook.ts"], + ["ntfy", "notifications/senders/ntfy.ts"], + ])( + "%s sender routes an exact operator-approved private origin through the private pin", + (_channel, rel) => { + const src = read(rel); + expect(src).toMatch( + /requirePublicHost:\s*!policy\.privateOriginApproved/, + ); + expect(src).toMatch( + /operatorApprovedPrivateOrigin:\s*policy\.canonicalOrigin/, + ); + expect(src).not.toMatch(/requirePublicHost:\s*(?:true|false)\b/); + // The verdict must come from the shared policy, never a local check. + expect(src).toMatch(/evaluateNotificationTarget\(/); + }, + ); it("local AI client keeps the pin CONDITIONAL (LAN escape hatch)", () => { const src = read("ai/local-client.ts"); diff --git a/src/lib/analytics/compliance/__tests__/compliance.test.ts b/src/lib/analytics/compliance/__tests__/compliance.test.ts index 3dfd77106..e7bebd2d5 100644 --- a/src/lib/analytics/compliance/__tests__/compliance.test.ts +++ b/src/lib/analytics/compliance/__tests__/compliance.test.ts @@ -1673,6 +1673,69 @@ describe("buildComplianceDisplay — two rows, cadence-scaled windows", () => { expect(display.currentCycle.hasClosedCycles).toBe(true); }); + it("currentCycle — taking the first of two close doses leaves the second due", () => { + // 19:49 + 23:59 Berlin (CEST): 17:49Z + 21:59Z. The 19:49 take used to + // resolve the 23:59 slot too (flat ±6 h radius), so the open cycle + // pointed at tomorrow's 19:49 while tonight's dose was still due. + const schedules: ComplianceSchedule[] = [ + { + windowStart: "19:49", + windowEnd: "23:59", + daysOfWeek: null, + rollingIntervalDays: null, + timesOfDay: ["19:49", "23:59"], + }, + ]; + const slot = new Date("2025-06-15T17:49:00Z"); + const takenAt = new Date("2025-06-15T17:49:12Z"); + const events = [{ scheduledFor: slot, takenAt, skipped: false }]; + const display = buildComplianceDisplay( + events, + schedules, + ctx({ lastIntakeAt: takenAt, timeZone: "Europe/Berlin" }), + { now: new Date("2025-06-15T18:00:00Z") }, + ); + expect(display.currentCycle.state).toBe("on_track"); + expect(display.currentCycle.nextDueAt?.toISOString()).toBe( + "2025-06-15T21:59:00.000Z", + ); + }); + + it("currentCycle — a row on sibling schedule A's 08:00 leaves sibling B's 12:00 due", () => { + // Two schedule rows on one medication (08:00 daily plus 12:00 daily). + // Berlin (CEST): 06:00Z + 10:00Z. The row carries no schedule identity; + // B's slot must lose to A's nearer occurrence, not read resolved. + const schedules: ComplianceSchedule[] = [ + { + windowStart: "08:00", + windowEnd: "08:00", + daysOfWeek: null, + rollingIntervalDays: null, + timesOfDay: ["08:00"], + }, + { + windowStart: "12:00", + windowEnd: "12:00", + daysOfWeek: null, + rollingIntervalDays: null, + timesOfDay: ["12:00"], + }, + ]; + const slot = new Date("2025-06-15T06:00:00Z"); + const takenAt = new Date("2025-06-15T06:03:00Z"); + const events = [{ scheduledFor: slot, takenAt, skipped: false }]; + const display = buildComplianceDisplay( + events, + schedules, + ctx({ lastIntakeAt: takenAt, timeZone: "Europe/Berlin" }), + { now: new Date("2025-06-15T07:00:00Z") }, + ); + expect(display.currentCycle.state).toBe("on_track"); + expect(display.currentCycle.nextDueAt?.toISOString()).toBe( + "2025-06-15T10:00:00.000Z", + ); + }); + it("currentCycle — rolling weekly overdue past grace → missed", () => { // v1.13.x Fix 4 — last shot ~10 days ago (cadence 7) → next due was 3 // days ago, well past grace → the open cycle is `missed` (the only red diff --git a/src/lib/analytics/compliance/cycle.ts b/src/lib/analytics/compliance/cycle.ts index f80a9f2c2..1ed582699 100644 --- a/src/lib/analytics/compliance/cycle.ts +++ b/src/lib/analytics/compliance/cycle.ts @@ -3,8 +3,16 @@ import { nextOccurrenceAfter, + type CanonicalSchedule, type Occurrence, + type RecurrenceContext, } from "@/lib/medications/scheduling/recurrence"; +import { + ADHOC_RESOLVE_EPSILON_MS, + anchorResolvesOccurrence, + RESOLVE_RADIUS_MS, + type NearestOccurrenceCache, +} from "@/lib/medications/scheduling/slot-resolution"; import { lastNonSkippedTakenAt, type ComplianceIntakeInstant, @@ -64,40 +72,46 @@ export interface CurrentCycle { * selection: a window holding zero expected (closed) doses means the * percentage rows are vacuous and the card should show a neutral state. */ -/** - * v1.15.10 — half-window an intake may sit from a slot's canonical instant - * and still count as "resolving" that slot. The intake write paths snap - * `scheduledFor` to the exact engine slot instant, so an exact (or - * sub-minute) match is the common case; the ±6h tolerance also catches an - * off-time take on a dense intraday cadence (e.g. a 07:00 dose logged 09:13) - * whose snapped slot is the 07:00 row but whose raw instant we compare - * defensively. It is the same ±half-gap radius the cadence pairer uses for a - * 12h-gap (twice-daily) med, floored so a single-dose-a-day cadence still - * matches its one slot. - */ -const OPEN_DOSE_RESOLVE_RADIUS_MS = 6 * 60 * 60 * 1000; - /** * v1.15.10 — true when an intake event has already resolved the slot at * `slotAt`. A resolved slot is one the user took, deliberately skipped, or * the auto-miss cron flagged — any of which means the card must NOT keep - * surfacing that slot as the next dose. Matches on the snapped `scheduledFor` - * first (the canonical, exact path) and falls back to the take/skip instant - * within the resolve radius for rows that predate the snap or drifted. + * surfacing that slot as the next dose. Anchors on the snapped + * `scheduledFor` (the canonical, exact path), falling back to the take + * instant for a legacy row without one. A drifted anchor resolves its + * nearest occurrence only, within the shared drift radius, so a sibling + * slot closer than that radius is never swallowed; an ad-hoc row + * (`scheduledFor === takenAt`) resolves only the slot it sits on. Same rule + * as the list's next-due (`slot-resolution.ts`), so the cycle descriptor + * and the card's due slot cannot disagree. */ function slotIsResolved( slotAt: Date, intakes: ComplianceIntakeInstant[], + schedule: CanonicalSchedule, + schedules: CanonicalSchedule[], + ctx: RecurrenceContext, + cache: NearestOccurrenceCache, ): boolean { - const slot = slotAt.getTime(); for (const e of intakes) { const isResolved = e.skipped || e.autoMissed === true || e.takenAt !== null; if (!isResolved) continue; const ref = (e.scheduledFor ?? e.takenAt) as Date | undefined; if (!ref) continue; - if (Math.abs(ref.getTime() - slot) <= OPEN_DOSE_RESOLVE_RADIUS_MS) { - return true; - } + const adHoc = + e.scheduledFor !== undefined && + e.takenAt !== null && + e.scheduledFor.getTime() === e.takenAt.getTime(); + const resolves = anchorResolvesOccurrence({ + anchor: ref, + occurrenceAt: slotAt, + radiusMs: adHoc ? ADHOC_RESOLVE_EPSILON_MS : RESOLVE_RADIUS_MS, + schedule, + schedules, + ctx, + cache, + }); + if (resolves) return true; } return false; } @@ -134,9 +148,11 @@ export function buildCurrentCycle( } }; - for (let i = 0; i < schedules.length; i++) { - const s = schedules[i]; - const canonical = toCanonicalSchedule(s, `compliance-cycle-${i}`); + const canonicals = schedules.map((s, i) => + toCanonicalSchedule(s, `compliance-cycle-${i}`), + ); + const nearestCache: NearestOccurrenceCache = new Map(); + for (const canonical of canonicals) { if (canonical.scheduleType === "PRN") continue; const n = canonical.rollingIntervalDays; if (n !== null && n > 0) { @@ -162,7 +178,16 @@ export function buildCurrentCycle( for (let step = 0; step < 64; step++) { const occ = nextOccurrenceAfter(canonical, after, recurrenceCtx); if (!occ) break; - if (!slotIsResolved(occ.at, allIntakes)) { + if ( + !slotIsResolved( + occ.at, + allIntakes, + canonical, + canonicals, + recurrenceCtx, + nearestCache, + ) + ) { picked = occ; break; } diff --git a/src/lib/logging/event-builder.ts b/src/lib/logging/event-builder.ts index 13347ae3b..439d65b5b 100644 --- a/src/lib/logging/event-builder.ts +++ b/src/lib/logging/event-builder.ts @@ -150,6 +150,16 @@ export class WideEventBuilder { return this; } + /** + * Whether a caller has already named this event's action. A leaf that + * wants to mark the event (the private-origin egress mark in the + * notification senders) checks this first so it never overwrites the + * route's or job's own action name, which dashboards pin on. + */ + hasAction(): boolean { + return this.event.action !== undefined; + } + setBackground(bg: WideEvent["background"]): this { this.event.background = bg; return this; diff --git a/src/lib/medications/scheduling/__tests__/next-due.test.ts b/src/lib/medications/scheduling/__tests__/next-due.test.ts index 9ef1241fc..6a6fb298f 100644 --- a/src/lib/medications/scheduling/__tests__/next-due.test.ts +++ b/src/lib/medications/scheduling/__tests__/next-due.test.ts @@ -672,28 +672,275 @@ describe("Mounjaro rolling weekly — canonical overdue band after day change", scheduleId: "mounjaro-sibling", }); }); +}); - it("does not let an action from the archived era suppress the live-era occurrence", () => { - const liveEraStart = d("2026-07-27T12:00:00.000Z"); - const display = computeDisplayDue({ +/** + * A resolved row stands for exactly ONE occurrence: the one nearest to its + * anchor, and only within the drift radius. The former flat ±6 h radius + * reached the sibling slot on any cadence closer than twelve hours, so the + * first dose's row resolved the second occurrence too, the walk jumped to + * tomorrow, and the second dose could never be logged from the card. + * + * Berlin is UTC+2 in June / September: 19:49 + 23:59 local are 17:49Z + + * 21:59Z; 08:00 + 12:00 local are 06:00Z + 10:00Z. + */ +describe("resolved-slot marks — a row resolves only its nearest occurrence", () => { + const medication = makeMedication(); + function closeSlots(timesOfDay: string[]): WorkerScheduleRow { + return { + id: "sched-close", + windowStart: timesOfDay[0], + windowEnd: timesOfDay[timesOfDay.length - 1], + daysOfWeek: null, + timesOfDay, + reminderGraceMinutes: null, + rrule: null, + rollingIntervalDays: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + }; + } + + it("19:49 taken leaves 23:59 the same day due, not tomorrow's 19:49", () => { + const now = d("2026-09-10T18:00:00Z"); // 20:00 Berlin + const next = computeNextDueAt({ medication, - schedules: [schedule], - now: d("2026-07-29T10:00:00.000Z"), + schedules: [closeSlots(["19:49", "23:59"])], + now, userTz: BERLIN, - lastIntakeAt: lastTaken, - eraStart: liveEraStart, + lastIntakeAt: d("2026-09-10T17:49:12Z"), + resolvedSlots: [mark(d("2026-09-10T17:49:00Z"))], + }); + expect(next).not.toBeNull(); + expect(next!.toISOString()).toBe("2026-09-10T21:59:00.000Z"); + }); + + it("the card offers the 23:59 slot after the 19:49 take (display due)", () => { + const now = d("2026-09-10T18:00:00Z"); + const due = computeDisplayDue({ + medication, + schedules: [closeSlots(["19:49", "23:59"])], + now, + userTz: BERLIN, + lastIntakeAt: d("2026-09-10T17:49:12Z"), + resolvedSlots: [mark(d("2026-09-10T17:49:00Z"))], + }); + expect(due).not.toBeNull(); + expect(due!.overdue).toBe(false); + expect(due!.at.toISOString()).toBe("2026-09-10T21:59:00.000Z"); + }); + + it("08:00 taken, 12:00 pending → next due is 12:00", () => { + const now = d("2026-06-10T07:00:00Z"); // 09:00 Berlin + const next = computeNextDueAt({ + medication, + schedules: [closeSlots(["08:00", "12:00"])], + now, + userTz: BERLIN, + lastIntakeAt: d("2026-06-10T06:03:00Z"), + resolvedSlots: [mark(d("2026-06-10T06:00:00Z"))], + }); + expect(next).not.toBeNull(); + expect(next!.toISOString()).toBe("2026-06-10T10:00:00.000Z"); + }); + + it("every four hours: the first take leaves the second slot due", () => { + const now = d("2026-06-10T05:00:00Z"); // 07:00 Berlin + const next = computeNextDueAt({ + medication, + schedules: [ + closeSlots(["02:00", "06:00", "10:00", "14:00", "18:00", "22:00"]), + ], + now, + userTz: BERLIN, + lastIntakeAt: d("2026-06-10T04:02:00Z"), resolvedSlots: [ - identifiedMark(missedOccurrence, { - scheduleId: "mounjaro-weekly", - actionAt: d("2026-07-27T11:59:59.000Z"), - }), + mark(d("2026-06-10T00:00:00Z")), // 02:00 Berlin + mark(d("2026-06-10T04:00:00Z")), // 06:00 Berlin ], }); + expect(next).not.toBeNull(); + expect(next!.toISOString()).toBe("2026-06-10T08:00:00.000Z"); // 10:00 Berlin + }); - expect(display).toMatchObject({ - at: missedOccurrence, - overdue: true, - scheduleId: "mounjaro-weekly", + it("a drifted slot-anchored row still resolves its nearest occurrence", () => { + // The case the radius always protected: a row whose anchor sits off + // the canonical instant (a pre-snap row, or an instant that moved + // under it) resolves the occurrence it is nearest to. + const now = d("2026-06-10T08:00:00Z"); // 10:00 Berlin + const next = computeNextDueAt({ + medication, + schedules: [closeSlots(["08:00", "20:00"])], + now, + userTz: BERLIN, + lastIntakeAt: d("2026-06-10T07:13:00Z"), + // Anchored 09:13 Berlin, 1 h 13 min off the 08:00 slot. + resolvedSlots: [mark(d("2026-06-10T07:13:00Z"))], }); + expect(next).not.toBeNull(); + expect(next!.toISOString()).toBe("2026-06-10T18:00:00.000Z"); // 20:00 Berlin + }); + + it("a drifted row an hour off across a DST shift still resolves the slot", () => { + // 2026-03-29 Berlin springs forward at 02:00; a 08:00 row computed in + // the old offset sits at 07:00Z where the engine now says 06:00Z. + const now = d("2026-03-29T07:30:00Z"); // 09:30 Berlin (CEST) + const due = computeDisplayDue({ + medication, + schedules: [closeSlots(["08:00", "20:00"])], + now, + userTz: BERLIN, + lastIntakeAt: d("2026-03-29T07:00:00Z"), + resolvedSlots: [mark(d("2026-03-29T07:00:00Z"))], + }); + expect(due).not.toBeNull(); + expect(due!.overdue).toBe(false); + expect(due!.at.toISOString()).toBe("2026-03-29T18:00:00.000Z"); // 20:00 CEST + }); + + it("a drifted row nearer the sibling resolves the sibling, not both", () => { + // 08:00 + 12:00 Berlin. A skip anchored at 10:30 is nearer 12:00 than + // 08:00, so 08:00 stays open overdue at 09:30 (its band runs to 12:00). + const now = d("2026-06-10T07:30:00Z"); // 09:30 Berlin + const due = computeDisplayDue({ + medication, + schedules: [closeSlots(["08:00", "12:00"])], + now, + userTz: BERLIN, + lastIntakeAt: null, + resolvedSlots: [mark(d("2026-06-10T08:30:00Z"))], // 10:30 Berlin + }); + expect(due).not.toBeNull(); + expect(due!.overdue).toBe(true); + expect(due!.at.toISOString()).toBe("2026-06-10T06:00:00.000Z"); // 08:00 Berlin + }); + + it("an ad-hoc row between two close slots resolves neither", () => { + const adhoc = d("2026-06-10T08:30:00Z"); // 10:30 Berlin + const now = d("2026-06-10T09:00:00Z"); // 11:00 Berlin + const next = computeNextDueAt({ + medication, + schedules: [closeSlots(["08:00", "12:00"])], + now, + userTz: BERLIN, + lastIntakeAt: adhoc, + resolvedSlots: [ + mark(d("2026-06-10T06:00:00Z")), + toResolvedSlotMark({ scheduledFor: adhoc, takenAt: adhoc }), + ], + }); + expect(next).not.toBeNull(); + expect(next!.toISOString()).toBe("2026-06-10T10:00:00.000Z"); // 12:00 Berlin + }); + + it("an ad-hoc row seconds off a close slot still resolves that slot only", () => { + // Only the ad-hoc row is present: at 12:30 the 12:00 band is still open, + // so the slot reads resolved through the epsilon path alone. + const adhoc = d("2026-06-10T09:59:30Z"); // 11:59:30 Berlin + const now = d("2026-06-10T10:30:00Z"); // 12:30 Berlin + const due = computeDisplayDue({ + medication, + schedules: [closeSlots(["08:00", "12:00"])], + now, + userTz: BERLIN, + lastIntakeAt: adhoc, + resolvedSlots: [ + toResolvedSlotMark({ scheduledFor: adhoc, takenAt: adhoc }), + ], + }); + expect(due).not.toBeNull(); + expect(due!.overdue).toBe(false); + expect(due!.at.toISOString()).toBe("2026-06-11T06:00:00.000Z"); // tomorrow 08:00 + }); + + it("a row exactly midway between two slots resolves the earlier one", () => { + // 08:00 + 12:00 Berlin, a row anchored at 10:00: the tie goes to 08:00, + // so 12:00 stays due. + const now = d("2026-06-10T07:00:00Z"); // 09:00 Berlin + const next = computeNextDueAt({ + medication, + schedules: [closeSlots(["08:00", "12:00"])], + now, + userTz: BERLIN, + lastIntakeAt: d("2026-06-10T08:00:00Z"), + resolvedSlots: [mark(d("2026-06-10T08:00:00Z"))], // 10:00 Berlin + }); + expect(next).not.toBeNull(); + expect(next!.toISOString()).toBe("2026-06-10T10:00:00.000Z"); // 12:00 Berlin + }); + + it("a row on sibling schedule A's 08:00 does not resolve sibling B's 12:00", () => { + // Two schedule rows on one medication (08:00 daily plus 12:00 daily), the + // wizard's compose shape. Rows carry no schedule identity, so B's slot + // must lose to A's nearer occurrence, not be tested against B alone. + const a: WorkerScheduleRow = { ...closeSlots(["08:00"]), id: "sched-a" }; + const b: WorkerScheduleRow = { ...closeSlots(["12:00"]), id: "sched-b" }; + const now = d("2026-06-10T07:00:00Z"); // 09:00 Berlin + const input = { + medication, + schedules: [a, b], + now, + userTz: BERLIN, + lastIntakeAt: d("2026-06-10T06:00:00Z"), + resolvedSlots: [mark(d("2026-06-10T06:00:00Z"))], // A's 08:00 exactly + }; + expect(computeNextDueAt(input)?.toISOString()).toBe( + "2026-06-10T10:00:00.000Z", + ); + const due = computeDisplayDue(input); + expect(due).not.toBeNull(); + expect(due!.overdue).toBe(false); + expect(due!.at.toISOString()).toBe("2026-06-10T10:00:00.000Z"); + expect(due!.scheduleId).toBe("sched-b"); + }); + + it("on the 25-hour fall-back day a slot across the repeated hour stays due", () => { + // 2026-10-25 Berlin falls back at 03:00 CEST to 02:00 CET. 01:00 local + // is 2026-10-24T23:00Z (CEST); 03:00 local is 02:00Z (CET), three hours + // on. Taking the 01:00 dose must leave 03:00 due. + const now = d("2026-10-25T00:00:00Z"); + const next = computeNextDueAt({ + medication, + schedules: [closeSlots(["01:00", "03:00"])], + now, + userTz: BERLIN, + lastIntakeAt: d("2026-10-24T23:00:00Z"), + resolvedSlots: [mark(d("2026-10-24T23:00:00Z"))], + }); + expect(next).not.toBeNull(); + expect(next!.toISOString()).toBe("2026-10-25T02:00:00.000Z"); + }); + + it("on the fall-back day a row anchored in the old offset still resolves 08:00", () => { + // 08:00 CET on 2026-10-25 is 07:00Z; a row computed in the CEST offset + // sits at 06:00Z, an hour off, and still stands for that slot. + const now = d("2026-10-25T07:30:00Z"); // 08:30 CET + const due = computeDisplayDue({ + medication, + schedules: [closeSlots(["08:00", "20:00"])], + now, + userTz: BERLIN, + lastIntakeAt: d("2026-10-25T06:00:00Z"), + resolvedSlots: [mark(d("2026-10-25T06:00:00Z"))], + }); + expect(due).not.toBeNull(); + expect(due!.overdue).toBe(false); + expect(due!.at.toISOString()).toBe("2026-10-25T19:00:00.000Z"); // 20:00 CET + }); + + it("the overdue search agrees: 23:59 stays open overdue after midnight", () => { + const now = d("2026-09-10T22:30:00Z"); // 00:30 Berlin, next day + const due = computeDisplayDue({ + medication, + schedules: [closeSlots(["19:49", "23:59"])], + now, + userTz: BERLIN, + lastIntakeAt: d("2026-09-10T17:49:12Z"), + resolvedSlots: [mark(d("2026-09-10T17:49:00Z"))], + }); + expect(due).not.toBeNull(); + expect(due!.overdue).toBe(true); + expect(due!.at.toISOString()).toBe("2026-09-10T21:59:00.000Z"); }); }); diff --git a/src/lib/medications/scheduling/next-due.ts b/src/lib/medications/scheduling/next-due.ts index 562d60e11..dac18cbbc 100644 --- a/src/lib/medications/scheduling/next-due.ts +++ b/src/lib/medications/scheduling/next-due.ts @@ -19,27 +19,17 @@ import { import { advanceRollingOccurrence, nextOccurrenceAfter, + type CanonicalSchedule, + type RecurrenceContext, } from "@/lib/medications/scheduling/recurrence"; import { buildBandsForMedication } from "@/lib/medications/scheduling/band-minter"; import { DOSE_WINDOW_DEFAULTS } from "@/lib/medications/scheduling/dose-window-defaults"; - -/** - * v1.15.10 — radius an intake may sit from a slot's canonical instant and - * still count as resolving it. Intake writes snap `scheduledFor` to the exact - * engine slot instant, so the match is normally exact; the ±6h tolerance is - * the same defensive half-gap the compliance open-dose search uses (a 12h-gap - * twice-daily med splits cleanly at ±6h). - */ -const RESOLVE_RADIUS_MS = 6 * 60 * 60 * 1000; - -/** - * v1.16.9 — exact-match slop for an AD-HOC row. An ad-hoc take anchors - * `scheduledFor = takenAt` on its own instant, so it can only resolve a - * slot it actually sits on (sub-minute drift absorbed); letting it use - * the ±6h radius hid genuinely-due slots — a 14:30 ad-hoc take resolved - * tonight's 20:00 dose while the ledger still counted that slot missed. - */ -const ADHOC_RESOLVE_EPSILON_MS = 60 * 1000; +import { + ADHOC_RESOLVE_EPSILON_MS, + anchorResolvesOccurrence, + RESOLVE_RADIUS_MS, + type NearestOccurrenceCache, +} from "@/lib/medications/scheduling/slot-resolution"; /** * A row that resolves a slot (taken / deliberately skipped / @@ -47,17 +37,16 @@ const ADHOC_RESOLVE_EPSILON_MS = 60 * 1000; * * `slotAnchored: false` marks the ad-hoc shape (`scheduledFor === * takenAt`): such a row resolves a slot only on a near-exact anchor - * match, never across the ±6h radius. Slot-anchored rows (the write + * match, never across the drift radius. Slot-anchored rows (the write * paths snap their `scheduledFor` to the canonical slot instant) keep - * the defensive radius. + * the drift radius, and within it resolve their nearest occurrence only + * (`anchorResolvesOccurrence`). */ export interface ResolvedSlotMark { at: Date; slotAnchored: boolean; /** Canonical schedule owner when attribution could identify it. */ scheduleId?: string; - /** When the action was made; used to isolate replacement schedule eras. */ - actionAt?: Date; /** Diagnostic state carried by exact occurrence-aware callers. */ status?: "taken" | "skipped" | "autoMissed"; } @@ -80,27 +69,39 @@ export function toResolvedSlotMark(row: { }; } +/** + * Predicate over the canonical occurrences of ONE schedule: true when a + * resolved row stands for the occurrence at `slotAt`. Each row resolves + * its nearest occurrence only, measured across every schedule of the + * medication, so a sibling slot closer than the drift radius is never + * swallowed (see `slot-resolution.ts` for the rule). The era floor is + * enforced by the band range in the overdue search and by `now` in the + * forward walk, not here. + */ function buildIsResolved( resolved: ResolvedSlotMark[], - identity?: { scheduleId: string; eraStart?: Date | null }, + identity: { + schedule: CanonicalSchedule; + schedules: CanonicalSchedule[]; + ctx: RecurrenceContext; + }, ): (slotAt: Date) => boolean { + const cache: NearestOccurrenceCache = new Map(); return (slotAt: Date): boolean => { - const slot = slotAt.getTime(); for (const r of resolved) { - if (r.scheduleId !== undefined && r.scheduleId !== identity?.scheduleId) { + if (r.scheduleId !== undefined && r.scheduleId !== identity.schedule.id) { continue; } - if ( - identity?.eraStart && - r.actionAt && - r.actionAt.getTime() < identity.eraStart.getTime() - ) { - continue; - } - const radius = r.slotAnchored - ? RESOLVE_RADIUS_MS - : ADHOC_RESOLVE_EPSILON_MS; - if (Math.abs(r.at.getTime() - slot) <= radius) return true; + const resolves = anchorResolvesOccurrence({ + anchor: r.at, + occurrenceAt: slotAt, + radiusMs: r.slotAnchored ? RESOLVE_RADIUS_MS : ADHOC_RESOLVE_EPSILON_MS, + schedule: identity.schedule, + schedules: identity.schedules, + ctx: identity.ctx, + cache, + }); + if (resolves) return true; } return false; }; @@ -161,12 +162,13 @@ function computeNextDueCandidate(input: { if (schedules.length === 0) return null; const ctx = buildRecurrenceContext({ medication, userTz, lastIntakeAt }); + const canonicals = schedules.map(buildCanonicalSchedule); let earliest: DueCandidate | null = null; - for (const schedule of schedules) { - const canonical = buildCanonicalSchedule(schedule); + for (const canonical of canonicals) { const isResolved = buildIsResolved(input.resolvedSlots ?? [], { - scheduleId: canonical.id, - eraStart: input.eraStart, + schedule: canonical, + schedules: canonicals, + ctx, }); // Walk forward past slots the user has already resolved. Bounded so a // fully-logged-ahead history can't spin. @@ -272,19 +274,22 @@ function findOpenOverdueSlot( const ctx = buildRecurrenceContext({ medication, userTz, lastIntakeAt }); const intakeInstants = lastIntakeAt ? [lastIntakeAt] : []; + const canonicals = schedules.map(buildCanonicalSchedule); let latest: { at: Date; availableFrom: Date; scheduleId: string; } | null = null; for (const schedule of schedules) { + const canonical = buildCanonicalSchedule(schedule); const isResolved = buildIsResolved(input.resolvedSlots ?? [], { - scheduleId: schedule.id, - eraStart: input.eraStart, + schedule: canonical, + schedules: canonicals, + ctx, }); const { bands } = buildBandsForMedication({ medication, - schedule: buildCanonicalSchedule(schedule), + schedule: canonical, ctx, userTz, range: { from: floor, to: now }, diff --git a/src/lib/medications/scheduling/slot-resolution.ts b/src/lib/medications/scheduling/slot-resolution.ts new file mode 100644 index 000000000..f1e2c8dfd --- /dev/null +++ b/src/lib/medications/scheduling/slot-resolution.ts @@ -0,0 +1,141 @@ +/** + * Which occurrence a resolved intake row (taken / deliberately skipped / + * cron-auto-missed) stands for. + * + * The intake write paths snap a slot-anchored row's `scheduledFor` to the + * engine's canonical occurrence instant, so row and occurrence normally meet + * exactly. The tolerance beyond an exact match exists for a row whose anchor + * drifted off the canonical instant: a row written before the snap, or an + * instant that moved under it through a DST or schedule-time shift. From + * v1.15.10 that tolerance was a flat ±6 h radius, sized for the twice-daily + * 12 h gap. On any cadence whose neighbouring slots are closer than twelve + * hours (19:49 + 23:59, 08:00 + 12:00, every four hours) the radius reached + * the sibling: the first dose's row resolved the second occurrence too, the + * next-due walk jumped to tomorrow, the card offered tomorrow's slot, and the + * second dose could never be logged from the card. + * + * Rule: a row resolves exactly ONE occurrence, the one nearest to its anchor, + * and only when that occurrence lies within the row's radius. An exact match + * short-circuits; a drifted row is measured against every occurrence the + * schedule emits within the radius around the anchor, and resolves the slot + * under test only when no other occurrence is nearer (an exact tie goes to + * the earlier occurrence). So the effective reach is `min(radius, half the + * gap to the neighbouring occurrence)`, and a sibling can never be swallowed. + * The neighbours are every schedule's occurrences, not only the schedule + * under test: a medication composed of sibling schedules (08:00 daily plus + * 12:00 daily) is one cadence to the person taking it. Rolling cadences emit + * at most one occurrence per interval day, which is farther than any radius + * here, so the nearest test is skipped for them. + */ +import { + occurrencesBetween, + type CanonicalSchedule, + type RecurrenceContext, +} from "@/lib/medications/scheduling/recurrence"; + +/** + * Drift a slot-anchored row may sit from a slot's canonical instant and + * still count as resolving it, provided the slot is the row's nearest + * occurrence. + */ +export const RESOLVE_RADIUS_MS = 6 * 60 * 60 * 1000; + +/** + * v1.16.9 — exact-match slop for an AD-HOC row. An ad-hoc take anchors + * `scheduledFor = takenAt` on its own instant, so it can only resolve a + * slot it actually sits on (sub-minute drift absorbed); letting it use + * the drift radius hid genuinely-due slots — a 14:30 ad-hoc take resolved + * tonight's 20:00 dose while the ledger still counted that slot missed. + */ +export const ADHOC_RESOLVE_EPSILON_MS = 60 * 1000; + +/** + * Nearest occurrence to `anchor` within `radiusMs`, across EVERY schedule + * of the medication: `at` and its distance, or null when no schedule emits + * inside the window. On an exact tie the earlier occurrence wins. Memoised + * per anchor in `cache` when one is passed, because the set depends on the + * row alone and a predicate asks about many slots per row. + */ +function nearestOccurrenceWithin(input: { + anchor: number; + radiusMs: number; + schedules: CanonicalSchedule[]; + ctx: RecurrenceContext; + cache?: NearestOccurrenceCache; +}): { at: number; distance: number } | null { + const key = `${input.anchor}:${input.radiusMs}`; + const cached = input.cache?.get(key); + if (cached !== undefined) return cached; + let nearest: { at: number; distance: number } | null = null; + const from = new Date(input.anchor - input.radiusMs); + const to = new Date(input.anchor + input.radiusMs); + for (const schedule of input.schedules) { + for (const occurrence of occurrencesBetween( + schedule, + from, + to, + input.ctx, + )) { + const at = occurrence.at.getTime(); + const distance = Math.abs(input.anchor - at); + if ( + nearest === null || + distance < nearest.distance || + (distance === nearest.distance && at < nearest.at) + ) { + nearest = { at, distance }; + } + } + } + input.cache?.set(key, nearest); + return nearest; +} + +export type NearestOccurrenceCache = Map< + string, + { at: number; distance: number } | null +>; + +export function anchorResolvesOccurrence(input: { + /** The resolved row's anchor (`scheduledFor`, or the take instant). */ + anchor: Date; + /** The canonical occurrence instant under test. */ + occurrenceAt: Date; + /** Reach of this row: the drift radius, or the ad-hoc epsilon. */ + radiusMs: number; + /** The schedule that emitted `occurrenceAt`. */ + schedule: CanonicalSchedule; + /** + * Every schedule of the medication. The nearest test enumerates all of + * them: production rows carry no schedule identity, so a row on sibling + * A's 08:00 is also asked about sibling B's 12:00, and B's own + * occurrences alone would never show that A's 08:00 is nearer. + */ + schedules: CanonicalSchedule[]; + ctx: RecurrenceContext; + /** Optional per-predicate memo for the nearest-occurrence enumeration. */ + cache?: NearestOccurrenceCache; +}): boolean { + const anchor = input.anchor.getTime(); + const slot = input.occurrenceAt.getTime(); + const distance = Math.abs(anchor - slot); + if (distance === 0) return true; + if (distance > input.radiusMs) return false; + if (input.schedule.rollingIntervalDays !== null) return true; + + // Drifted: the row stands for its nearest occurrence only. Any occurrence + // strictly nearer than the slot under test, or equally near but earlier, + // claims the row instead. + const nearest = nearestOccurrenceWithin({ + anchor, + radiusMs: input.radiusMs, + schedules: input.schedules, + ctx: input.ctx, + cache: input.cache, + }); + if (nearest === null || nearest.at === slot) return true; + return !( + nearest.distance < distance || + (nearest.distance === distance && nearest.at < slot) + ); +} diff --git a/src/lib/nightscout/__tests__/client-pinned.test.ts b/src/lib/nightscout/__tests__/client-pinned.test.ts index c132bdaf7..51537bf32 100644 --- a/src/lib/nightscout/__tests__/client-pinned.test.ts +++ b/src/lib/nightscout/__tests__/client-pinned.test.ts @@ -5,7 +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. + * 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"; diff --git a/src/lib/notifications/__tests__/egress-policy.test.ts b/src/lib/notifications/__tests__/egress-policy.test.ts new file mode 100644 index 000000000..85607c116 --- /dev/null +++ b/src/lib/notifications/__tests__/egress-policy.test.ts @@ -0,0 +1,255 @@ +/** + * `NOTIFICATION_PRIVATE_ORIGINS` (#947): the operator grant for a webhook or + * ntfy target on a private network. + * + * What is pinned here: the variable is read from the server environment and + * nowhere else, a malformed entry is logged once and grants nothing, a listed + * origin approves exactly itself, and a target that is not listed keeps the + * public floor it always had. The dial-time behaviour of an approved origin + * (pinned resolver, redirect ban, loopback floor) is proven in + * `safe-fetch.test.ts` and `safe-fetch-dispatcher.test.ts`; the senders' + * use of the verdict in their own suites. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { eventStorage } from "@/lib/logging/context"; +import { WideEventBuilder } from "@/lib/logging/event-builder"; + +import { + annotatePrivateOriginEgress, + configuredNotificationPrivateOrigins, + evaluateNotificationTarget, + isAllowedNotificationTarget, +} from "../egress-policy"; + +const ORIGINAL = process.env.NOTIFICATION_PRIVATE_ORIGINS; + +beforeEach(() => { + delete process.env.NOTIFICATION_PRIVATE_ORIGINS; +}); + +afterEach(() => { + vi.restoreAllMocks(); + if (ORIGINAL === undefined) { + delete process.env.NOTIFICATION_PRIVATE_ORIGINS; + } else { + process.env.NOTIFICATION_PRIVATE_ORIGINS = ORIGINAL; + } +}); + +describe("configuredNotificationPrivateOrigins", () => { + it("grants nothing when the variable is unset or blank", () => { + expect(configuredNotificationPrivateOrigins()).toEqual(new Set()); + process.env.NOTIFICATION_PRIVATE_ORIGINS = " , "; + expect(configuredNotificationPrivateOrigins()).toEqual(new Set()); + }); + + it("parses exact origins and follows a changed value", () => { + process.env.NOTIFICATION_PRIVATE_ORIGINS = + "https://gotify.example.com, http://ntfy.lan:8080"; + expect(configuredNotificationPrivateOrigins()).toEqual( + new Set(["https://gotify.example.com", "http://ntfy.lan:8080"]), + ); + + process.env.NOTIFICATION_PRIVATE_ORIGINS = "http://10.0.0.5:8080"; + expect(configuredNotificationPrivateOrigins()).toEqual( + new Set(["http://10.0.0.5:8080"]), + ); + }); + + 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://169.254.169.254"; + + expect(configuredNotificationPrivateOrigins()).toEqual( + new Set(["https://gotify.lan"]), + ); + expect(configuredNotificationPrivateOrigins()).toEqual( + new Set(["https://gotify.lan"]), + ); + + // One line per malformed entry, written on the first parse only. + expect(warn).toHaveBeenCalledTimes(2); + 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://169.254.169.254"); + expect(lines[1]).toMatch(/link-local.*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", () => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + process.env.NOTIFICATION_PRIVATE_ORIGINS = "10.0.0.0/8, *.lan, true"; + expect(configuredNotificationPrivateOrigins()).toEqual(new Set()); + }); +}); + +describe("evaluateNotificationTarget", () => { + it("approves a target under a listed origin, path and all", () => { + process.env.NOTIFICATION_PRIVATE_ORIGINS = "https://gotify.example.com"; + expect( + evaluateNotificationTarget("https://gotify.example.com/message?token=x"), + ).toEqual({ + allowed: true, + canonicalOrigin: "https://gotify.example.com", + privateOriginApproved: true, + reasonCode: null, + }); + }); + + it("approves a listed literal private address the input-time floor would refuse", () => { + process.env.NOTIFICATION_PRIVATE_ORIGINS = "http://10.0.0.5:8080"; + expect(evaluateNotificationTarget("http://10.0.0.5:8080/message")).toEqual({ + allowed: true, + canonicalOrigin: "http://10.0.0.5:8080", + privateOriginApproved: true, + reasonCode: null, + }); + expect(isAllowedNotificationTarget("http://10.0.0.5:8080/message")).toBe( + true, + ); + }); + + it.each([ + ["sibling port", "http://10.0.0.5:9000/message"], + ["other scheme", "https://10.0.0.5:8080/message"], + ["neighbouring address", "http://10.0.0.6:8080/message"], + ["sub-host of a listed name", "https://push.gotify.example.com/message"], + ["unlisted private literal", "http://192.168.1.9/message"], + ])("refuses a %s: a grant names one origin only", (_label, url) => { + process.env.NOTIFICATION_PRIVATE_ORIGINS = + "http://10.0.0.5:8080, https://gotify.example.com"; + expect(evaluateNotificationTarget(url)).toMatchObject({ + allowed: false, + privateOriginApproved: false, + reasonCode: "private_origin_not_approved", + }); + expect(isAllowedNotificationTarget(url)).toBe(false); + }); + + it("keeps a public target functional and unapproved (the pin stays on)", () => { + process.env.NOTIFICATION_PRIVATE_ORIGINS = "https://gotify.example.com"; + expect(evaluateNotificationTarget("https://ntfy.sh/topic")).toEqual({ + allowed: true, + canonicalOrigin: "https://ntfy.sh", + privateOriginApproved: false, + reasonCode: null, + }); + }); + + 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_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([ + ["metadata", "http://169.254.169.254/latest"], + ["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({ + allowed: false, + reasonCode: "private_origin_not_grantable", + }); + }); + + it("refuses an unparseable target as invalid", () => { + expect(evaluateNotificationTarget("not a url")).toEqual({ + allowed: false, + canonicalOrigin: null, + privateOriginApproved: false, + reasonCode: "invalid_origin", + }); + expect( + evaluateNotificationTarget("ftp://gotify.lan/message"), + ).toMatchObject({ allowed: false, reasonCode: "invalid_origin" }); + }); + + it("lets an unlisted private DNS name through to the pinned resolver, as before", () => { + // Nothing widens and nothing narrows for a name the operator did not + // list: input time cannot tell gotify.lan from a public name, so the + // verdict is the same as before #947 and the connect-time pin decides. + expect(evaluateNotificationTarget("https://gotify.lan/message")).toEqual({ + allowed: true, + canonicalOrigin: "https://gotify.lan", + privateOriginApproved: false, + reasonCode: null, + }); + }); +}); + +describe("annotatePrivateOriginEgress", () => { + it("names the action when the event has none and pins the meta pair", () => { + const event = new WideEventBuilder("background"); + eventStorage.run(event, () => { + annotatePrivateOriginEgress("webhook", "https://gotify.example.com"); + }); + + const json = event.toJSON(); + expect(json.action).toEqual({ name: "notification.egress.private_origin" }); + expect(json.meta).toEqual({ + channel: "webhook", + origin: "https://gotify.example.com", + }); + }); + + it("keeps an action the route or job already set, and still lands the meta", () => { + // A settings route or a job names its own action; dashboards pin on it. + // The egress mark must not overwrite that name. + const event = new WideEventBuilder("http"); + event.setAction({ name: "settings.webhook.test" }); + eventStorage.run(event, () => { + annotatePrivateOriginEgress("ntfy", "http://ntfy.lan:8080"); + }); + + const json = event.toJSON(); + expect(json.action).toEqual({ name: "settings.webhook.test" }); + expect(json.meta).toEqual({ + channel: "ntfy", + origin: "http://ntfy.lan:8080", + }); + }); + + it("is a no-op without an active event", () => { + expect(() => + annotatePrivateOriginEgress("webhook", "https://gotify.example.com"), + ).not.toThrow(); + }); +}); diff --git a/src/lib/notifications/egress-policy.ts b/src/lib/notifications/egress-policy.ts new file mode 100644 index 000000000..4c3dfa2a9 --- /dev/null +++ b/src/lib/notifications/egress-policy.ts @@ -0,0 +1,113 @@ +import { + evaluateOriginGrant, + INVALID_ORIGIN, + originOfUrl, + parsePrivateOrigins, + PRIVATE_ORIGIN_NOT_APPROVED_CODE, + PRIVATE_ORIGIN_NOT_GRANTABLE_CODE, + type OriginVerdict, +} from "@/lib/private-origin-policy"; +import { annotate, getEvent } from "@/lib/logging/context"; + +/** + * `NOTIFICATION_PRIVATE_ORIGINS` (#947) — the operator grant that lets the + * generic webhook and ntfy senders reach a relay on the operator's own + * network (a Gotify behind a LAN reverse proxy, a self-hosted ntfy on + * Tailscale). + * + * Both senders dial through `safeFetch` with the connect-time DNS pin, which + * refuses any destination that resolves to a private address. That is the + * DNS-rebinding defence (#217) and it is deliberate: a public-looking name + * with a LAN A record is exactly what it stops. The grant is the operator's + * way to say "this one origin is mine": comma-separated exact origins, + * + * NOTIFICATION_PRIVATE_ORIGINS="https://gotify.example.com,http://ntfy.lan:8080" + * + * A listed origin is still dialled through the pinned resolver with redirects + * 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. + * + * The variable is read directly from `process.env` so the compose-whitelist + * guard sees the read. It is parsed once per distinct value: a malformed + * entry is logged once and grants nothing, and the valid entries beside it + * keep working, because a typo in one relay's origin must not take every + * public webhook on the instance down with it. + */ + +const ENV_NAME = "NOTIFICATION_PRIVATE_ORIGINS"; + +let cachedRaw: string | undefined; +let cachedOrigins: ReadonlySet = new Set(); + +export function configuredNotificationPrivateOrigins(): ReadonlySet { + const raw = process.env.NOTIFICATION_PRIVATE_ORIGINS; + if (raw === cachedRaw) return cachedOrigins; + // 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; +} + +/** + * Resolve one webhook or ntfy target URL against the grant and the public + * floor. The target may carry a path (Gotify's `/message`, an ntfy topic); + * only its origin is compared to the grant set. The full URL goes to the + * public check so the raw-string guards in `isPublicUrl` (octal and hex + * address spellings) see what the user typed. + * + * One function for every caller — the settings save, the test button and the + * dispatcher's senders — so the decision cannot differ between the button + * that says "works" and the reminder that then does not arrive. + */ +export function evaluateNotificationTarget(url: string): OriginVerdict { + const origin = originOfUrl(url); + if (!origin) { + return { + allowed: false, + canonicalOrigin: null, + privateOriginApproved: false, + reasonCode: INVALID_ORIGIN, + }; + } + return evaluateOriginGrant( + origin, + configuredNotificationPrivateOrigins(), + url, + ); +} + +/** Predicate form for the Zod refine on the save routes. */ +export function isAllowedNotificationTarget(url: string): boolean { + return evaluateNotificationTarget(url).allowed; +} + +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 + * egress, pinned to `{ channel, origin }` so a dashboard can count them and + * an operator can see which grant is actually in use. The origin is the + * canonical grant string, never the full target URL, which may carry a + * token in its path or query. + * + * The action name is set only when the event has none yet: a settings route + * or a job that already named its action keeps it, and the meta pair still + * lands on the same event, so the mark is never lost and never clobbers. + */ +export function annotatePrivateOriginEgress( + channel: "webhook" | "ntfy", + origin: string, +): void { + const event = getEvent(); + if (!event) return; + if (!event.hasAction()) { + annotate({ action: { name: "notification.egress.private_origin" } }); + } + annotate({ meta: { channel, origin } }); +} diff --git a/src/lib/notifications/retry-policy.ts b/src/lib/notifications/retry-policy.ts index 604007db7..6dbdfe8d8 100644 --- a/src/lib/notifications/retry-policy.ts +++ b/src/lib/notifications/retry-policy.ts @@ -35,6 +35,13 @@ export interface SendOutcome { reason?: string; /** Human-readable error fragment for logs (NOT for the disabled_reason column). */ message?: string; + /** + * Stable machine code an API surface may forward as `meta.errorCode`. Set + * only for a refusal the caller can act on — today the private-origin + * policy (#947), which the test routes answer with 422 and a reason + * instead of a bare 500. + */ + errorCode?: string; } /** diff --git a/src/lib/notifications/senders/__tests__/ntfy.test.ts b/src/lib/notifications/senders/__tests__/ntfy.test.ts new file mode 100644 index 000000000..9bb4c17be --- /dev/null +++ b/src/lib/notifications/senders/__tests__/ntfy.test.ts @@ -0,0 +1,235 @@ +/** + * ntfy sender unit tests. + * + * Covers the outbound shape (topic appended to the server URL, priority and + * tag headers, bearer token), the outcome classification, and (#947) the + * NOTIFICATION_PRIVATE_ORIGINS grant: a listed server rides the + * operator-approved pin, an unlisted one keeps the public pin, and a listed + * origin admits nothing beside itself. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const safeFetchMock = vi.fn(); +const recordPushAttemptMock = vi.fn(); +const annotateMock = vi.fn(); + +vi.mock("@/lib/safe-fetch", () => ({ + safeFetch: (...args: unknown[]) => safeFetchMock(...args), + SafeFetchError: class SafeFetchError extends Error { + kind: string; + constructor(message: string, kind: string) { + super(message); + this.kind = kind; + } + }, +})); + +vi.mock("@/lib/logging/context", () => ({ + getEvent: () => ({ + addWarning: vi.fn(), + addExternalCall: vi.fn(), + hasAction: () => false, + }), + annotate: (...args: unknown[]) => annotateMock(...args), +})); + +vi.mock("@/lib/notifications/senders/push-attempt-record", () => ({ + recordPushAttempt: (...args: unknown[]) => recordPushAttemptMock(...args), + recordPushAttemptForPayload: ( + _payload: unknown, + _recipientUserId: string, + attempt: unknown, + ) => recordPushAttemptMock(attempt), +})); + +import { sendViaNtfy } from "../ntfy"; +import { SafeFetchError } from "@/lib/safe-fetch"; + +const config = { + serverUrl: "https://ntfy.example.com/", + topic: "health", + authToken: "tk_secret", +}; + +function payload(over?: Record) { + return { + eventType: "SYSTEM_ALERT" as const, + userId: "user-1", + title: "Title", + message: "Body", + ...over, + }; +} + +type EgressOpts = { + requirePublicHost?: boolean; + operatorApprovedPrivateOrigin?: string; +}; + +const ORIGINAL_PRIVATE_ORIGINS = process.env.NOTIFICATION_PRIVATE_ORIGINS; + +beforeEach(() => { + safeFetchMock.mockReset(); + recordPushAttemptMock.mockReset(); + annotateMock.mockReset(); + delete process.env.NOTIFICATION_PRIVATE_ORIGINS; +}); + +afterEach(() => { + vi.clearAllMocks(); + if (ORIGINAL_PRIVATE_ORIGINS === undefined) { + delete process.env.NOTIFICATION_PRIVATE_ORIGINS; + } else { + process.env.NOTIFICATION_PRIVATE_ORIGINS = ORIGINAL_PRIVATE_ORIGINS; + } +}); + +describe("sendViaNtfy", () => { + it("POSTs the topic URL through safeFetch under the public pin and returns ok on 200", async () => { + safeFetchMock.mockResolvedValue({ ok: true, status: 200 }); + + const result = await sendViaNtfy(config, payload()); + + expect(result).toEqual({ ok: true, statusCode: 200 }); + const [url, init, opts] = safeFetchMock.mock.calls[0] as [ + string, + { method?: string; headers?: Record; body?: string }, + EgressOpts, + ]; + expect(url).toBe("https://ntfy.example.com/health"); + expect(init.method).toBe("POST"); + expect(init.headers?.Authorization).toBe("Bearer tk_secret"); + expect(init.body).toBe("Body"); + expect(opts.requirePublicHost).toBe(true); + expect(opts.operatorApprovedPrivateOrigin).toBeUndefined(); + expect(recordPushAttemptMock).toHaveBeenCalledWith( + expect.objectContaining({ channel: "NTFY", result: "ok" }), + ); + }); + + it("hard-rejects on 410 (topic gone)", async () => { + safeFetchMock.mockResolvedValue({ ok: false, status: 410 }); + + const result = await sendViaNtfy(config, payload()); + + expect(result.ok).toBe(false); + expect(result.hardReject).toBe(true); + expect(result.reason).toBe("ntfy_410_gone"); + }); + + it("reports a soft, named refusal when safeFetch blocks a private host (SSRF)", async () => { + safeFetchMock.mockRejectedValue( + new SafeFetchError("refused private host", "private_host"), + ); + + const result = await sendViaNtfy(config, payload()); + + expect(result).toMatchObject({ + ok: false, + hardReject: false, + reason: "ntfy_private_origin_refused", + errorCode: "private_origin_not_approved", + }); + expect(recordPushAttemptMock).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "NTFY", + result: "error", + reason: "ntfy_private_origin_refused", + }), + ); + }); + + it("keeps a genuine network fault distinct from a policy refusal", async () => { + safeFetchMock.mockRejectedValue(new SafeFetchError("timeout", "timeout")); + + const result = await sendViaNtfy(config, payload()); + + expect(result.reason).toBe("ntfy_network_error"); + expect(result.errorCode).toBeUndefined(); + }); + + describe("NOTIFICATION_PRIVATE_ORIGINS (#947)", () => { + it("dials a listed server through the operator-approved pin and marks the event", async () => { + process.env.NOTIFICATION_PRIVATE_ORIGINS = "http://ntfy.lan:8080"; + safeFetchMock.mockResolvedValue({ ok: true, status: 200 }); + + const result = await sendViaNtfy( + { serverUrl: "http://ntfy.lan:8080", topic: "health" }, + payload(), + ); + + expect(result.ok).toBe(true); + const [url, , opts] = safeFetchMock.mock.calls[0] as [ + string, + unknown, + EgressOpts, + ]; + expect(url).toBe("http://ntfy.lan:8080/health"); + expect(opts.requirePublicHost).toBe(false); + expect(opts.operatorApprovedPrivateOrigin).toBe("http://ntfy.lan:8080"); + expect(annotateMock).toHaveBeenCalledWith({ + action: { name: "notification.egress.private_origin" }, + }); + expect(annotateMock).toHaveBeenCalledWith({ + meta: { channel: "ntfy", origin: "http://ntfy.lan:8080" }, + }); + }); + + it("keeps the public pin for a server the operator did not list", async () => { + process.env.NOTIFICATION_PRIVATE_ORIGINS = "https://gotify.example.com"; + safeFetchMock.mockResolvedValue({ ok: true, status: 200 }); + + await sendViaNtfy(config, payload()); + + const [, , opts] = safeFetchMock.mock.calls[0] as [ + string, + unknown, + EgressOpts, + ]; + expect(opts.requirePublicHost).toBe(true); + expect(opts.operatorApprovedPrivateOrigin).toBeUndefined(); + expect(annotateMock).not.toHaveBeenCalled(); + }); + + it.each([ + ["sibling port", "http://ntfy.lan:9090"], + ["other scheme", "https://ntfy.lan:8080"], + ["sub-host", "http://push.ntfy.lan:8080"], + ["unlisted private literal", "http://192.168.1.9"], + ])( + "refuses a %s without dialling: a listed origin admits only itself", + async (_label, serverUrl) => { + process.env.NOTIFICATION_PRIVATE_ORIGINS = "http://ntfy.lan:8080"; + + const result = await sendViaNtfy( + { serverUrl, topic: "health" }, + payload(), + ); + + expect(safeFetchMock).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + ok: false, + hardReject: false, + reason: "ntfy_private_origin_refused", + errorCode: "private_origin_not_approved", + }); + expect(annotateMock).not.toHaveBeenCalled(); + }, + ); + }); + + it("reports the not-grantable code for a metadata target, without dialling (M1)", async () => { + const result = await sendViaNtfy( + { serverUrl: "http://169.254.169.254", 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 aab98366d..255f1705e 100644 --- a/src/lib/notifications/senders/__tests__/webhook.test.ts +++ b/src/lib/notifications/senders/__tests__/webhook.test.ts @@ -3,7 +3,10 @@ * * Covers: success, hard-reject classification (404/410/401/403), SSRF block * (safeFetch throws `private_host`), cooldown-relevant transient classification - * (5xx), and the push_attempts ledger write per outcome. + * (5xx), the push_attempts ledger write per outcome, and (#947) the + * NOTIFICATION_PRIVATE_ORIGINS grant: a listed origin rides the + * operator-approved pin, an unlisted one keeps the public pin, and a listed + * origin admits nothing beside itself. */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -21,8 +24,14 @@ vi.mock("@/lib/safe-fetch", () => ({ }, })); +const annotateMock = vi.fn(); vi.mock("@/lib/logging/context", () => ({ - getEvent: () => ({ addWarning: vi.fn(), addExternalCall: vi.fn() }), + getEvent: () => ({ + addWarning: vi.fn(), + addExternalCall: vi.fn(), + hasAction: () => false, + }), + annotate: (...args: unknown[]) => annotateMock(...args), })); vi.mock("@/lib/notifications/senders/push-attempt-record", () => ({ @@ -53,15 +62,29 @@ function payload(over?: Record) { }; } +const ORIGINAL_PRIVATE_ORIGINS = process.env.NOTIFICATION_PRIVATE_ORIGINS; + beforeEach(() => { safeFetchMock.mockReset(); recordPushAttemptMock.mockReset(); + annotateMock.mockReset(); + delete process.env.NOTIFICATION_PRIVATE_ORIGINS; }); afterEach(() => { vi.clearAllMocks(); + if (ORIGINAL_PRIVATE_ORIGINS === undefined) { + delete process.env.NOTIFICATION_PRIVATE_ORIGINS; + } else { + process.env.NOTIFICATION_PRIVATE_ORIGINS = ORIGINAL_PRIVATE_ORIGINS; + } }); +type EgressOpts = { + requirePublicHost?: boolean; + operatorApprovedPrivateOrigin?: string; +}; + describe("sendViaWebhook", () => { it("POSTs through safeFetch with requirePublicHost and returns ok on 200", async () => { safeFetchMock.mockResolvedValue({ ok: true, status: 200 }); @@ -115,7 +138,10 @@ describe("sendViaWebhook", () => { expect(result.reason).toBe("webhook_503"); }); - it("returns a transient network error when safeFetch blocks a private host (SSRF)", async () => { + it("reports a soft, named refusal when safeFetch blocks a private host (SSRF)", async () => { + // Before #947 this collapsed into `webhook_network_error` and the test + // button answered a bare 500. The refusal stays soft (delivery resumes + // the moment the operator lists the origin) but names itself. safeFetchMock.mockRejectedValue( new SafeFetchError("refused private host", "private_host"), ); @@ -124,16 +150,28 @@ describe("sendViaWebhook", () => { expect(result.ok).toBe(false); expect(result.hardReject).toBe(false); - expect(result.reason).toBe("webhook_network_error"); + expect(result.reason).toBe("webhook_private_origin_refused"); + expect(result.errorCode).toBe("private_origin_not_approved"); expect(recordPushAttemptMock).toHaveBeenCalledWith( expect.objectContaining({ channel: "WEBHOOK", result: "error", - reason: "webhook_network_error", + reason: "webhook_private_origin_refused", }), ); }); + it("keeps a genuine network fault distinct from a policy refusal", async () => { + safeFetchMock.mockRejectedValue( + new SafeFetchError("socket hang up", "network"), + ); + + const result = await sendViaWebhook(config, payload()); + + expect(result.reason).toBe("webhook_network_error"); + expect(result.errorCode).toBeUndefined(); + }); + it("omits the custom header when not configured", async () => { safeFetchMock.mockResolvedValue({ ok: true, status: 200 }); await sendViaWebhook({ url: "https://example.com/hook" }, payload()); @@ -142,4 +180,104 @@ describe("sendViaWebhook", () => { (init as { headers?: Record }).headers?.Authorization, ).toBeUndefined(); }); + + describe("NOTIFICATION_PRIVATE_ORIGINS (#947)", () => { + it("dials a listed origin through the operator-approved pin and marks the event", async () => { + process.env.NOTIFICATION_PRIVATE_ORIGINS = + "https://gotify.example.com, http://ntfy.lan:8080"; + safeFetchMock.mockResolvedValue({ ok: true, status: 200 }); + + const result = await sendViaWebhook(config, payload()); + + expect(result.ok).toBe(true); + const [url, , opts] = safeFetchMock.mock.calls[0] as [ + string, + unknown, + EgressOpts, + ]; + expect(url).toBe("https://gotify.example.com/message"); + expect(opts.requirePublicHost).toBe(false); + expect(opts.operatorApprovedPrivateOrigin).toBe( + "https://gotify.example.com", + ); + expect(annotateMock).toHaveBeenCalledWith({ + action: { name: "notification.egress.private_origin" }, + }); + expect(annotateMock).toHaveBeenCalledWith({ + meta: { channel: "webhook", origin: "https://gotify.example.com" }, + }); + }); + + it("keeps the public pin for a target the operator did not list", async () => { + process.env.NOTIFICATION_PRIVATE_ORIGINS = "http://ntfy.lan:8080"; + safeFetchMock.mockResolvedValue({ ok: true, status: 200 }); + + await sendViaWebhook(config, payload()); + + const [, , opts] = safeFetchMock.mock.calls[0] as [ + string, + unknown, + EgressOpts, + ]; + expect(opts.requirePublicHost).toBe(true); + expect(opts.operatorApprovedPrivateOrigin).toBeUndefined(); + expect(annotateMock).not.toHaveBeenCalled(); + }); + + it.each([ + ["sibling port", "https://gotify.example.com:8443/message"], + ["other scheme", "http://gotify.example.com/message"], + ["sub-host", "https://push.gotify.example.com/message"], + ["unlisted private literal", "http://10.0.0.9/message"], + ])( + "refuses a %s without dialling: a listed origin admits only itself", + async (_label, url) => { + process.env.NOTIFICATION_PRIVATE_ORIGINS = "https://gotify.example.com"; + + const result = await sendViaWebhook({ url }, payload()); + + expect(safeFetchMock).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + ok: false, + hardReject: false, + reason: "webhook_private_origin_refused", + errorCode: "private_origin_not_approved", + }); + expect(annotateMock).not.toHaveBeenCalled(); + }, + ); + + it("approves a listed literal private address the input floor alone would refuse", async () => { + process.env.NOTIFICATION_PRIVATE_ORIGINS = "http://10.0.0.9:8080"; + safeFetchMock.mockResolvedValue({ ok: true, status: 200 }); + + const result = await sendViaWebhook( + { url: "http://10.0.0.9:8080/message" }, + payload(), + ); + + expect(result.ok).toBe(true); + const [, , opts] = safeFetchMock.mock.calls[0] as [ + string, + unknown, + EgressOpts, + ]; + expect(opts.operatorApprovedPrivateOrigin).toBe("http://10.0.0.9:8080"); + }); + }); + + it("reports the not-grantable code for a metadata target, without dialling (M1)", async () => { + const result = await sendViaWebhook( + { url: "http://169.254.169.254/latest" }, + 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 ea966adac..1692112b8 100644 --- a/src/lib/notifications/senders/ntfy.ts +++ b/src/lib/notifications/senders/ntfy.ts @@ -6,10 +6,16 @@ import type { SendOutcome } from "@/lib/notifications/retry-policy"; import { classifyHttpStatus } from "@/lib/notifications/retry-policy"; import { getEvent } from "@/lib/logging/context"; import { recordPushAttemptForPayload } from "@/lib/notifications/senders/push-attempt-record"; -import { safeFetch } from "@/lib/safe-fetch"; +import { safeFetch, SafeFetchError } from "@/lib/safe-fetch"; import { plainPushText } from "@/lib/notifications/strip-emoji"; import { stripHtml } from "@/lib/notifications/strip-html"; import { isUrgentPayload } from "@/lib/notifications/types"; +import { + annotatePrivateOriginEgress, + evaluateNotificationTarget, + 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). @@ -26,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)}`; @@ -60,11 +70,25 @@ export async function sendViaNtfy( // Strip HTML tags for ntfy (plain text only) + emoji on routine reminders const body = plainPushText(stripHtml(payload.message), payload.eventType); - // requirePublicHost adds the DNS-rebinding pin (issue #217) on top - // of the input-time isPublicUrl guard already enforced when the - // user saved the ntfy serverUrl in their channel config. The - // authToken would otherwise leak on a rebinding flip to a private - // address. + // The DNS-rebinding pin (issue #217) sits on top of the input-time + // guard enforced when the user saved the serverUrl; the authToken would + // otherwise leak on a rebinding flip to a private address. A server the + // operator listed in NOTIFICATION_PRIVATE_ORIGINS (#947) runs under the + // operator-approved pin instead — same resolver pin, redirects + // forbidden, loopback and metadata refused. Same verdict function as + // 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", + ); + } + if (policy.privateOriginApproved) { + annotatePrivateOriginEgress("ntfy", policy.canonicalOrigin); + } + const res = await safeFetch( url, { @@ -72,7 +96,13 @@ export async function sendViaNtfy( headers, body, }, - { timeoutMs: 5_000, requirePublicHost: true }, + { + timeoutMs: 5_000, + requirePublicHost: !policy.privateOriginApproved, + ...(policy.privateOriginApproved + ? { operatorApprovedPrivateOrigin: policy.canonicalOrigin } + : {}), + }, ); getEvent()?.addExternalCall({ @@ -107,6 +137,13 @@ export async function sendViaNtfy( }; } catch (err) { const message = err instanceof Error ? err.message : "request_failed"; + // A policy refusal is not a network fault (see the webhook sender): soft + // classification, its own reason, and a code the test route can forward. + const policyRefused = + err instanceof SafeFetchError && err.kind === "private_host"; + const reason = policyRefused + ? "ntfy_private_origin_refused" + : "ntfy_network_error"; getEvent()?.addExternalCall({ service: "ntfy", method: "sendNotification", @@ -118,13 +155,16 @@ export async function sendViaNtfy( channel: "NTFY", eventType: payload.eventType, result: "error", - reason: "ntfy_network_error", + reason, }); return { ok: false, hardReject: false, - reason: "ntfy_network_error", + reason, message, + ...(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 34de62f72..c9d480a7e 100644 --- a/src/lib/notifications/senders/webhook.ts +++ b/src/lib/notifications/senders/webhook.ts @@ -6,9 +6,15 @@ import type { SendOutcome } from "@/lib/notifications/retry-policy"; import { classifyHttpStatus } from "@/lib/notifications/retry-policy"; import { getEvent } from "@/lib/logging/context"; import { recordPushAttemptForPayload } from "@/lib/notifications/senders/push-attempt-record"; -import { safeFetch } from "@/lib/safe-fetch"; +import { safeFetch, SafeFetchError } from "@/lib/safe-fetch"; import { plainPushText } from "@/lib/notifications/strip-emoji"; import { stripHtml } from "@/lib/notifications/strip-html"; +import { + annotatePrivateOriginEgress, + evaluateNotificationTarget, + 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). @@ -20,10 +26,15 @@ import { stripHtml } from "@/lib/notifications/strip-html"; * `title`/`message` fields are stripped of HTML + decorative emoji on routine * reminders exactly like the ntfy sender. * - * Outbound goes through `safeFetch({ requirePublicHost: true })`: the SSRF - * floor plus the connect-time DNS-rebinding pin apply because the host is - * user-supplied. The optional shared-secret header would otherwise leak on a - * rebinding flip to a private address. + * Outbound goes through `safeFetch` with the connect-time DNS-rebinding pin + * because the host is user-supplied; the optional shared-secret header would + * otherwise leak on a rebinding flip to a private address. A public target + * runs under the public pin. An origin the operator listed in + * `NOTIFICATION_PRIVATE_ORIGINS` (#947) runs under the operator-approved pin + * instead: still resolved and pinned inside the connector, redirects + * forbidden, loopback and metadata answers refused. The verdict comes from + * `evaluateNotificationTarget`, the same function the settings routes use, + * so the test button and the dispatcher cannot disagree. * * Returns a `SendOutcome` so the dispatcher can distinguish hard rejects * (404/410 endpoint gone, 401/403 secret wrong) from soft errors (5xx, 429, @@ -35,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", @@ -63,6 +78,18 @@ export async function sendViaWebhook( : "default", }); + 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", + ); + } + if (policy.privateOriginApproved) { + annotatePrivateOriginEgress("webhook", policy.canonicalOrigin); + } + const res = await safeFetch( config.url, { @@ -70,7 +97,13 @@ export async function sendViaWebhook( headers, body, }, - { timeoutMs: 5_000, requirePublicHost: true }, + { + timeoutMs: 5_000, + requirePublicHost: !policy.privateOriginApproved, + ...(policy.privateOriginApproved + ? { operatorApprovedPrivateOrigin: policy.canonicalOrigin } + : {}), + }, ); getEvent()?.addExternalCall({ @@ -105,6 +138,15 @@ export async function sendViaWebhook( }; } catch (err) { const message = err instanceof Error ? err.message : "request_failed"; + // A policy refusal is not a network fault: the target is on a private + // network the operator has not listed. It keeps the soft classification + // (delivery resumes the moment the origin is listed) but carries its own + // reason so the ledger and the test button can say why. + const policyRefused = + err instanceof SafeFetchError && err.kind === "private_host"; + const reason = policyRefused + ? "webhook_private_origin_refused" + : "webhook_network_error"; getEvent()?.addExternalCall({ service: "webhook", method: "sendNotification", @@ -116,13 +158,16 @@ export async function sendViaWebhook( channel: "WEBHOOK", eventType: payload.eventType, result: "error", - reason: "webhook_network_error", + reason, }); return { ok: false, hardReject: false, - reason: "webhook_network_error", + reason, message, + ...(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 224f66438..7b948794f 100644 --- a/src/lib/openapi/routes/settings.ts +++ b/src/lib/openapi/routes/settings.ts @@ -243,7 +243,7 @@ const channelEnabledOnlyRequest = notificationChannelEnabledSchema.meta({ const ntfySettingsRequest = ntfySettingsSchema.meta({ id: "NtfySettingsRequest", description: - "ntfy channel config. `serverUrl` must pass the SSRF floor — a URL resolving into a private range is refused at input time. An EMPTY OR OMITTED `authToken` preserves the stored one rather than clearing it: the GET never returns the secret, so a client round-tripping the form would otherwise wipe it on every unrelated save. Sending a non-empty value replaces it. There is no way to clear a stored auth token through this endpoint.", + "ntfy channel config. `serverUrl` must pass the SSRF floor — a URL resolving into a private range is refused at input time unless the operator listed its exact origin in `NOTIFICATION_PRIVATE_ORIGINS`. An EMPTY OR OMITTED `authToken` preserves the stored one rather than clearing it: the GET never returns the secret, so a client round-tripping the form would otherwise wipe it on every unrelated save. Sending a non-empty value replaces it. There is no way to clear a stored auth token through this endpoint.", }); const telegramSettingsRequest = telegramSettingsSchema.meta({ @@ -255,7 +255,7 @@ const telegramSettingsRequest = telegramSettingsSchema.meta({ const webhookSettingsRequest = webhookSettingsSchema.meta({ id: "WebhookSettingsRequest", description: - "Generic-webhook channel config — one channel covering Gotify, Discord, Slack, a Matrix bridge, Home Assistant, or any relay accepting an inbound JSON POST. `url` must pass the SSRF floor at input time and is re-checked at dispatch time. An EMPTY OR OMITTED `headerValue` preserves the stored one, for the same reason as ntfy's `authToken`: the GET never returns it. There is no way to clear a stored header value through this endpoint.", + "Generic-webhook channel config — one channel covering Gotify, Discord, Slack, a Matrix bridge, Home Assistant, or any relay accepting an inbound JSON POST. `url` must pass the SSRF floor at input time and is re-checked at dispatch time; a private origin passes both only when the operator listed it in `NOTIFICATION_PRIVATE_ORIGINS`. An EMPTY OR OMITTED `headerValue` preserves the stored one, for the same reason as ntfy's `authToken`: the GET never returns it. There is no way to clear a stored header value through this endpoint.", }); /** @@ -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.", + "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.", + "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 } }, }, }, @@ -705,7 +705,7 @@ export const settingsPaths: NonNullable = { tags: ["Notifications"], summary: "Send a test message to the saved ntfy topic", description: - "Publishes one message to the account's configured ntfy server and topic. Takes no body. Rate-limited 5 per 5 minutes per user. Refuses with 400 when no ntfy channel is saved or when the saved config is missing a server URL or topic; a publish failure is a 500.", + "Publishes one message to the account's configured ntfy server and topic. Takes no body. Rate-limited 5 per 5 minutes per user. Refuses with 400 when no ntfy channel is saved or when the saved config is missing a server URL or topic; a publish failure is a 500. A server on a private network is refused with 422 and `meta.errorCode` = `private_origin_not_approved` unless the operator listed its exact origin in `NOTIFICATION_PRIVATE_ORIGINS`; the test and the scheduled delivery take the same decision.", responses: { "200": { description: @@ -717,6 +717,11 @@ 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 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 } }, + }, }, }, }, @@ -746,7 +751,7 @@ export const settingsPaths: NonNullable = { tags: ["Notifications"], summary: "Send a test payload to the saved webhook", description: - "Posts one payload to the account's configured webhook URL. Takes no body. Rate-limited 5 per 5 minutes per user. Refuses with 400 when no webhook channel is saved or the saved config has no URL; a delivery failure is a 500. The URL was checked against the SSRF floor when it was saved, so a test cannot be used to reach an internal host.", + "Posts one payload to the account's configured webhook URL. Takes no body. Rate-limited 5 per 5 minutes per user. Refuses with 400 when no webhook channel is saved or the saved config has no URL; a delivery failure is a 500. The URL was checked against the SSRF floor when it was saved, so a test cannot be used to reach an internal host. A URL on a private network is refused with 422 and `meta.errorCode` = `private_origin_not_approved` unless the operator listed its exact origin in `NOTIFICATION_PRIVATE_ORIGINS`; the test and the scheduled delivery take the same decision.", responses: { "200": { description: @@ -758,6 +763,11 @@ 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 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 new file mode 100644 index 000000000..e51f4a2d4 --- /dev/null +++ b/src/lib/private-origin-policy.ts @@ -0,0 +1,232 @@ +import { isIP } from "node:net"; +import { + isOperatorGrantableIp, + isPublicUrl, +} from "@/lib/validations/notifications"; + +/** + * Exact-origin grant grammar shared by every operator allowlist that opens a + * private destination: `NIGHTSCOUT_PRIVATE_ORIGINS` (v1.17.0) and + * `NOTIFICATION_PRIVATE_ORIGINS` (#947). One parser, one verdict, so the two + * lists cannot drift in what they accept. + * + * A grant is one canonical `scheme://host[:port]` trust unit. It is never a + * suffix, wildcard, CIDR, capability URL or prefix: a range grant would let a + * DNS rebinding to any address inside the range pass the connect-time pin, + * which turns the pin into a formality. An operator with several relays lists + * several origins. + */ + +export const PRIVATE_ORIGIN_NOT_APPROVED_CODE = + "private_origin_not_approved" as const; +/** + * The target is the unspecified address, link-local or the metadata range: + * no grant can ever open it, so telling the user to ask the operator would + * send them in a circle. + */ +export const PRIVATE_ORIGIN_NOT_GRANTABLE_CODE = + "private_origin_not_grantable" as const; +export const INVALID_ORIGIN = "invalid_origin" as const; + +export type OriginReason = + | typeof PRIVATE_ORIGIN_NOT_APPROVED_CODE + | typeof PRIVATE_ORIGIN_NOT_GRANTABLE_CODE + | typeof INVALID_ORIGIN; + +/** + * A hostname no grant may name: a literal address outside the grantable + * ranges (unspecified, link-local, metadata). Loopback and `localhost` are + * grantable when listed exactly — a host-networking deployment lists them — + * and DNS names are not judged here: a name that resolves into a refused + * range is dropped at dial time by the pinned dispatcher. + */ +export function isNeverGrantableHost(hostname: string): boolean { + const literal = hostname.toLowerCase().replace(/^\[|\]$/g, ""); + return isIP(literal) !== 0 && !isOperatorGrantableIp(literal); +} + +/** + * The entry as it may be logged or put in an error message: scheme and host + * only. A malformed entry is malformed precisely when it carries the shapes + * that hold a secret — a query with a token, userinfo, a capability URL + * pasted whole — and this text goes to stdout, not through the wide-event + * redactors. + */ +export function redactGrantEntry(entry: string): string { + return entry + .split(/[?#]/, 1)[0] + .replace(/\/\/[^/@]*@/, "//") + .slice(0, 200); +} + +/** Why an entry is not a grant, in operator-readable words. */ +export function describeGrantRejection(entry: string): string { + try { + const url = new URL(entry); + if ( + (url.protocol === "http:" || url.protocol === "https:") && + isNeverGrantableHost(url.hostname) + ) { + return "the unspecified address, link-local and metadata addresses cannot be granted; list the address the relay actually listens on"; + } + } catch { + // fall through to the grammar sentence + } + return "a grant is one exact http(s) origin (scheme://host[:port]) with no path, query, credentials or wildcard"; +} + +export interface OriginVerdict { + allowed: boolean; + canonicalOrigin: string | null; + privateOriginApproved: boolean; + reasonCode: OriginReason | null; +} + +/** + * Parse one exact http(s) origin. + * + * Credentials, paths, queries, and fragments are deliberately forbidden: the + * operator grants a complete canonical scheme/host/port trust unit, never a + * suffix, wildcard, capability URL, or prefix. A literal address in the + * unspecified range, link-local or the metadata range is refused too — + * `isOperatorGrantableIp` says why — and the pinned dispatcher applies the + * same floor when a listed DNS name resolves there. Loopback is grantable + * when listed exactly: an operator on host networking lists it on purpose. + */ +export function canonicalOrigin(value: string): string | null { + try { + const url = new URL(value); + if (url.protocol !== "http:" && url.protocol !== "https:") return null; + if (url.username || url.password) return null; + if (url.pathname !== "/" || url.search || url.hash) return null; + if (!url.hostname || url.hostname.includes("*")) return null; + // Unspecified, link-local and metadata literals are refused at parse + // time. Loopback, `localhost` and reserved-looking names (`.local` mDNS, + // `.internal`, `.lan`) stay grantable: an explicitly listed origin is + // what the list is for, and the dial-time floor still drops a metadata + // or link-local answer. + if (isNeverGrantableHost(url.hostname)) return null; + return url.origin; + } catch { + return null; + } +} + +/** + * The origin of a target URL that may legitimately carry a path (a Gotify + * `/message` endpoint, an ntfy topic), for comparison against the grant set. + */ +export function originOfUrl(value: string): string | null { + try { + const url = new URL(value); + if (url.protocol !== "http:" && url.protocol !== "https:") return null; + if (!url.hostname) return null; + return url.origin; + } catch { + return null; + } +} + +function sharesApprovedPrivateHostname( + origin: string, + privateOrigins: ReadonlySet, +): boolean { + const hostname = new URL(origin).hostname; + + for (const approvedOrigin of privateOrigins) { + const approvedHostname = new URL(approvedOrigin).hostname; + if ( + hostname === approvedHostname || + hostname.endsWith(`.${approvedHostname}`) + ) { + return true; + } + } + + return false; +} + +/** + * Parse a server-only comma-separated exact-origin allowlist. + * + * Every malformed non-empty entry is handed to `onInvalid` with the entry + * already reduced to scheme and host (`redactGrantEntry`) and the reason in + * words (`describeGrantRejection`); the callback decides the failure mode: the Nightscout list throws (a connection form can afford to + * fail loudly), the notification list logs once and skips the entry (a typo + * must not take every public webhook down with it). Either way a malformed + * entry grants nothing — the set only ever shrinks. + */ +export function parsePrivateOrigins( + raw: string | undefined, + onInvalid: (redactedEntry: string, reason: string) => void, +): ReadonlySet { + const origins = new Set(); + for (const entry of (raw ?? "").split(",")) { + const trimmed = entry.trim(); + if (!trimmed) continue; + const origin = canonicalOrigin(trimmed); + if (!origin) { + onInvalid(redactGrantEntry(trimmed), describeGrantRejection(trimmed)); + continue; + } + origins.add(origin); + } + return origins; +} + +/** + * Resolve one canonical origin against the grant set. + * + * Exact operator membership wins before the ordinary public-host verdict so a + * private DNS name can be trusted without granting its suffix or sibling + * ports. Public origins remain supported without configuration. `publicCheck` + * defaults to the origin itself; a caller holding the full URL passes it so + * the raw-string guards in `isPublicUrl` (octal and hex address spellings) + * see the text the user typed. + */ +export function evaluateOriginGrant( + origin: string, + privateOrigins: ReadonlySet, + publicCheck: string = origin, +): OriginVerdict { + if (privateOrigins.has(origin)) { + return { + allowed: true, + canonicalOrigin: origin, + privateOriginApproved: true, + reasonCode: null, + }; + } + + // An exact grant must not accidentally become a hostname suffix grant. + // Deny related hosts before the generic public-host fallback: private DNS + // names such as gotify.lan may otherwise look syntactically public here. + if (sharesApprovedPrivateHostname(origin, privateOrigins)) { + return { + allowed: false, + canonicalOrigin: origin, + privateOriginApproved: false, + reasonCode: PRIVATE_ORIGIN_NOT_APPROVED_CODE, + }; + } + + if (isPublicUrl(publicCheck)) { + return { + allowed: true, + canonicalOrigin: origin, + privateOriginApproved: false, + reasonCode: null, + }; + } + + // Two refusals that read differently to the person who gets them: a + // private address the operator could list, and one no grant can open. + return { + allowed: false, + canonicalOrigin: origin, + privateOriginApproved: false, + reasonCode: isNeverGrantableHost(new URL(origin).hostname) + ? PRIVATE_ORIGIN_NOT_GRANTABLE_CODE + : PRIVATE_ORIGIN_NOT_APPROVED_CODE, + }; +} diff --git a/src/lib/safe-fetch-dispatcher.ts b/src/lib/safe-fetch-dispatcher.ts index 4ced48b20..544278ce0 100644 --- a/src/lib/safe-fetch-dispatcher.ts +++ b/src/lib/safe-fetch-dispatcher.ts @@ -1,7 +1,9 @@ import dns from "node:dns"; -import { isIP } from "node:net"; import { Agent } from "undici"; -import { isPublicIp } from "@/lib/validations/notifications"; +import { + isOperatorGrantableIp, + isPublicIp, +} from "@/lib/validations/notifications"; /** * Custom `undici.Agent` that resolves the request hostname literally, @@ -68,10 +70,15 @@ function pinnedLookupWithPolicy( callback(err, ""); return; } + // The operator-approved policy is not "anything that parses": a listed + // 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) - : isIP(address.address) !== 0, + : isOperatorGrantableIp(address.address), ); if (allowed.length === 0) { const refused = new Error( @@ -165,10 +172,13 @@ export function getPinnedPublicDispatcher(): Agent { /** * Pinned dispatcher for an exact operator-approved origin. * - * Private addresses are permitted only after the Nightscout origin policy has - * matched the complete canonical scheme/host/port trust grant. Resolution is - * still performed once inside Undici's connector and the vetted answer set is + * Private addresses are permitted only after an exact-origin policy + * (`NIGHTSCOUT_PRIVATE_ORIGINS`, `NOTIFICATION_PRIVATE_ORIGINS`) has matched + * 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. + * 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 6cc5127b8..b6bf2b9cd 100644 --- a/src/lib/validations/__tests__/nightscout.test.ts +++ b/src/lib/validations/__tests__/nightscout.test.ts @@ -120,4 +120,41 @@ describe("Nightscout exact-origin policy", () => { reasonCode: "invalid_origin", }); }); + + 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://169.254.169.254: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://169.254.169.254:1337/"', + ); + 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 12d289a17..cd7cbae26 100644 --- a/src/lib/validations/nightscout.ts +++ b/src/lib/validations/nightscout.ts @@ -1,89 +1,62 @@ import { z } from "zod/v4"; -import { isPublicUrl } from "@/lib/validations/notifications"; +import { + canonicalOrigin, + evaluateOriginGrant, + INVALID_ORIGIN, + parsePrivateOrigins, + PRIVATE_ORIGIN_NOT_APPROVED_CODE, + type OriginReason, + type OriginVerdict, +} from "@/lib/private-origin-policy"; export const NIGHTSCOUT_PRIVATE_ORIGIN_REASON = - "private_origin_not_approved" as const; -export const NIGHTSCOUT_INVALID_ORIGIN_REASON = "invalid_origin" as const; - -export type NightscoutOriginReason = - | typeof NIGHTSCOUT_PRIVATE_ORIGIN_REASON - | typeof NIGHTSCOUT_INVALID_ORIGIN_REASON; - -export interface NightscoutOriginVerdict { - allowed: boolean; - canonicalOrigin: string | null; - privateOriginApproved: boolean; - reasonCode: NightscoutOriginReason | null; -} + PRIVATE_ORIGIN_NOT_APPROVED_CODE; +export const NIGHTSCOUT_INVALID_ORIGIN_REASON = INVALID_ORIGIN; /** - * Parse one exact http(s) origin. - * - * Credentials, paths, queries, and fragments are deliberately forbidden: the - * operator grants a complete canonical scheme/host/port trust unit, never a - * suffix, wildcard, capability URL, or prefix. + * 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 stay + * valid grants exactly as before #947; the shared floor refuses only the + * unspecified address, link-local and the metadata range. */ -function canonicalOrigin(value: string): string | null { - try { - const url = new URL(value); - if (url.protocol !== "http:" && url.protocol !== "https:") return null; - if (url.username || url.password) return null; - if (url.pathname !== "/" || url.search || url.hash) return null; - if (!url.hostname || url.hostname.includes("*")) return null; - return url.origin; - } catch { - return null; +export class NightscoutOriginConfigError extends Error { + constructor(redactedEntry: string, reason: string) { + super( + `Invalid NIGHTSCOUT_PRIVATE_ORIGINS entry "${redactedEntry}": ${reason}`, + ); + this.name = "NightscoutOriginConfigError"; } } -function sharesApprovedPrivateHostname( - origin: string, - privateOrigins: ReadonlySet, -): boolean { - const hostname = new URL(origin).hostname; +export type NightscoutOriginReason = OriginReason; - for (const approvedOrigin of privateOrigins) { - const approvedHostname = new URL(approvedOrigin).hostname; - if ( - hostname === approvedHostname || - hostname.endsWith(`.${approvedHostname}`) - ) { - return true; - } - } - - return false; -} +export type NightscoutOriginVerdict = OriginVerdict; /** * Parse the server-only comma-separated exact-origin allowlist. * - * Invalid non-empty entries fail startup/call-site evaluation loudly rather - * than being skipped: partial acceptance makes an operator believe a private - * integration is protected while silently changing which origin is trusted. + * The grammar lives in `@/lib/private-origin-policy` and is shared with + * `NOTIFICATION_PRIVATE_ORIGINS`. Invalid non-empty entries fail + * startup/call-site evaluation loudly rather than being skipped: partial + * acceptance makes an operator believe a private integration is protected + * while silently changing which origin is trusted. */ export function parseNightscoutPrivateOrigins( raw: string | undefined, ): ReadonlySet { - const origins = new Set(); - for (const entry of (raw ?? "").split(",")) { - const trimmed = entry.trim(); - if (!trimmed) continue; - const origin = canonicalOrigin(trimmed); - if (!origin) { - throw new Error("Invalid NIGHTSCOUT_PRIVATE_ORIGINS entry"); - } - origins.add(origin); - } - return origins; + return parsePrivateOrigins(raw, (redactedEntry, reason) => { + throw new NightscoutOriginConfigError(redactedEntry, reason); + }); } /** * Resolve one user-supplied Nightscout base URL against server policy. * - * Exact operator membership wins before the ordinary public-host verdict so a - * private DNS name can be trusted without granting its suffix or sibling - * ports. Public origins remain supported without configuration. + * A Nightscout base URL is itself a bare origin, so the strict grant grammar + * applies to the input as well: a path, query, fragment or credential part + * is `invalid_origin`, not merely unapproved. */ export function evaluateNightscoutOrigin( value: string, @@ -98,43 +71,7 @@ export function evaluateNightscoutOrigin( reasonCode: NIGHTSCOUT_INVALID_ORIGIN_REASON, }; } - - if (privateOrigins.has(origin)) { - return { - allowed: true, - canonicalOrigin: origin, - privateOriginApproved: true, - reasonCode: null, - }; - } - - // An exact grant must not accidentally become a hostname suffix grant. - // Deny related hosts before the generic public-host fallback: private DNS - // names such as cgm.lan may otherwise look syntactically public here. - if (sharesApprovedPrivateHostname(origin, privateOrigins)) { - return { - allowed: false, - canonicalOrigin: origin, - privateOriginApproved: false, - reasonCode: NIGHTSCOUT_PRIVATE_ORIGIN_REASON, - }; - } - - if (isPublicUrl(origin)) { - return { - allowed: true, - canonicalOrigin: origin, - privateOriginApproved: false, - reasonCode: null, - }; - } - - return { - allowed: false, - canonicalOrigin: origin, - privateOriginApproved: false, - reasonCode: NIGHTSCOUT_PRIVATE_ORIGIN_REASON, - }; + return evaluateOriginGrant(origin, privateOrigins); } export function configuredNightscoutPrivateOrigins(): ReadonlySet { diff --git a/src/lib/validations/notifications.ts b/src/lib/validations/notifications.ts index f7d1e0c36..15cf9b873 100644 --- a/src/lib/validations/notifications.ts +++ b/src/lib/validations/notifications.ts @@ -228,6 +228,63 @@ export function isPublicIp(ip: string): boolean { return false; } +/** + * Decide whether a resolved address may be dialled under an operator's + * exact-origin grant (`NOTIFICATION_PRIVATE_ORIGINS`, + * `NIGHTSCOUT_PRIVATE_ORIGINS`). + * + * A grant exists so a relay on the operator's own network can be reached, so + * 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; + const lower = ip.toLowerCase(); + + const family = isIP(lower); + if (family === 4) { + const parsed = parseIpv4Strict(lower); + if (!parsed) return false; + return !isNeverGrantableIpv4(parsed); + } + + if (family === 6) { + const bytes = parseIpv6Bytes(lower); + if (!bytes) return false; + + // 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 true; + } + if (bytes[0] === 0xfe && (bytes[1] & 0xc0) === 0x80) return false; + + const embedded = embeddedIpv4(bytes); + if (embedded && isNeverGrantableIpv4(embedded)) return false; + return true; + } + + return false; +} + +/** 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 === 0) return true; + if (a === 169 && b === 254) return true; + return false; +} + export function isPublicUrl(url: string): boolean { try { // Pre-URL guard #1: the WHATWG URL parser interprets leading-zero IPv4 @@ -281,41 +338,64 @@ export function isPublicUrl(url: string): boolean { } } -export const ntfySettingsSchema = z.object({ - serverUrl: z - .url("Ungültige Server-URL") - .max(200) - .refine( - (url) => isPublicUrl(url), - "Server-URL darf nicht auf interne Netzwerke zeigen", - ) - .optional() - .or(z.literal("")), - topic: z.string().max(100).optional().or(z.literal("")), - authToken: z.string().max(200).optional().or(z.literal("")), - enabled: z.boolean(), -}); +/** + * ntfy channel settings. The server URL is user-supplied and must pass the + * target predicate at input time. The exported constant carries the plain + * public floor (`isPublicUrl`) for the OpenAPI contract; the save route + * builds the same shape with `isAllowedNotificationTarget`, which adds the + * operator's `NOTIFICATION_PRIVATE_ORIGINS` grant (#947) on top of the floor + * without this module having to read the environment. + */ +export function ntfySettingsSchemaWith( + isAllowedTarget: (url: string) => boolean, +) { + return z.object({ + serverUrl: z + .url("Ungültige Server-URL") + .max(200) + .refine( + isAllowedTarget, + "Server-URL darf nicht auf interne Netzwerke zeigen", + ) + .optional() + .or(z.literal("")), + topic: z.string().max(100).optional().or(z.literal("")), + authToken: z.string().max(200).optional().or(z.literal("")), + enabled: z.boolean(), + }); +} + +export const ntfySettingsSchema = ntfySettingsSchemaWith(isPublicUrl); /** * Generic-webhook channel settings (v1.17.1). The URL is user-supplied and - * must pass the SSRF floor (`isPublicUrl`) at input time; the dispatcher - * re-checks it at fetch time via `safeFetch({ requirePublicHost: true })`. The - * optional header name/value carry a shared secret (e.g. Gotify token). + * must pass the target predicate at input time; the sender re-checks it at + * fetch time through `safeFetch` with the connect-time pin. The exported + * constant carries the plain public floor for the OpenAPI contract; the save + * route passes `isAllowedNotificationTarget` so an origin the operator listed + * in `NOTIFICATION_PRIVATE_ORIGINS` (#947) saves. The optional header + * name/value carry a shared secret (e.g. Gotify token). */ -export const webhookSettingsSchema = z.object({ - url: z - .url("Ungültige Webhook-URL") - .max(500) - .refine( - (url) => isPublicUrl(url), - "Webhook-URL darf nicht auf interne Netzwerke zeigen", - ) - .optional() - .or(z.literal("")), - headerName: z.string().max(100).optional().or(z.literal("")), - headerValue: z.string().max(500).optional().or(z.literal("")), - enabled: z.boolean(), -}); +export function webhookSettingsSchemaWith( + isAllowedTarget: (url: string) => boolean, +) { + return z.object({ + url: z + .url("Ungültige Webhook-URL") + .max(500) + .refine( + isAllowedTarget, + "Webhook-URL darf nicht auf interne Netzwerke zeigen", + ) + .optional() + .or(z.literal("")), + headerName: z.string().max(100).optional().or(z.literal("")), + headerValue: z.string().max(500).optional().or(z.literal("")), + enabled: z.boolean(), + }); +} + +export const webhookSettingsSchema = webhookSettingsSchemaWith(isPublicUrl); /** * Email channel settings (v1.17.1). The SMTP transport is operator-configured;