From afcb37e76c0ad523ac77f71372e52cd5d4c4faa6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 26 Sep 2026 15:33:34 +0200 Subject: [PATCH] feat(config): let the operator turn off the breach and update checks Two requests left the server without anything being connected: the HaveIBeenPwned range query on every password set (registration, change, admin reset) and the daily GitHub releases request behind the About page and the admin version tile. Neither could be turned off, which made "nothing leaves" untrue for an instance that wanted it to be. PASSWORD_BREACH_CHECK_DISABLED and UPDATE_CHECK_DISABLED (1, true, yes or on) stop each request. Both stay on by default. The breach check keeps its fail-open contract: switched off, a password is simply not looked up. The update route answers status "unknown" with reason "disabled" and the admin tile says the check is off. The password routes reach the check only through the switch, and a test holds that import line. Both are on the compose whitelist, in the env manifest under a new "Outbound requests" group with IP_GEO_LOOKUP_DISABLED, and documented in both env examples and the getting-started guide. --- .env.example | 9 +++ .env.production.example | 20 ++++++ docker-compose.yml | 8 +++ docs/self-hosting/getting-started.md | 15 ++++- messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/ko.json | 1 + messages/pl.json | 1 + scripts/env-manifest.json | 19 ++++++ .../password-breach-check-switch.test.ts | 66 +++++++++++++++++++ .../admin/users/[id]/reset-password/route.ts | 4 +- src/app/api/auth/password/route.ts | 4 +- src/app/api/auth/register/route.ts | 4 +- .../check-updates/__tests__/route.test.ts | 47 +++++++++++++ src/app/api/version/check-updates/route.ts | 16 +++++ src/components/admin/version-tile-section.tsx | 7 ++ src/lib/openapi/routes/meta.ts | 3 +- src/lib/password-breach-check.ts | 29 ++++++++ 21 files changed, 248 insertions(+), 10 deletions(-) create mode 100644 src/__tests__/password-breach-check-switch.test.ts create mode 100644 src/app/api/version/check-updates/__tests__/route.test.ts create mode 100644 src/lib/password-breach-check.ts diff --git a/.env.example b/.env.example index aa5a7e3c3..441da892d 100644 --- a/.env.example +++ b/.env.example @@ -292,6 +292,15 @@ API_TOKEN_HMAC_KEY="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32" # DEPLOY_WEBHOOK_SECRET="" +# ----------------------------------------------------------------------------- +# Outbound requests HealthLog makes on its own (optional switches) +# ----------------------------------------------------------------------------- +# Each accepts "1", "true" or "yes". See .env.production.example for detail. +# PASSWORD_BREACH_CHECK_DISABLED="1" # no HaveIBeenPwned range query when a password is set +# UPDATE_CHECK_DISABLED="1" # no daily GitHub releases request +# IP_GEO_LOOKUP_DISABLED="1" # no online IP geolocation for login events + + # ----------------------------------------------------------------------------- # IP geolocation (optional) # ----------------------------------------------------------------------------- diff --git a/.env.production.example b/.env.production.example index b704fcb83..8925281e4 100644 --- a/.env.production.example +++ b/.env.production.example @@ -457,6 +457,26 @@ API_TOKEN_HMAC_KEY="" # TRUST_PROXY_HOPS="1" # TRUST_CF_CONNECTING_IP="1" +# ----------------------------------------------------------------------------- +# Outbound requests HealthLog makes on its own -- optional switches +# ----------------------------------------------------------------------------- +# Besides the services you connect yourself (AI provider, integrations, +# notification channels, off-host backups), the server contacts three third +# parties by default. Each has an off-switch that accepts "1", "true" or "yes": +# +# - HaveIBeenPwned, when a password is set (registration, change, admin +# reset). Only the first five hex characters of the password's SHA-1 are +# sent (k-anonymity); the password and its full hash never leave. With the +# switch on, known-breached passwords are no longer refused. +# PASSWORD_BREACH_CHECK_DISABLED="1" +# +# - The GitHub releases API, at most once a day per browser, for the "update +# available" badge on the About page and the admin version tile. +# UPDATE_CHECK_DISABLED="1" +# +# - The online IP geolocation provider for login events; see +# IP_GEO_LOOKUP_DISABLED in the next section. + # ----------------------------------------------------------------------------- # IP geolocation lookup -- optional # ----------------------------------------------------------------------------- diff --git a/docker-compose.yml b/docker-compose.yml index a24b90ba0..37b3379bd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -102,6 +102,14 @@ x-healthlog-env: &healthlog_env IP_GEO_LOOKUP_URL: "${IP_GEO_LOOKUP_URL:-}" IP_GEO_LOOKUP_DISABLED: "${IP_GEO_LOOKUP_DISABLED:-}" IP_GEO_ALLOW_INSECURE: "${IP_GEO_ALLOW_INSECURE:-}" + # Outbound requests an operator can turn off (1 / true / yes). Both are on + # by default. PASSWORD_BREACH_CHECK_DISABLED stops the HaveIBeenPwned range + # query made when a password is set (only the first five characters of the + # password's SHA-1 are sent). UPDATE_CHECK_DISABLED stops the daily request + # to the GitHub releases API behind the About page and the admin version + # tile. Listed here so an operator value in .env reaches the container. + PASSWORD_BREACH_CHECK_DISABLED: "${PASSWORD_BREACH_CHECK_DISABLED:-}" + UPDATE_CHECK_DISABLED: "${UPDATE_CHECK_DISABLED:-}" # Directory the offline GeoLite2 databases are read from. Default # `/opt/geolite2` — the path the published image bakes its own copy # into, if the build had a MaxMind licence key. Point this at a diff --git a/docs/self-hosting/getting-started.md b/docs/self-hosting/getting-started.md index 0d7c8871d..af157eb85 100644 --- a/docs/self-hosting/getting-started.md +++ b/docs/self-hosting/getting-started.md @@ -21,6 +21,13 @@ already installed, longer if a TLS-fronted public hostname is in scope weather and daylight; it stays off until you enable it and set a home location, and every outbound call goes through the SSRF-guarded egress wrapper. + Three requests happen by default without anything connected: the + HaveIBeenPwned range query when a password is set (only the first five + characters of the password's SHA-1 are sent), a daily check of the + GitHub releases API for the update badge, and the online IP + geolocation lookup for login events. `PASSWORD_BREACH_CHECK_DISABLED`, + `UPDATE_CHECK_DISABLED` and `IP_GEO_LOOKUP_DISABLED` turn each off; see + `.env.production.example`. - **Optional but recommended:** a reverse proxy that terminates TLS and forwards to `http://localhost:3000` (Caddy, Traefik, Nginx Proxy Manager, Coolify, or bare Nginx — see `reverse-proxy.md`). @@ -33,9 +40,11 @@ cd HealthLog cp .env.example .env ``` -`.env.example` is the canonical reference for every env var the app -reads. Anything you do not uncomment falls back to a sane default or -disables the optional subsystem. +`.env.example` is a short starter with the variables most self-hosts +touch. The complete reference, with every variable the app reads and what +it does, is `.env.production.example`; CI checks that file against +`scripts/env-manifest.json`. Anything you do not set falls back to a sane +default or leaves the optional subsystem off. ## 2. Generate the three required secrets diff --git a/messages/de.json b/messages/de.json index 90a936194..f2b860aed 100644 --- a/messages/de.json +++ b/messages/de.json @@ -7063,6 +7063,7 @@ "versionTileBuiltAt": "Gebaut am", "versionTileUpdateAvailable": "Update verfügbar: {tag}", "versionTileUpToDate": "Aktuell", + "versionTileCheckOff": "Update-Prüfung aus", "snapshotLoadError": "System-Snapshot konnte nicht geladen werden.", "snapshotLoading": "Snapshot wird geladen…", "auditTitle": "Letzte Aktivitäten", diff --git a/messages/en.json b/messages/en.json index 0a50cd1f5..d52bd5e17 100644 --- a/messages/en.json +++ b/messages/en.json @@ -7063,6 +7063,7 @@ "versionTileBuiltAt": "Built at", "versionTileUpdateAvailable": "Update available: {tag}", "versionTileUpToDate": "Up to date", + "versionTileCheckOff": "Update check off", "snapshotLoadError": "Failed to load system snapshot.", "snapshotLoading": "Loading snapshot…", "auditTitle": "Recent activity", diff --git a/messages/es.json b/messages/es.json index 315aa94eb..dab19e140 100644 --- a/messages/es.json +++ b/messages/es.json @@ -7063,6 +7063,7 @@ "versionTileBuiltAt": "Compilado el", "versionTileUpdateAvailable": "Actualización disponible: {tag}", "versionTileUpToDate": "Actualizado", + "versionTileCheckOff": "Comprobación de actualizaciones desactivada", "snapshotLoadError": "No se pudo cargar la instantánea del sistema.", "snapshotLoading": "Cargando instantánea...", "auditTitle": "Actividad reciente", diff --git a/messages/fr.json b/messages/fr.json index 51d24a76c..fcdfbf196 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -7063,6 +7063,7 @@ "versionTileBuiltAt": "Compilé le", "versionTileUpdateAvailable": "Mise à jour disponible : {tag}", "versionTileUpToDate": "À jour", + "versionTileCheckOff": "Vérification des mises à jour désactivée", "snapshotLoadError": "Échec du chargement de l'instantané du système.", "snapshotLoading": "Chargement de l'instantané...", "auditTitle": "Activité récente", diff --git a/messages/it.json b/messages/it.json index b5c300b6a..b5c2bcd04 100644 --- a/messages/it.json +++ b/messages/it.json @@ -7063,6 +7063,7 @@ "versionTileBuiltAt": "Compilato il", "versionTileUpdateAvailable": "Aggiornamento disponibile: {tag}", "versionTileUpToDate": "Aggiornato", + "versionTileCheckOff": "Controllo aggiornamenti disattivato", "snapshotLoadError": "Impossibile caricare l'istantanea del sistema.", "snapshotLoading": "Caricamento istantanea...", "auditTitle": "Attività recente", diff --git a/messages/ko.json b/messages/ko.json index e0052fa0c..f28a5f4bb 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -7042,6 +7042,7 @@ "versionTileBuiltAt": "빌드 시각", "versionTileUpdateAvailable": "업데이트 있음: {tag}", "versionTileUpToDate": "최신 상태", + "versionTileCheckOff": "업데이트 확인 꺼짐", "snapshotLoadError": "시스템 스냅샷을 불러오지 못했어요.", "snapshotLoading": "스냅샷 불러오는 중…", "auditTitle": "최근 활동", diff --git a/messages/pl.json b/messages/pl.json index 8fc46cd3d..022fc7adb 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -7063,6 +7063,7 @@ "versionTileBuiltAt": "Skompilowano", "versionTileUpdateAvailable": "Dostępna aktualizacja: {tag}", "versionTileUpToDate": "Aktualne", + "versionTileCheckOff": "Sprawdzanie aktualizacji wyłączone", "snapshotLoadError": "Nie udało się załadować migawki systemu.", "snapshotLoading": "Ładowanie migawki...", "auditTitle": "Ostatnia aktywność", diff --git a/scripts/env-manifest.json b/scripts/env-manifest.json index 11fcfadb5..ece348465 100644 --- a/scripts/env-manifest.json +++ b/scripts/env-manifest.json @@ -163,6 +163,25 @@ } ] }, + { + "name": "Outbound requests", + "description": "Switches for the requests HealthLog makes to third parties on its own. All default to on; set any of them to 1, true or yes to turn that request off.", + "required": false, + "variables": [ + { + "name": "IP_GEO_LOOKUP_DISABLED", + "purpose": "Stops the online IP-to-location lookup for login events and the monthly MaxMind download. Only the local GeoLite2 databases are used." + }, + { + "name": "PASSWORD_BREACH_CHECK_DISABLED", + "purpose": "Stops the HaveIBeenPwned range query made when a password is set. The query carries the first five hex characters of the password's SHA-1, never the password or the full hash." + }, + { + "name": "UPDATE_CHECK_DISABLED", + "purpose": "Stops the daily request to the GitHub releases API behind the About page and the admin version tile." + } + ] + }, { "name": "APNs (iOS push)", "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.", diff --git a/src/__tests__/password-breach-check-switch.test.ts b/src/__tests__/password-breach-check-switch.test.ts new file mode 100644 index 000000000..622c5d92d --- /dev/null +++ b/src/__tests__/password-breach-check-switch.test.ts @@ -0,0 +1,66 @@ +/** + * The HaveIBeenPwned check sits behind one operator switch, and nothing + * reaches it except through that switch. + * + * `PASSWORD_BREACH_CHECK_DISABLED` is how an operator stops the one request + * a password change makes to a third party. A route that imported the raw + * check from `@/lib/auth/hibp` would keep sending it with the switch on, so + * the import itself is frozen: only `src/lib/password-breach-check.ts` (and + * tests) may name that module. + */ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { walkSourceFiles } from "./helpers/source-files"; + +const checkPasswordBreach = vi.fn(); +vi.mock("@/lib/auth/hibp", () => ({ + checkPasswordBreach: (password: string) => checkPasswordBreach(password), +})); + +const SRC = join(process.cwd(), "src"); +const RAW_IMPORT = /from\s+["']@\/lib\/auth\/hibp["']|["']\.\/hibp["']/; + +afterEach(() => { + vi.unstubAllEnvs(); + checkPasswordBreach.mockReset(); +}); + +describe("password breach check switch", () => { + it("calls the range API when the switch is unset or empty", async () => { + checkPasswordBreach.mockResolvedValue({ breached: true, count: 3 }); + const { checkPasswordBreachIfEnabled } = + await import("@/lib/password-breach-check"); + vi.stubEnv("PASSWORD_BREACH_CHECK_DISABLED", ""); + await expect( + checkPasswordBreachIfEnabled("correct horse battery"), + ).resolves.toEqual({ breached: true, count: 3 }); + expect(checkPasswordBreach).toHaveBeenCalledTimes(1); + }); + + it.each(["1", "true", "yes"])( + "makes no request when PASSWORD_BREACH_CHECK_DISABLED=%s", + async (value) => { + vi.stubEnv("PASSWORD_BREACH_CHECK_DISABLED", value); + const { checkPasswordBreachIfEnabled } = + await import("@/lib/password-breach-check"); + await expect( + checkPasswordBreachIfEnabled("correct horse battery"), + ).resolves.toBeNull(); + expect(checkPasswordBreach).not.toHaveBeenCalled(); + }, + ); + + it("is the only non-test importer of the raw check", () => { + const importers = walkSourceFiles(SRC, { floor: 3000 }) + .filter((rel) => !rel.startsWith("generated/")) + .filter((rel) => !rel.includes("__tests__")) + .filter((rel) => !rel.endsWith(".test.ts")) + .filter((rel) => rel !== "lib/auth/hibp.ts") + .filter((rel) => RAW_IMPORT.test(readFileSync(join(SRC, rel), "utf8"))); + // Non-empty by construction: the wrapper itself must match, or the + // matcher went stale and this assertion would pass on nothing. + expect(importers).toEqual(["lib/password-breach-check.ts"]); + }); +}); diff --git a/src/app/api/admin/users/[id]/reset-password/route.ts b/src/app/api/admin/users/[id]/reset-password/route.ts index 024ccb29a..11a6a40c7 100644 --- a/src/app/api/admin/users/[id]/reset-password/route.ts +++ b/src/app/api/admin/users/[id]/reset-password/route.ts @@ -14,7 +14,7 @@ import { destroyAllSessions } from "@/lib/auth/session"; import { revokeStepUpElevations } from "@/lib/auth/step-up"; import { NextRequest, NextResponse } from "next/server"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; -import { checkPasswordBreach } from "@/lib/auth/hibp"; +import { checkPasswordBreachIfEnabled } from "@/lib/password-breach-check"; import { getServerTranslator } from "@/lib/i18n/server-translator"; export const POST = apiHandler( @@ -70,7 +70,7 @@ export const POST = apiHandler( // v1.23 — reject an admin-set password found in a known breach corpus // (HIBP k-anonymity). Fail-open on an unreachable HIBP. - const breach = await checkPasswordBreach(password); + const breach = await checkPasswordBreachIfEnabled(password); if (breach?.breached) { return apiError( getServerTranslator(locale).t("auth.passwordBreached"), diff --git a/src/app/api/auth/password/route.ts b/src/app/api/auth/password/route.ts index ae97c2792..b7b66d697 100644 --- a/src/app/api/auth/password/route.ts +++ b/src/app/api/auth/password/route.ts @@ -24,7 +24,7 @@ import { annotate } from "@/lib/logging/context"; import { destroyAllSessions, createSession } from "@/lib/auth/session"; import { revokeStepUpElevations } from "@/lib/auth/step-up"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; -import { checkPasswordBreach } from "@/lib/auth/hibp"; +import { checkPasswordBreachIfEnabled } from "@/lib/password-breach-check"; import { getServerTranslator } from "@/lib/i18n/server-translator"; export const POST = apiHandler(async (request: NextRequest) => { @@ -89,7 +89,7 @@ export const POST = apiHandler(async (request: NextRequest) => { // corpus (HIBP k-anonymity). Fail-open: a null result (HIBP unreachable) // never blocks the change. Only the user's chosen NEW password is checked — // existing credentials are never retroactively blocked at login. - const breach = await checkPasswordBreach(newPassword); + const breach = await checkPasswordBreachIfEnabled(newPassword); if (breach?.breached) { return apiError( getServerTranslator(locale).t("auth.passwordBreached"), diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index d89662cbd..2c4b5623c 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -21,7 +21,7 @@ import { NextRequest, NextResponse } from "next/server"; import { apiHandler } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; -import { checkPasswordBreach } from "@/lib/auth/hibp"; +import { checkPasswordBreachIfEnabled } from "@/lib/password-breach-check"; import { getServerTranslator } from "@/lib/i18n/server-translator"; import { isValidTimezone, @@ -173,7 +173,7 @@ export const POST = apiHandler(async (request: NextRequest) => { // v1.23 — reject a registration password found in a known breach corpus // (HIBP k-anonymity). Fail-open on an unreachable HIBP. - const breach = await checkPasswordBreach(password); + const breach = await checkPasswordBreachIfEnabled(password); if (breach?.breached) { return apiError( getServerTranslator(locale).t("auth.passwordBreached"), diff --git a/src/app/api/version/check-updates/__tests__/route.test.ts b/src/app/api/version/check-updates/__tests__/route.test.ts new file mode 100644 index 000000000..aea5e067b --- /dev/null +++ b/src/app/api/version/check-updates/__tests__/route.test.ts @@ -0,0 +1,47 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: unknown>(fn: T) => fn, + requireAuth: vi.fn().mockResolvedValue({ user: { id: "u1" } }), +})); + +const safeFetchMock = vi.fn(); +vi.mock("@/lib/safe-fetch", () => ({ + safeFetch: (...args: unknown[]) => safeFetchMock(...args), +})); + +import { GET } from "../route"; + +const call = () => (GET as unknown as () => Promise)(); + +afterEach(() => { + vi.unstubAllEnvs(); + safeFetchMock.mockReset(); +}); + +describe("GET /api/version/check-updates", () => { + it.each(["1", "true", "yes"])( + "makes no outbound request when UPDATE_CHECK_DISABLED=%s", + async (value) => { + vi.stubEnv("UPDATE_CHECK_DISABLED", value); + const body = (await (await call()).json()) as { + data: { status: string; reason: string }; + }; + expect(body.data.status).toBe("unknown"); + expect(body.data.reason).toBe("disabled"); + expect(safeFetchMock).not.toHaveBeenCalled(); + }, + ); + + it("asks the release feed when the switch is unset or empty", async () => { + vi.stubEnv("UPDATE_CHECK_DISABLED", ""); + safeFetchMock.mockResolvedValue( + Response.json({ tag_name: "v999.0.0", html_url: "https://x.test" }), + ); + const body = (await (await call()).json()) as { + data: { status: string }; + }; + expect(safeFetchMock).toHaveBeenCalledTimes(1); + expect(body.data.status).toBe("newer_available"); + }); +}); diff --git a/src/app/api/version/check-updates/route.ts b/src/app/api/version/check-updates/route.ts index 4ed7a645b..8681fe3b5 100644 --- a/src/app/api/version/check-updates/route.ts +++ b/src/app/api/version/check-updates/route.ts @@ -1,5 +1,6 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiError, apiSuccess } from "@/lib/api-response"; +import { envFlag } from "@/lib/env"; import { annotate } from "@/lib/logging/context"; import { getEvent } from "@/lib/logging/context"; import { safeFetch } from "@/lib/safe-fetch"; @@ -27,6 +28,12 @@ export const dynamic = "force-dynamic"; * repo missing the release manifest); UI surfaces a non-blocking * warning so the user can retry * + * `UPDATE_CHECK_DISABLED` (1, true, yes or on) stops the request to GitHub + * entirely: the route answers `unknown` with `reason: "disabled"` and makes + * no outbound call. The About page and the admin version tile run this check + * on their own once a day, so an instance that promises no third-party + * requests needs the switch. + * * Responses are not cached at the route level. The browser bears the * load with React Query's `staleTime`, so a single user spamming * "Check now" still hits the GH API at most every 30 seconds. @@ -78,6 +85,15 @@ export const GET = apiHandler(async () => { const current = packageJson.version; + if (envFlag("UPDATE_CHECK_DISABLED")) { + annotate({ meta: { outcome: "disabled" } }); + return apiSuccess({ + status: "unknown" as const, + current, + reason: "disabled", + }); + } + let release: GithubReleaseShape | null = null; const callStart = Date.now(); try { diff --git a/src/components/admin/version-tile-section.tsx b/src/components/admin/version-tile-section.tsx index 6220499b9..36648bf7f 100644 --- a/src/components/admin/version-tile-section.tsx +++ b/src/components/admin/version-tile-section.tsx @@ -80,6 +80,9 @@ export function VersionTileSection() { const newer = update?.status === "newer_available"; const upToDate = update?.status === "up_to_date"; + // The operator turned the GitHub request off (UPDATE_CHECK_DISABLED); say + // so rather than leaving the slot empty like an unreachable feed. + const checkOff = update?.status === "unknown" && update.reason === "disabled"; const sha = shortSha(version?.buildSha ?? null); return ( @@ -110,6 +113,10 @@ export function VersionTileSection() {