Merge the release with the security fixes into the backup health work

This commit is contained in:
Marc-André Bombeck committed 2026-10-25 15:03:17 +01:00
commit da9d941a24
99 files changed
+3700 -1073

No files matched your search

+40 -30
View File
@@ -24863,9 +24863,9 @@ paths:
**Auth is Bearer only.** A cookie session does not authenticate here: the route reads the `Authorization` header
itself, hashes it and looks the token up, so a browser with a valid login and no token gets 401. The token must
carry either the `*` wildcard or the `medication:ingest` scope — AND, once the named medication resolves, either
`*` or that medication's own `medication:{id}:ingest` scope. The second check runs after the lookup, which is
why a token scoped to one medication gets 404 for a name it may not touch only when the name does not exist, and
403 when it does.
`*` or that medication's own `medication:{id}:ingest` scope. The second check runs after the lookup. A token
scoped to one medication gets the same 403 for a name that does not exist as for one outside its scope, so it
cannot be used to learn which medications exist; only a `*` token gets 404 for an unknown name.
**The operator can switch this off.** With the global API kill switch disabled every request is refused with 403
@@ -24873,8 +24873,9 @@ paths:
**Replay is by `idempotencyKey`, and the status says which happened.** A key already stored for this token's
owner returns the existing row with **200**; a fresh dose returns **201**. Nothing else distinguishes them —
same body shape, same fields — so branch on the status code.
owner and the same medication returns the existing row with **200**; a fresh dose returns **201**; a key already
used for another medication returns **409** without its row. Replay is answered only after both scope checks.
Nothing else distinguishes them — same body shape, same fields — so branch on the status code.
**A dose is attributed to its scheduled slot, not stamped ad-hoc.** When the take falls inside a schedule's
@@ -24918,14 +24919,22 @@ paths:
$ref: "#/components/schemas/ErrorEnvelope"
"403":
description: Refused. Either the operator has the global API switch off (the message says so), or the token lacks
`medication:ingest` / `*`, or it lacks the resolved medication's own `medication:{id}:ingest` / `*` scope.
`medication:ingest` / `*`, or it lacks the resolved medication's own `medication:{id}:ingest` / `*` scope. A
token without `*` also gets this answer for a name that matches no active medication.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"404":
description: No ACTIVE medication of the token owner's matches `medicationName`. An inactive medication of the right
name is also a 404 — the name is only searched among active ones.
description: "Only for a `*` token: no ACTIVE medication of the token owner's matches `medicationName`. An inactive
medication of the right name is also a 404 — the name is only searched among active ones."
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"409":
description: The `idempotencyKey` is already stored for a dose of another medication. Nothing was written and that dose
is not returned.
content:
application/json:
schema:
@@ -38583,28 +38592,29 @@ components:
profile_claim.oidc_only_unsupported, profile_claim.sso_unavailable, profile_claim.taken; record_write —
record_write.rate_limited; recovery — recovery.kit.stale; report-selection —
report-selection.body.invalid_json, report-selection.body.invalid_shape,
report-selection.leaves.unknown; route — route.retired; search — search.invalid; security_activity —
security_activity.invalid; share-link — share-link.document.decryptFailed, share-link.document.notFound,
share-link.document.rateLimited, share-link.documents.invalid, share-link.report.notFound,
share-link.report.rateLimited, share-link.selection.forbidden_leaf, share-link.selection.unknown_leaf;
sharing — sharing.accept.expired, sharing.accept.not_found, sharing.accept.not_pending,
sharing.accept.revoked, sharing.access.denied, sharing.invite.duplicate, sharing.invite.invalid,
sharing.invite.invalid_scope, sharing.invite.manage_browser_only, sharing.invite.no_account,
sharing.invite.refused, sharing.invite.self, sharing.not_permitted, sharing.renounce.already_ended,
sharing.renounce.not_found, sharing.revoke.already_ended, sharing.revoke.not_found,
sharing.session.changed, sharing.switch.invalid, sharing.switch.wrong_transport; source-priority —
source-priority.body.invalid_json, source-priority.body.invalid_shape; symptoms —
symptoms.definition.limitReached, symptoms.definition.notFound, symptoms.episode.notOpen,
symptoms.event.notFound, symptoms.invalid; timeline — timeline.invalid; tokens —
tokens.documents.ceiling_reached, tokens.measurements.ceiling_reached,
tokens.mirror.not_measurement_token, tokens.workouts.ceiling_reached; vaccination —
vaccination.booster-invalid, vaccination.booster-no-antigen, vaccination.booster-out-of-scope,
vaccination.custom-vaccine-not-found, vaccination.custom.invalid, vaccination.custom.name-taken,
vaccination.custom.not-found, vaccination.encounter-not-found, vaccination.identity-required,
vaccination.invalid, vaccination.link.invalid, vaccination.not-found,
vaccination.practitioner-not-found; workout — workout.batch.invalid, workout.batch.payload_too_large,
workout.batch.source_not_permitted, workout.batch.too_large, workout.delete.synced_source; (unprefixed)
— about_me_conflict, ai_response_truncated, coach_prefs_conflict, dashboard_layout_conflict, empty_file,
report-selection.leaves.unknown; request — request.cross_site; route — route.retired; search —
search.invalid; security_activity — security_activity.invalid; share-link —
share-link.document.decryptFailed, share-link.document.notFound, share-link.document.rateLimited,
share-link.documents.invalid, share-link.report.notFound, share-link.report.rateLimited,
share-link.selection.forbidden_leaf, share-link.selection.unknown_leaf; sharing —
sharing.accept.expired, sharing.accept.not_found, sharing.accept.not_pending, sharing.accept.revoked,
sharing.access.denied, sharing.invite.duplicate, sharing.invite.invalid, sharing.invite.invalid_scope,
sharing.invite.manage_browser_only, sharing.invite.no_account, sharing.invite.refused,
sharing.invite.self, sharing.not_permitted, sharing.renounce.already_ended, sharing.renounce.not_found,
sharing.revoke.already_ended, sharing.revoke.not_found, sharing.session.changed, sharing.switch.invalid,
sharing.switch.wrong_transport; source-priority — source-priority.body.invalid_json,
source-priority.body.invalid_shape; symptoms — symptoms.definition.limitReached,
symptoms.definition.notFound, symptoms.episode.notOpen, symptoms.event.notFound, symptoms.invalid;
timeline — timeline.invalid; tokens — tokens.documents.ceiling_reached,
tokens.measurements.ceiling_reached, tokens.mirror.not_measurement_token,
tokens.workouts.ceiling_reached; vaccination — vaccination.booster-invalid,
vaccination.booster-no-antigen, vaccination.booster-out-of-scope, vaccination.custom-vaccine-not-found,
vaccination.custom.invalid, vaccination.custom.name-taken, vaccination.custom.not-found,
vaccination.encounter-not-found, vaccination.identity-required, vaccination.invalid,
vaccination.link.invalid, vaccination.not-found, vaccination.practitioner-not-found; workout —
workout.batch.invalid, workout.batch.payload_too_large, workout.batch.source_not_permitted,
workout.batch.too_large, workout.delete.synced_source; (unprefixed) — about_me_conflict,
ai_response_truncated, coach_prefs_conflict, 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,
+30 -23
View File
@@ -522,29 +522,36 @@ low-friction default.
## Deploy-status webhook: replay protection
`POST /api/internal/deploy-webhook` authenticates via the static
`X-Deploy-Webhook-Secret` header. Since v1.16.4 it additionally
enforces a freshness window: a request carrying an
`X-Deploy-Webhook-Timestamp` header (unix seconds, unix milliseconds,
or ISO-8601) is rejected with `401 {"status":"stale"}` when the
timestamp lies more than **5 minutes** from server time — so a captured
request cannot be replayed later to re-page admins or forge deploy
outcomes in the audit log.
`POST /api/internal/deploy-webhook` accepts two kinds of request.
Two modes:
- **Secret header.** `X-Deploy-Webhook-Secret` carries the shared secret.
This is what Coolify's stock notification sender can do. It does not
protect against a replay: anyone who captures one request holds the
secret and can send any request they like. An `X-Deploy-Webhook-Timestamp`
beside it is still checked (a stale or unparseable one is refused with
`401 {"status":"stale"}`), but it is covered by nothing, so treat it as a
staleness hint. Versions before 1.46 described it as replay protection.
- **Signature.** `X-Deploy-Webhook-Signature: sha256=<hex>` is the
HMAC-SHA256, under `DEPLOY_WEBHOOK_SECRET`, of the
`X-Deploy-Webhook-Timestamp` value, a dot, and the exact request body. The
timestamp (unix seconds, unix milliseconds or ISO-8601) must lie within
**5 minutes** of server time. A signed request sends no secret, and a
captured one is useless once the window has passed.
- **Default (tolerant).** Requests _without_ the header pass — Coolify's
stock notification sender does not attach one. A header that is
present but stale or unparseable is always rejected.
- **Strict.** Set `DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP=true` in the app
environment to make the header mandatory, closing the replay window
completely. Use this when the sender (or a reverse proxy in front of
the app) can attach the timestamp, e.g.:
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:
```sh
curl -X POST https://<host>/api/internal/deploy-webhook \
-H "X-Deploy-Webhook-Secret: $SECRET" \
-H "X-Deploy-Webhook-Timestamp: $(date +%s)" \
-H "Content-Type: application/json" \
-d '{"status":"success"}'
```
```sh
TS=$(date +%s)
BODY='{"status":"success"}'
SIG=$(printf '%s.%s' "$TS" "$BODY" \
| openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.*= //')
curl -X POST https://<host>/api/internal/deploy-webhook \
-H "X-Deploy-Webhook-Timestamp: $TS" \
-H "X-Deploy-Webhook-Signature: sha256=$SIG" \
-H "Content-Type: application/json" \
-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.
+7 -3
View File
@@ -58,9 +58,13 @@ Anything written after the move lives only in the Postgres 18 volume.
## Afterwards
Keep the old volume until the new database has run for a while. When you are
sure, remove it (`docker volume ls` shows its full name, prefixed with the
compose project):
Keep the old volume until the new database has run for a while, then remove
it. It is a complete copy of the database as it stood before the move, and
nothing that happens afterwards reaches it: an account deleted, a document
purged from the trash, or a key rotated after a leak is still there in the
old volume, and so is everything else for anyone who can read the host's
Docker volumes. Removing it is part of the move, not optional tidying.
`docker volume ls` shows its full name, prefixed with the compose project:
```bash
docker volume rm <project>_pgdata
+20 -7
View File
@@ -40,22 +40,35 @@ chain from the **right**, counting back `TRUST_PROXY_HOPS` entries.
This guards against a client rotating `X-Forwarded-For` per request
to defeat IP-based rate limits.
| Topology | `TRUST_PROXY_HOPS` |
| --------------------------------------------------------- | ------------------------------------- |
| App is internet-facing with no proxy | `0` (no forwarding header is trusted) |
| Single proxy in front (Caddy / Traefik / Nginx / Coolify) | `1` (default) |
| Cloudflare → your proxy → app | `2` |
| Cloudflare → Coolify-Tunnel → Coolify → app | `3` |
| Topology | `TRUST_PROXY_HOPS` |
| --------------------------------------------------------- | ---------------------------------- |
| App is internet-facing with no proxy | `0` (the connection's own address) |
| Single proxy in front (Caddy / Traefik / Nginx / Coolify) | `1` (default) |
| Cloudflare → your proxy → app | `2` |
| Cloudflare → Coolify-Tunnel → Coolify → app | `3` |
A misconfigured count logs a one-shot warning to stderr and collapses
every anonymous caller into one shared rate-limit bucket. Match the
value to the actual hop count.
Forwarding headers are believed only from a connection that could come
from a proxy: loopback, a private or unique-local network, or CGNAT (a
Tailscale address). 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 (or, for
Cloudflare straight to the origin, use `TRUST_CF_CONNECTING_IP=1`). 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.
`X-Real-IP` gets the same trust as `X-Forwarded-For`. It is read only
when `TRUST_PROXY_HOPS` is exactly `1` and the request carries no
`X-Forwarded-For`, which covers a single proxy that sets only
`X-Real-IP`. With `0` neither header is believed, because without a proxy
in front both come from the caller. With `2` or more, the hop count
in front both come from the caller; the address the connection came from
is used instead. With `2` or more, the hop count
describes an `X-Forwarded-For` chain, so have your proxies send one.
If requests arrive with a forwarding header the setting does not let