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() {