From 0037cc1bcdb60ab580d8dd993a2424d2ec46ddde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 27 Aug 2026 15:21:50 +0200 Subject: [PATCH] Write restored PHI 0o600 and let TRUST_PROXY_HOPS reach the container Two findings from the docs truth audit, both operator-facing. scripts/restore-backup.ts wrote the decrypted backup, which is health data in plaintext, with the process default file mode, leaving it world-readable on the operator's disk while it sat there. It is now written 0o600. TRUST_PROXY_HOPS is read from the environment to size the trusted X-Forwarded-For chain, but it was absent from the compose environment whitelist, so an operator value in .env never reached the container and was silently ignored. It now rides all four surfaces: the compose whitelist, both env examples, and the manifest that pnpm check-env reads. --- .env.example | 6 ++++++ .env.production.example | 6 ++++++ docker-compose.yml | 7 +++++++ scripts/env-manifest.json | 21 +++++++++++++-------- scripts/restore-backup.ts | 4 +++- 5 files changed, 35 insertions(+), 9 deletions(-) diff --git a/.env.example b/.env.example index 0bf9d08a8..3c02cfbec 100644 --- a/.env.example +++ b/.env.example @@ -169,6 +169,12 @@ API_TOKEN_HMAC_KEY="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32" # regex- and length-bounded), but the gating decision is up to the # operator — there is no way for the server to detect whether the flag # matches the real deployment topology. When in doubt, leave it off. +# Number of trusted reverse-proxy hops in front of the app. The client IP is +# read that many entries from the right of the X-Forwarded-For chain. Leave +# unset for a directly-exposed host; set to 1 behind a single reverse proxy. +# A wrong value makes the app refuse to read XFF and share one rate-limit +# bucket across all anonymous callers. +# TRUST_PROXY_HOPS="1" # TRUST_CF_CONNECTING_IP="" diff --git a/.env.production.example b/.env.production.example index c90fb5377..c41bdeb91 100644 --- a/.env.production.example +++ b/.env.production.example @@ -306,6 +306,12 @@ API_TOKEN_HMAC_KEY="" # address and the geo lookup resolves the wrong location. Set this to "1" # ONLY when Cloudflare is in front -- a direct-exposed self-host must leave # it unset, or an attacker could forge the header. Off by default. +# Number of trusted reverse-proxy hops in front of the app; the client IP is +# read that many entries from the right of the X-Forwarded-For chain. Unset +# for a directly-exposed host, 1 behind a single reverse proxy. A wrong +# value makes the app refuse XFF and collapse anonymous callers into one +# rate-limit bucket. +# TRUST_PROXY_HOPS="1" # TRUST_CF_CONNECTING_IP="1" # ----------------------------------------------------------------------------- diff --git a/docker-compose.yml b/docker-compose.yml index e304d1cab..36bd212bd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -75,6 +75,13 @@ x-healthlog-env: &healthlog_env # free ip-api.com endpoint) — off by default, an explicit operator choice. # Listed here so operator values in .env reach the container (the # `environment:` block is a whitelist — vars not listed never propagate). + # TRUST_PROXY_HOPS is the number of trusted reverse-proxy hops in front of + # the app: the client IP is read that many entries from the right of the + # X-Forwarded-For chain. Wrong value and the app refuses to read XFF and + # collapses every anonymous caller into one rate-limit bucket. Listed here + # so an operator value in .env reaches the container (the `environment:` + # block is a whitelist). + TRUST_PROXY_HOPS: "${TRUST_PROXY_HOPS:-}" TRUST_CF_CONNECTING_IP: "${TRUST_CF_CONNECTING_IP:-}" IP_GEO_LOOKUP_URL: "${IP_GEO_LOOKUP_URL:-}" IP_GEO_LOOKUP_DISABLED: "${IP_GEO_LOOKUP_DISABLED:-}" diff --git a/scripts/env-manifest.json b/scripts/env-manifest.json index 3bf61318d..b1d95efa4 100644 --- a/scripts/env-manifest.json +++ b/scripts/env-manifest.json @@ -1,6 +1,6 @@ { "$schema": "./env-manifest.schema.json", - "description": "Manifest of env vars required by a HealthLog production deployment. Used by `pnpm check-env` to catch missing/empty vars BEFORE a deploy ships. v1.4.42 W6 — born out of the v1.4.40 AP-2 .p8 install gap that shipped silently for 3 days.", + "description": "Manifest of env vars required by a HealthLog production deployment. Used by `pnpm check-env` to catch missing/empty vars BEFORE a deploy ships. v1.4.42 W6 \u2014 born out of the v1.4.40 AP-2 .p8 install gap that shipped silently for 3 days.", "groups": [ { "name": "Core", @@ -37,7 +37,7 @@ }, { "name": "Withings OAuth", - "description": "Withings API credentials are entered per user in Settings → Integrations (no env-var path). Only the webhook shared secret is operator-level.", + "description": "Withings API credentials are entered per user in Settings \u2192 Integrations (no env-var path). Only the webhook shared secret is operator-level.", "required": false, "variables": [ { @@ -100,7 +100,7 @@ }, { "name": "OIDC SSO options", - "description": "Optional tuning for an enabled OIDC provider. Each stands alone (deliberately NOT allOrNone — any subset is valid) and each has a safe default when unset.", + "description": "Optional tuning for an enabled OIDC provider. Each stands alone (deliberately NOT allOrNone \u2014 any subset is valid) and each has a safe default when unset.", "required": false, "variables": [ { @@ -157,7 +157,7 @@ }, { "name": "APNs (iOS push)", - "description": "All four core APNS_* vars together. Missing any disables APNs silently — the v1.4.40 AP-2 gap that motivated this manifest. allOrNone trips the synthetic required row whenever 1-3 of the 4 are set.", + "description": "All four core APNS_* vars together. Missing any disables APNs silently \u2014 the v1.4.40 AP-2 gap that motivated this manifest. allOrNone trips the synthetic required row whenever 1-3 of the 4 are set.", "required": false, "allOrNone": true, "variables": [ @@ -175,7 +175,7 @@ }, { "name": "APNS_KEY", - "purpose": "APNs auth key (.p8 contents). Use APNS_KEY for 12-factor env-block deploys; APNS_KEY_FILE is the filesystem alternative — at least one of the two must be set.", + "purpose": "APNs auth key (.p8 contents). Use APNS_KEY for 12-factor env-block deploys; APNS_KEY_FILE is the filesystem alternative \u2014 at least one of the two must be set.", "anyOf": ["APNS_KEY", "APNS_KEY_FILE"] } ] @@ -202,13 +202,13 @@ "variables": [ { "name": "TLS_LEAF_SPKI_PINS", - "purpose": "Comma-separated known-good leaf SPKI pins, base64(sha256(DER subjectPublicKeyInfo)) — the same value the native client pins. Hold both the current and the next pin during a dual-pin renewal window." + "purpose": "Comma-separated known-good leaf SPKI pins, base64(sha256(DER subjectPublicKeyInfo)) \u2014 the same value the native client pins. Hold both the current and the next pin during a dual-pin renewal window." } ] }, { "name": "SMTP (email channel)", - "description": "Operator SMTP transport for the email notification channel (v1.17.1). All of SMTP_HOST, SMTP_PORT, SMTP_FROM together enable the channel; SMTP_USER/SMTP_PASS are optional (unauthenticated relay). Missing any of the three core vars disables email silently — the channel is never offered and the Settings card hides itself.", + "description": "Operator SMTP transport for the email notification channel (v1.17.1). All of SMTP_HOST, SMTP_PORT, SMTP_FROM together enable the channel; SMTP_USER/SMTP_PASS are optional (unauthenticated relay). Missing any of the three core vars disables email silently \u2014 the channel is never offered and the Settings card hides itself.", "required": false, "allOrNone": true, "variables": [ @@ -250,7 +250,7 @@ }, { "name": "BACKUP_ENCRYPTION_KEY", - "purpose": "Separate AES key for the backup body — MUST NOT match ENCRYPTION_KEY." + "purpose": "Separate AES key for the backup body \u2014 MUST NOT match ENCRYPTION_KEY." } ] }, @@ -268,6 +268,11 @@ "name": "SESSION_COOKIE_SECURE", "purpose": "Forces the Secure flag on every cookie the app issues. Unset, it tracks NODE_ENV. Set false for a LAN / VPN-only self-host served over plain HTTP, where the default makes login look broken.", "example": "false" + }, + { + "name": "TRUST_PROXY_HOPS", + "purpose": "Trusted reverse-proxy hop count; the client IP is read that many entries from the right of X-Forwarded-For.", + "example": "1" } ] } diff --git a/scripts/restore-backup.ts b/scripts/restore-backup.ts index e49f64eba..354da0fff 100644 --- a/scripts/restore-backup.ts +++ b/scripts/restore-backup.ts @@ -115,7 +115,9 @@ async function main() { ); } - writeFileSync(out, plaintext, "utf8"); + // The restored file is decrypted PHI in plaintext. Write it 0o600 so it is + // not world-readable on the operator's disk while it sits there. + writeFileSync(out, plaintext, { encoding: "utf8", mode: 0o600 }); console.log( `Restored userId=${body.userId} (${plaintext.length} bytes) -> ${out}`, );