mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
Merge release v1.38.17
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.
This commit is contained in:
49 files changed
+3199
-307
No files matched your search
@@ -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)
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -71,6 +71,20 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
|
||||
# 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
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
+39
-14
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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에 엔드포인트가 없어요",
|
||||
|
||||
@@ -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",
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+1
-1
@@ -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/*`
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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<string>;
|
||||
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
|
||||
|
||||
@@ -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<Response>;
|
||||
|
||||
function put(path: string, body: unknown): Promise<Response> {
|
||||
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<Response> {
|
||||
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/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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: () => {
|
||||
|
||||
@@ -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: () => {
|
||||
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<void>((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<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
}, 20_000);
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<string> = new Set();
|
||||
|
||||
export function configuredNotificationPrivateOrigins(): ReadonlySet<string> {
|
||||
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 } });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<string, unknown>) {
|
||||
return {
|
||||
eventType: "SYSTEM_ALERT" as const,
|
||||
userId: "user-1",
|
||||
title: "Title",
|
||||
message: "<b>Body</b>",
|
||||
...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<string, string>; 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>) {
|
||||
};
|
||||
}
|
||||
|
||||
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<string, string> }).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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<SendOutcome> {
|
||||
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 }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<SendOutcome> {
|
||||
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<string, string> = {
|
||||
"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 }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -334,6 +334,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
|
||||
"notification_prefs_conflict",
|
||||
"oidc_only",
|
||||
"private_origin_not_approved",
|
||||
"private_origin_not_grantable",
|
||||
"rate_limited_self",
|
||||
"too_many_rows",
|
||||
"unreadable_json",
|
||||
|
||||
@@ -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<ZodOpenApiObject["paths"]> = {
|
||||
...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<ZodOpenApiObject["paths"]> = {
|
||||
...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<ZodOpenApiObject["paths"]> = {
|
||||
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<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
"422": {
|
||||
description:
|
||||
"The saved server is on a private network the operator has not approved (`meta.errorCode` = `private_origin_not_approved`), or is a 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<ZodOpenApiObject["paths"]> = {
|
||||
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<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
"422": {
|
||||
description:
|
||||
"The saved URL is on a private network the operator has not approved (`meta.errorCode` = `private_origin_not_approved`), or is a 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 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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<string>,
|
||||
): 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<string> {
|
||||
const origins = new Set<string>();
|
||||
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<string>,
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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=");
|
||||
});
|
||||
});
|
||||
@@ -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<string>,
|
||||
): 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<string> {
|
||||
const origins = new Set<string>();
|
||||
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<string> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user