Merge the release with the security review fixes into the data-safety review fixes

This commit is contained in:
Marc-André Bombeck committed 2026-10-25 18:36:54 +01:00
commit bcb31c80db
98 files changed
+3166 -608

No files matched your search

+6 -3
View File
@@ -82,9 +82,12 @@ before treating a warning as a regression.
v1.30.17. The path still lists (`GET`) and revokes (`DELETE /api/tokens/{id}`)
every token above, whichever surface issued it.
- `sessionCookie` — HttpOnly `healthlog_session` cookie set by the
password and passkey login flows. Used by the web app and any iOS
client running inside a web view.
- `sessionCookie` — HttpOnly session cookie set by the password and
passkey login flows. Used by the web app and any iOS client running
inside a web view. It is named `__Host-healthlog_session` wherever it is
`Secure` (every HTTPS install), so no other host under the same domain
can set it; a plain-HTTP install keeps `healthlog_session`. A cookie
under the old name is still read and is re-issued under the new one.
- **`components.schemas`** — DTOs for the documented routes plus the
underlying Prisma models (e.g. `Measurement`, `Medication`,
`MedicationIntakeEvent`, `MoodEntry`, `User`, `DashboardLayout`,
+7 -4
View File
@@ -38530,8 +38530,8 @@ components:
activity.calendar.too_old; ai — ai.budget.exceeded, ai.provider.none, ai.record.notPermitted,
ai.unavailable, ai.usage.aboveCeiling, ai.usage.defaultAboveCeiling, ai.usage.invalid,
ai.usage.levelTooLow, ai.usage.rateLimited; ai_provider — ai_provider.invalid, ai_provider.no_fields;
aiRuns — aiRuns.failed, aiRuns.notFound, aiRuns.timedOut, aiRuns.workerUnavailable; allergy —
allergy.invalid; analytics — analytics.invalid_query; anamnesis — anamnesis.auditC.invalid,
aiRuns — aiRuns.failed, aiRuns.notFound, aiRuns.readerBusy, aiRuns.timedOut, aiRuns.workerUnavailable;
allergy — allergy.invalid; analytics — analytics.invalid_query; anamnesis — anamnesis.auditC.invalid,
anamnesis.auditC.notFound, anamnesis.auditC.rateLimited, anamnesis.fact.conflict,
anamnesis.fact.currentExists, anamnesis.fact.invalidValue, anamnesis.lifeConfirm.invalid,
anamnesis.lifeSections.invalid; auth — auth.account.deletion_pending, auth.admin.required,
@@ -75841,7 +75841,8 @@ components:
description: "Present once `status` is `FAILED`: the `meta.errorCode`, message and HTTP status the queuing route would
have answered with, or one of the run's own codes: `aiRuns.workerUnavailable` (no worker took the run within
fifteen minutes), `aiRuns.timedOut` (the read outlived the calls the AI response time allows),
`aiRuns.failed` (anything else)."
`aiRuns.readerBusy` (the server's document reader was busy past the wait a read gets, or stopped before it
answered; nothing was read or charged, try again in a minute), `aiRuns.failed` (anything else)."
createdAt:
type: string
startedAt:
@@ -94543,4 +94544,6 @@ components:
type: apiKey
in: cookie
name: healthlog_session
description: Browser session cookie — set by /api/auth/login.
description: Browser session cookie — set by /api/auth/login. Named `__Host-healthlog_session` wherever the cookie is
`Secure` (every HTTPS install); a plain-HTTP install, which cannot hold a `__Host-` cookie, keeps
`healthlog_session`.
+13 -4
View File
@@ -564,8 +564,17 @@ low-friction default.
**5 minutes** of server time. A signed request sends no secret, and a
captured one is useless once the window has passed.
Set `DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP=true` to accept signed requests only.
Use it when the sender, or a small relay in front of the app, can sign:
Two settings tighten this:
- `DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP=true` requires a fresh
`X-Deploy-Webhook-Timestamp` on a request that uses the secret header; one
without it is refused with `401 {"status":"stale"}`. This is what the
setting has always meant. It keeps an old capture out, but not someone who
saw the secret and sends a new request with a new timestamp.
- `DEPLOY_WEBHOOK_REQUIRE_SIGNATURE=true` accepts signed requests only; the
secret header alone is refused with 401. This is the one that stops a
replay. Use it when the sender, or a small relay in front of the app, can
sign:
```sh
TS=$(date +%s)
@@ -579,5 +588,5 @@ curl -X POST https://<host>/api/internal/deploy-webhook \
-d "$BODY"
```
Before 1.46 the strict setting accepted the secret header plus a fresh
timestamp. Such a sender now gets 401 in strict mode and has to sign.
Both may be set; a signed request satisfies both. An operator who set the
timestamp flag before 1.46 keeps exactly the behaviour they had.
+3 -1
View File
@@ -107,10 +107,12 @@ In the admin area, or with a signed-in admin session:
```sh
curl -X POST https://<your-instance>/api/admin/maintenance/measurements \
-H 'content-type: application/json' \
--cookie 'healthlog_session=<session>' \
--cookie '__Host-healthlog_session=<session>' \
-d '{"vacuum": true, "reindex": true}'
```
On a plain-HTTP install the cookie is named `healthlog_session`.
The answer is `202` with `{ "enqueued": true }`, or `enqueued: false` when a
run is already queued or running. A Bearer token is refused: the route is
cookie-only, like every admin route. Without a background worker the route
+30 -3
View File
@@ -17,7 +17,9 @@ Postgres 18 database is up and before the app, and it exits in a second when
there is nothing to move. When the old `pgdata` volume holds a Postgres 16
database and the new one is empty, it:
1. checks there is room on the disk for a second copy;
1. checks that the database service runs Postgres 18, and that there is room
on the disk for a second copy and for the dump the app takes before its
first migration;
2. starts Postgres 16 inside its own container on the old volume, reachable
only from inside that container;
3. streams a dump into Postgres 18 in a single transaction;
@@ -34,8 +36,10 @@ If any step fails, the new database stays empty, the app does not start, and
the log says what happened. A move cut short by a restart or a full disk is
run again from scratch on the next `docker compose up -d`.
The old `pgdata` volume is read and never changed, apart from the marker
file. That is the way back.
The old `pgdata` volume keeps the database as it was. Postgres 16 runs on it
during the move, so it writes a checkpoint there (and recovers first after an
unclean stop), and the marker file is added; no data in it changes. That is
the way back.
## Going back
@@ -55,6 +59,29 @@ HEALTHLOG_IMAGE_REF=:v1.45.0 docker compose up -d
```
Anything written after the move lives only in the Postgres 18 volume.
`db-upgrade` can stay in the file: it sees that the database service runs
Postgres 16 and leaves that volume alone. This also holds after a move that
stopped half-way.
## Updating again after going back
If you used Postgres 16 again after going back, the Postgres 18 volume holds
the state of the day of the move, not what you wrote since. `db-upgrade`
notices that the old database changed after the move and refuses to start on
the old copy. To move the current 16 database, remove the old copy and the
marker, then start again:
```bash
docker compose down
docker volume rm <project>_postgres-data
docker compose run --rm --entrypoint rm db-upgrade \
/var/lib/postgresql/data/HEALTHLOG_MOVED_TO_POSTGRES_18
docker compose up -d
```
`docker volume ls` shows the volume's full name. Removing it deletes what was
written on Postgres 18 before you went back; take a dump of it first if any
of that matters.
## Afterwards
+3 -1
View File
@@ -434,7 +434,9 @@ Keep the dump until the new version has run for a few days, and keep your
encryption key with it: a dump without the key cannot be read.
The database image is a separate matter: a new Postgres minor is a change of
the `image:` line, a new major (16 to 18) is a dump and restore, described in
the `image:` line. The move from 16 to 18 runs by itself on the first start
of the compose file from v1.46; a database you run yourself moves by dump
and restore. Both are in
[`docs/ops/postgres-major-upgrade.md`](../ops/postgres-major-upgrade.md).
The start log says what is about to happen before the migrations run:
+2 -2
View File
@@ -5,7 +5,7 @@
"type": 3,
"title": "HealthLog",
"name": "healthlog",
"description": "Self-hosted personal health tracker (PWA + native iOS client): weight, blood pressure, pulse, blood glucose, body composition, sleep, mood, menstrual cycle, and medications on one encrypted timeline. AES-256-GCM at rest, no vendor cloud, no telemetry. Ships app + PostgreSQL 16 as one stack.",
"description": "Self-hosted personal health tracker (PWA + native iOS client): weight, blood pressure, pulse, blood glucose, body composition, sleep, mood, menstrual cycle, and medications on one encrypted timeline. AES-256-GCM at rest, no vendor cloud, no telemetry. Ships app + PostgreSQL 18 as one stack.",
"note": "Generate each secret with: openssl rand -hex 32 (a 64-char hex string). For plain-HTTP / LAN access set SESSION_COOKIE_SECURE to false, or login fails. Set NEXT_PUBLIC_APP_URL and APP_URL to how you reach the app. Full guide: https://github.com/MBombeck/HealthLog/blob/main/docs/self-hosting/getting-started.md",
"categories": ["Tools", "Other"],
"platform": "linux",
@@ -33,7 +33,7 @@
{
"name": "HEALTHLOG_PLATFORM",
"label": "Platform hint",
"description": "Opens the admin's encryption key backup step on the Portainer instructions.",
"description": "Opens the admin's recovery kit on the Portainer instructions.",
"default": "portainer"
},
{
+22 -10
View File
@@ -95,19 +95,31 @@ different things depending on which button you press.
two actions below, the row stays. A start that was folded into a later
cycle stays for as long as that cycle does, because deleting the later
cycle gives it back.
- **Settings → Advanced → Delete all data** removes the rows outright, tombstones
- **Settings → Advanced → Delete all data** removes the rows, tombstones
included. Everything the account owns goes except what you sign in
with; the confirmation dialog lists it.
- **Deleting the account** removes the account row, and the database
cascades every record with it.
with; the confirmation dialog lists it. Before it does, it keeps a copy
of the account on the server for the deletion grace window (14 days by
default), so a mistaken wipe can be restored; the copy is erased when
the window ends.
- **Deleting the account** locks it at once and erases it, with every
record, when the grace window ends. Until then an admin can undo it.
Two consequences follow, and they matter for the seizure threat model
`ACCOUNT_DELETION_GRACE_DAYS=0` makes both immediate again, without the
copy and without an undo.
Three consequences follow, and they matter for the seizure threat model
above. A host imaged while a per-entry deletion is the only deletion you
have performed still holds that entry. And a backup taken before any of
these actions still holds whatever it captured, because a backup is a
copy of a moment. If your reason for deleting is that the data must not
be recoverable from the machine, use one of the two whole-record actions
and rotate your backups.
have performed still holds that entry; so does a host imaged inside the
grace window. A backup taken before any of these actions still holds
whatever it captured, because a backup is a copy of a moment: the nightly
database dumps keep a deleted record until they age out (`DB_BACKUP_KEEP`
nights on the server, and your bucket's lifecycle rule for `database/`).
And after the move to Postgres 18 the old `pgdata` volume is a full copy
of the database from that day until you remove it. If your reason for
deleting is that the data must not be recoverable from the machine, set
the grace window to 0 or wait it out, remove the old volume, and let the
dumps age out or delete them
([backup-restore.md](../ops/backup-restore.md)).
## Practical hardening
+58 -5
View File
@@ -21,13 +21,14 @@ tailnet with no extra container. See
| `NEXT_PUBLIC_APP_URL` | Every public-facing absolute URL the UI emits | Must match the hostname the proxy serves |
| `APP_URL` | Server-side absolute URLs from background jobs | Mirror of the above; both must match |
| `TRUST_PROXY_HOPS` | `X-Forwarded-For` chain trust | Defaults to `1` — one trusted hop |
| `TRUSTED_PROXY_CIDRS` | Which connections may report a client address | Unset: any private address; see below |
| `TRUST_CF_CONNECTING_IP` | Reading the visitor address Cloudflare sends | Off unless `1`; see the warning below |
Set the URL pair to the public origin (`https://your-instance.example.com`)
and restart the `app` container. The image reads both at startup; no
rebuild needed.
All four variables are on the shipped `docker-compose.yml`
All five variables are on the shipped `docker-compose.yml`
`environment:` whitelist, so a value in `.env` reaches the container
without editing the compose file. (Before v1.37.33 the whitelist was
missing `TRUST_PROXY_HOPS`; on those versions add
@@ -68,13 +69,56 @@ The reserved and multicast blocks are in the list because no host on the
internet can open a connection from them, so a peer there sits on a network
you run. A request that reaches HealthLog straight from a public
address carries headers its sender wrote, so HealthLog ignores them and
uses the connection's own address, and says so once in the log. A proxy on
the same host or Docker network connects from a private address, so the
usual setups above need nothing. If your proxy reaches HealthLog over a
public address, put it on a private network with HealthLog instead. The log
uses the connection's own address, and says so once in the log. The log
line also shows up when the published port is open to the internet beside
the proxy; bind it to the proxy's network or to `127.0.0.1` then.
That list is the default when `TRUSTED_PROXY_CIDRS` is unset, and it is
only as good as the network in front of the port. Read the next section
before relying on it.
### `TRUSTED_PROXY_CIDRS` — name the proxy
Set `TRUSTED_PROXY_CIDRS` to the addresses your reverse proxy connects
from, as comma-separated CIDRs (IPv4 or IPv6; a bare address counts as a
single host). Once it is set, only a connection from inside those ranges
may report the client's address. Every other connection is its own client,
whatever its address, private ones included, and the log says so once.
Why it matters: some setups make every caller look like a private address.
Docker Desktop, OrbStack, and Docker's `docker-proxy` (the userland proxy
behind a published port such as `3000:3000`) forward each connection from
the gateway address of the Docker network, which is private. On those
hosts, someone who can reach the published port directly, from the LAN or
from the internet if the port is open, passes the private-address check
and can again choose the address that every per-IP rate limit, the audit
log and the sign-in notices see, a new one per request if they like.
Naming your proxy closes that. The default port binding in
`docker-compose.yml` stays as it is (LAN installs rely on it); this
variable is the fix.
What to set:
| Where the proxy runs | `TRUSTED_PROXY_CIDRS` |
| ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| On the same host, proxying to `127.0.0.1:3000` (bare Nginx, Caddy) | `127.0.0.1/32,::1/128` |
| In a container on a Docker network with HealthLog (Traefik, NPM, Coolify) | that network's subnet, e.g. `172.18.0.0/16` (`docker network inspect <network>` shows it under `IPAM`) |
| On another machine that connects from a public address | that machine's address, e.g. `198.51.100.7/32` |
| No proxy, or `tailscale serve` only | leave it unset |
At boot the app logs one `[proxy-trust]` line naming the rule in force:
which connections it reads forwarding headers from and how many hops deep.
A value that is not a CIDR is logged as an error, and until it is fixed no
connection may report a client address.
**A proxy that connects from a public address.** Without
`TRUSTED_PROXY_CIDRS`, such a proxy is not believed: every request through
it resolves to the proxy's own address, so all your users share one
rate-limit bucket and one person mistyping a password can hold sign-in back
for everyone. Releases before v1.46 believed any peer, so this shows up after
the upgrade. Set `TRUSTED_PROXY_CIDRS` to the proxy's address (or put the proxy
on a private network with HealthLog) and restart.
### `TRUST_CF_CONNECTING_IP` — only when the origin takes Cloudflare only
With `TRUST_CF_CONNECTING_IP=1` HealthLog takes the visitor's address from
@@ -283,6 +327,15 @@ The one-shot stderr warning `[getClientIp] TRUST_PROXY_HOPS=N but
X-Forwarded-For carried M entries` confirms a mismatch. Adjust the
env var down to the actual chain length and restart.
If every request logs the same client address, the proxy's own, the proxy
is not one HealthLog believes: the `[getClientIp]` warning about a
connection "from a public address" or "from outside TRUSTED_PROXY_CIDRS"
names the case. Add the proxy's address to `TRUSTED_PROXY_CIDRS`.
A `TRUST_PROXY_HOPS` value that is not a whole number resolves no client
address at all (requests keep working) and is named in the `[proxy-trust]`
boot line.
## Running over plain HTTP on a LAN (no reverse proxy)
The session cookie carries the `Secure` flag by default under
+2 -2
View File
@@ -46,14 +46,14 @@
<Config Name="WebUI Port" Target="3000" Default="3000" Mode="tcp" Description="HTTP port HealthLog listens on." Type="Port" Display="always" Required="true" Mask="false">3000</Config>
<Config Name="DATABASE_URL" Target="DATABASE_URL" Default="postgresql://healthlog:CHANGE-ME@POSTGRES-HOST:5432/healthlog?schema=public" Mode="" Description="Connection string for your separately-installed PostgreSQL 16. Replace CHANGE-ME with the DB password and POSTGRES-HOST with the Postgres container IP or name." Type="Variable" Display="always" Required="true" Mask="true"/>
<Config Name="DATABASE_URL" Target="DATABASE_URL" Default="postgresql://healthlog:CHANGE-ME@POSTGRES-HOST:5432/healthlog?schema=public" Mode="" Description="Connection string for your separately-installed PostgreSQL (18, or 16). Replace CHANGE-ME with the DB password and POSTGRES-HOST with the Postgres container IP or name." Type="Variable" Display="always" Required="true" Mask="true"/>
<Config Name="ENCRYPTION_KEY" Target="ENCRYPTION_KEY" Default="" Mode="" Description="AES-256-GCM key for data at rest. Generate with: openssl rand -hex 32 (64-char hex). Back this up — losing it makes encrypted data unrecoverable." Type="Variable" Display="always" Required="true" Mask="true"/>
<Config Name="API_TOKEN_HMAC_KEY" Target="API_TOKEN_HMAC_KEY" Default="" Mode="" Description="HMAC key for API token hashing. Generate with: openssl rand -hex 32 (64-char hex)." Type="Variable" Display="always" Required="true" Mask="true"/>
<Config Name="SESSION_COOKIE_SECURE" Target="SESSION_COOKIE_SECURE" Default="false" Mode="" Description="Set to false for LAN / plain-HTTP installs (http://tower:3000) or login fails. Set to true only behind an HTTPS reverse proxy." Type="Variable" Display="always" Required="true" Mask="false">false</Config>
<Config Name="HEALTHLOG_PLATFORM" Target="HEALTHLOG_PLATFORM" Default="unraid" Mode="" Description="Where this server is configured. Opens the admin's encryption key backup step on the Unraid instructions." Type="Variable" Display="advanced" Required="false" Mask="false">unraid</Config>
<Config Name="HEALTHLOG_PLATFORM" Target="HEALTHLOG_PLATFORM" Default="unraid" Mode="" Description="Where this server is configured. Opens the admin's recovery kit on the Unraid instructions." Type="Variable" Display="advanced" Required="false" Mask="false">unraid</Config>
<Config Name="ENCRYPTION_KEY_CHECK" Target="ENCRYPTION_KEY_CHECK" Default="enforce" Mode="" Description="Start-up encryption key check. enforce refuses to serve when the key does not open the stored data. warn only logs it: a last resort for a refusal you are certain is false, and it disables a safety check." Type="Variable" Display="advanced" Required="false" Mask="false">enforce</Config>
<Config Name="NEXT_PUBLIC_APP_URL" Target="NEXT_PUBLIC_APP_URL" Default="http://tower:3000" Mode="" Description="Public URL users reach HealthLog at. Use the LAN URL (e.g. http://tower:3000) or your HTTPS domain behind a reverse proxy." Type="Variable" Display="always" Required="true" Mask="false"/>