Merge remote-tracking branch 'origin/main' into feat/onboarding-wizard

This commit is contained in:
Marc-André Bombeck committed 2026-09-10 20:53:38 +02:00
commit 8ed46eef58
49 files changed
+3199 -307

No files matched your search

+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:
@@ -25769,9 +25792,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"
@@ -25829,8 +25853,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
@@ -29998,8 +30023,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)