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}`, );