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:
Marc-André Bombeck authored and GitHub committed 2026-09-10 20:52:57 +02:00
commit 85151ca24e
49 files changed
+3199 -307

No files matched your search

+11
View File
@@ -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)
# -----------------------------------------------------------------------------
+14
View File
@@ -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
# -----------------------------------------------------------------------------
+52
View File
@@ -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
+7
View File
@@ -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
View File
@@ -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:
+7
View File
@@ -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.
+78 -9
View File
@@ -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)
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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에 엔드포인트가 없어요",
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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/*`
+11
View File
@@ -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.",
+17 -4
View File
@@ -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/);
}
});
});
+47 -7
View File
@@ -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;
+16
View File
@@ -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);
}
+55 -10
View File
@@ -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);
}
+14 -2
View File
@@ -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: () => {
+14 -2
View File
@@ -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/,
);
});
});
+163 -1
View File
@@ -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);
});
+24 -8
View File
@@ -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
+49 -24
View File
@@ -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;
}
+10
View File
@@ -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");
});
});
+48 -43
View File
@@ -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();
});
});
+113
View File
@@ -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 } });
}
+7
View File
@@ -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",
});
});
});
+49 -9
View File
@@ -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 }
: {}),
};
}
}
+53 -8
View File
@@ -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 }
: {}),
};
}
}
+1
View File
@@ -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",
+16 -6
View File
@@ -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 } },
},
},
},
},
+232
View File
@@ -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,
};
}
+16 -6
View File
@@ -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=");
});
});
+37 -100
View File
@@ -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> {
+111 -31
View File
@@ -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;