diff --git a/.env.example b/.env.example index 31cee47cc..1564836ab 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. +# Loopback, link-local and metadata 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..190e26dd7 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; loopback, link-local and metadata 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/docker-compose.yml b/docker-compose.yml index f09232067..434cfdd27 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. Loopback, link-local + # and metadata 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/self-hosting/notifications.md b/docs/self-hosting/notifications.md index 6f7e01ab1..ade4cd6ae 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,76 @@ 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; +- loopback (`127.0.0.0/8`, `::1`, `localhost`), the unspecified address, + link-local and the cloud-metadata range (`169.254.0.0/16`, `fe80::/10`) + cannot be granted: a literal entry is logged once and ignored, and a + listed name that resolves there is refused at dial time. On a + host-network deployment, list the LAN address of the relay instead of + `localhost`; +- RFC1918, IPv6 unique-local and CGNAT (a Tailscale address or MagicDNS + name) are what the list is for; +- 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 | works | +| Literal `192.168.x.x`, `10.x.x.x`, `100.64.x.x`, `fd00::/8` | refused at save | works | +| `localhost`, `127.0.0.1`, `169.254.169.254`, `::1` | refused at save | still refused | + +A private target that is not listed is refused when the card is saved +(`422`, `meta.errorCode` = `private_origin_not_approved`, the message +names the variable) or, for a DNS name the save cannot classify, when the +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/scripts/env-manifest.json b/scripts/env-manifest.json index b1d95efa4..910134538 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\". Loopback, link-local and metadata 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.",