From 9ea0cb867fafd4b2cbc4075779def4079af7ad70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 14:08:37 +0200 Subject: [PATCH] docs(01-09): document security contract changes - define explicit MCP read scope boundaries and upgrade handling - document Nightscout private-origin pinning and reset revocation --- docs/integrations/nightscout.md | 85 +++++++++++++++++++++++++++++++++ docs/ops/security-recovery.md | 60 +++++++++++++++++++++++ docs/self-hosting/mcp.md | 10 ++++ 3 files changed, 155 insertions(+) create mode 100644 docs/integrations/nightscout.md create mode 100644 docs/ops/security-recovery.md diff --git a/docs/integrations/nightscout.md b/docs/integrations/nightscout.md new file mode 100644 index 000000000..37f32f340 --- /dev/null +++ b/docs/integrations/nightscout.md @@ -0,0 +1,85 @@ +# Nightscout integration + +HealthLog can pull continuous-glucose readings from a Nightscout instance. +Public Nightscout origins work without an allowlist. Access to a private or +local destination is an operator decision and is denied unless its exact +origin is listed in `NIGHTSCOUT_PRIVATE_ORIGINS`. + +## Security change for upgrades + +`NIGHTSCOUT_PRIVATE_ORIGINS` is a breaking security boundary. The old +per-user “allow private host” opt-in is compatibility metadata only: it no +longer grants private-network access. Existing private connections that are +not operator-approved stop syncing with the stable +`private_origin_not_approved` reason until the operator configures the exact +origin. + +Set a comma-separated list of complete origins: + +```env +NIGHTSCOUT_PRIVATE_ORIGINS="https://cgm.internal.example:443,http://10.20.30.40:1337" +``` + +Each entry is an exact normalized `scheme://host:port` trust unit: + +- only `http` and `https` are accepted; +- scheme, hostname, and effective port must all match; +- paths, query strings, fragments, embedded credentials, and wildcards are + rejected; +- approving one hostname does not approve subdomains, sibling hosts, or a + different port; and +- any malformed non-empty entry fails evaluation instead of silently + weakening the configured policy. + +Do not put a Nightscout API token, capability URL, health data, or a URL +containing credentials in the allowlist. Restart the application after +changing the environment. + +## Connect safely + +Open **Settings → Integrations → Nightscout** and enter the instance base URL +and, when required, its Nightscout API token. HealthLog stores the connection +credentials encrypted. A public, read-only Nightscout instance can omit the +token. + +For a private instance, configure the operator allowlist first and enter the +same origin in Settings. For example, if the allowlist contains +`https://cgm.internal.example:443`, these are not equivalent and remain +blocked: + +```text +http://cgm.internal.example:443 +https://cgm.internal.example:8443 +https://other.cgm.internal.example:443 +``` + +The user-controlled private-host checkbox or request field cannot override +this decision. + +## DNS pinning and redirects + +Every outbound request re-evaluates the stored origin against current server +policy. For public destinations, HealthLog resolves the hostname, rejects +private and embedded-private IPv4/IPv6 answers, and pins the approved address +for the connection so DNS cannot change between validation and use. Redirects +are validated and pinned again; a public URL cannot redirect into the local +network. + +An exact private origin is intentionally allowed to resolve privately only +because the operator named that complete origin. Removing it from +`NIGHTSCOUT_PRIVATE_ORIGINS` takes authority away on the next fetch even if an +older user preference remains stored. + +## Upgrade checklist + +1. Inventory private Nightscout connections without copying their tokens into + tickets or logs. +2. Add only the exact origins that HealthLog must reach. +3. Restart HealthLog so the environment is reloaded. +4. Test the connection from Settings and confirm a sync succeeds. +5. Remove obsolete origins promptly; do not use a broad DNS suffix or wildcard + as a substitute for explicit entries. + +If a previously working private connection reports that operator action is +required, compare its exact scheme, host, and port with the allowlist. Do not +restore access by changing the user's legacy opt-in. diff --git a/docs/ops/security-recovery.md b/docs/ops/security-recovery.md new file mode 100644 index 000000000..f78fc5342 --- /dev/null +++ b/docs/ops/security-recovery.md @@ -0,0 +1,60 @@ +# Security recovery and credential revocation + +A password reset is an account-recovery event, not just a password-hash +change. The operator reset CLI revokes every access path for the target +account so a stolen session, native refresh token, long-lived API token, +trusted device, or temporary step-up proof cannot survive the new password. + +Use the operator procedure in [Password reset](password-reset.md) and prefer +its hidden stdin prompt: + +```sh +docker compose exec app node scripts/reset-password.mjs +``` + +Do not put the new password in shell history, a ticket, chat, or logs. + +## What a successful operator reset revokes + +The CLI performs one serialized database transaction for exactly one matched +user. It: + +1. locks the target account and writes the new password hash; +2. deletes every web session; +3. revokes every API access token; +4. revokes every native refresh token; +5. deletes every trusted device; and +6. deletes every live step-up elevation. + +The safe completion message reports only the username and affected row counts. +It never prints the password, password hash, tokens, cookies, or health data. +Unrelated users are not changed. + +The in-app administrative reset follows the same target-user revocation +policy for sessions, API tokens, native refresh tokens, and trusted device +records. Operators should still use the CLI recovery path when rollback-safe +direct recovery is required. + +## Rollback guarantee + +The operator CLI wraps identity resolution, password rotation, and every +revocation above in `BEGIN`/`COMMIT`. Any error issues `ROLLBACK` before the +database connection closes. A failed reset therefore does not leave a new +password paired with old credentials, or old password state paired with only +some credentials revoked. + +Treat any non-zero exit as “nothing committed.” Correct the operational +problem and run the whole command again; do not manually complete a subset of +the SQL statements. + +## After recovery + +- Have the user sign in with the new password and enroll replacement trusted + devices intentionally. +- Expect every browser and native client to require a fresh login. +- Reissue only the minimum API or MCP scopes each integration needs. +- Review security activity without copying credential material into the + incident record. +- If compromise is suspected beyond one account, rotate the affected + instance-level secrets using the relevant operator runbooks before + restoring integrations. diff --git a/docs/self-hosting/mcp.md b/docs/self-hosting/mcp.md index db632db0b..9afc189f3 100644 --- a/docs/self-hosting/mcp.md +++ b/docs/self-hosting/mcp.md @@ -70,6 +70,16 @@ scope — it is the least-privilege MCP scope and nothing more, and it is bound to the MCP surface: it is refused on every REST route. The same list shows last-used time and lets you revoke any token. +MCP reads now require an explicit MCP read capability. A token must carry +`health:read` (or the legacy/native `*` wildcard) before the server registers +or invokes any read tool. `health:write`, `fhir:read`, +`medication:ingest`, notification scopes, and every other unrelated scope do +not imply `health:read`; presenting one of those narrow tokens to `/mcp` is +rejected before health data is accessed. Likewise, `health:read` does not +grant a regular REST read or any write. When upgrading, check any manually +provisioned connector token and mint a replacement with `health:read` instead +of broadening an unrelated credential. + Cloud connectors that use OAuth (next section) mint the same `health:read` scope automatically, so for Claude.ai / ChatGPT you do not need to mint a token by hand — the manual token is for the stdio / power-