mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
Merge the release with the security review fixes into the data-safety review fixes
This commit is contained in:
commit
bcb31c80db
98 files changed
+3166
-608
No files matched your search
+6
-3
@@ -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`,
|
||||
|
||||
@@ -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
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
Reference in new issue
Block a user