mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
Merge the release with the security fixes into the backup health work
This commit is contained in:
commit
da9d941a24
99 files changed
+3700
-1073
No files matched your search
+40
-30
@@ -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
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user