From c6d2b212f2c34d9ddf79b5f83532291dff5f9505 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 26 May 2026 19:16:44 +0200 Subject: [PATCH] fix(auth): self-hoster opt-out for session-cookie Secure flag (v1.5.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v1.5.1 patch. Fixes the silent login failure self-hosters hit on plain-HTTP origins that aren't localhost — the bundled Docker image runs NODE_ENV=production, so every cookie carried Secure, and modern browsers drop those on http://10.x.x.x style origins. The session cookie never made it back to the browser, /api/auth/me returned 401, and the login page reloaded. New SESSION_COOKIE_SECURE env var with three values: - unset (default) — flag follows NODE_ENV === "production" (unchanged) - "false" — never set (LAN / VPN / Tailscale-only opt-out) - "true" — always set (pnpm dev fronted by HTTPS) Shared helper at src/lib/auth/secure-cookie.ts replaces four scattered literals across session, codex device-start, and withings connect. Six unit cases pin the decision matrix. OpenAPI spec generator now reads info.version from package.json so the drift gate stays meaningful instead of catching every release bump. Default behaviour unchanged for HTTPS-fronted deployments. --- .env.example | 10 ++++ CHANGELOG.md | 22 +++++++++ docs/api/openapi.yaml | 2 +- docs/self-hosting/reverse-proxy.md | 34 ++++++++++++++ package.json | 2 +- src/app/api/auth/codex/device-start/route.ts | 3 +- src/app/api/withings/connect/route.ts | 3 +- src/lib/auth/__tests__/secure-cookie.test.ts | 49 ++++++++++++++++++++ src/lib/auth/secure-cookie.ts | 30 ++++++++++++ src/lib/auth/session.ts | 7 +-- src/lib/openapi/registry.ts | 7 ++- 11 files changed, 161 insertions(+), 8 deletions(-) create mode 100644 src/lib/auth/__tests__/secure-cookie.test.ts create mode 100644 src/lib/auth/secure-cookie.ts diff --git a/.env.example b/.env.example index c83a6b3df..fc060f7c2 100644 --- a/.env.example +++ b/.env.example @@ -40,6 +40,16 @@ APP_URL="http://localhost:3000" # Node environment: "development" for `pnpm dev`, "production" for Docker. NODE_ENV="development" +# Session cookie `Secure` flag (v1.5.1). +# Defaults to NODE_ENV === "production" — the safe choice for any +# deployment terminated by HTTPS (Caddy / Traefik / Nginx in front). +# Set to `false` if you intentionally serve plain HTTP on a LAN, VPN, +# or Tailscale-only surface — the browser otherwise drops the session +# cookie on HTTP origins and login round-trips fail silently. NEVER +# set to `false` for a deployment that serves plain HTTP to the open +# internet; the session cookie crosses the wire in cleartext. +# SESSION_COOKIE_SECURE="false" + # ----------------------------------------------------------------------------- # Required Secrets — generate with `openssl rand -hex 32` diff --git a/CHANGELOG.md b/CHANGELOG.md index e1c7ee95a..08d2c20cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## [1.5.1] — 2026-05-26 — Self-hosting opt-out for the session-cookie `Secure` flag + +A self-hoster running HealthLog on a LAN address (`http://10.x.x.x:3000`) over plain HTTP reported a silent login failure: the `/api/auth/login` POST returned 200 with a `Set-Cookie` header on the response, but the very next `/api/auth/me` request came back 401 and the page reloaded to the login screen. Root cause is the modern browser behaviour around `Secure`-flagged cookies — every cookie the app issues in `NODE_ENV=production` carries `Secure`, and on a plain-HTTP origin that is not `localhost` / `127.0.0.1` / `::1`, the browser silently drops the cookie before sending the next request. The default-Docker image runs `NODE_ENV=production`, so any operator browsing from a different host than the one running `docker compose up` (NAS / homelab / VPS / Tailscale + Magic-DNS) used to hit the dead-end. + +### Added + +- `SESSION_COOKIE_SECURE` environment variable. Controls the `Secure` flag on the session cookie, the onboarding-hint cookie, the Withings OAuth state cookie, and the Codex device-OAuth state cookie. Three values: + - **unset** (default) — flag is set when `NODE_ENV === "production"`, the long-standing behaviour. + - **`false`** — flag is never set. Use this for LAN / VPN / Tailscale-only deployments where the operator deliberately serves plain HTTP and accepts the trade-off (the session cookie crosses the wire unencrypted; do NOT use on an open-internet HTTP origin). + - **`true`** — flag is always set, useful when a developer fronts a `pnpm dev` server with HTTPS to test the production cookie path. +- New shared helper `src/lib/auth/secure-cookie.ts` (`shouldEmitSecureCookie()`) that every `Secure`-bearing cookie call now reads. Replaces four scattered `secure: process.env.NODE_ENV === "production"` literals in `src/lib/auth/session.ts`, `src/app/api/auth/codex/device-start/route.ts`, and `src/app/api/withings/connect/route.ts`. +- `.env.example` block documenting the new variable with a clear warning about the open-internet-HTTP case. + +### Tests + +- `src/lib/auth/__tests__/secure-cookie.test.ts` — six cases covering the default path, both explicit overrides, whitespace / case-insensitive parsing, and the fall-through for unrecognised values. + +### Notes + +- The default behaviour is unchanged for every deployment that runs behind an HTTPS-terminating reverse proxy (the documented self-hosting path) — those continue to set `Secure` exactly as before. The opt-out is intentional and explicit. +- Operators currently working around the issue with `NODE_ENV=development` can move back to `NODE_ENV=production` plus `SESSION_COOKIE_SECURE=false` on this release; that keeps build optimisation + production error masking on while letting the cookie reach the browser over HTTP. + ## [1.5.0] — 2026-05-24 — Native iOS client public-beta + per-day cumulative stats overwrite + cadence-aware medication compliance The minor-version cut that marks the native iOS client publicly available. The SwiftUI iOS app (separate repository) is now joinable via TestFlight: https://testflight.apple.com/join/bucuTBpa. The backend contract the iOS app speaks against has been live since v1.4.23 and has been continuously validated across every v1.4.2x–v1.4.50 release. The 1.5.0 cut also lands the highest-leverage iOS-client unblocker per the v0.6.1 code audit: `/api/measurements/batch` now overwrites per-day cumulative `stats:*` rows on a re-post instead of dropping the new value as a duplicate. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 8fbc7206a..23fb7d36d 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.5.0 + version: 1.5.1 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/docs/self-hosting/reverse-proxy.md b/docs/self-hosting/reverse-proxy.md index 1778e36da..6426d842c 100644 --- a/docs/self-hosting/reverse-proxy.md +++ b/docs/self-hosting/reverse-proxy.md @@ -218,3 +218,37 @@ than the actual proxy hop count, or the proxy is dropping the The one-shot stderr warning `[getClientIp] TRUST_PROXY_HOPS=N but X-Forwarded-For carried M entries` confirms a mismatch. Adjust the env var down to the actual chain length and restart. + +## Running over plain HTTP on a LAN (no reverse proxy) + +The session cookie carries the `Secure` flag by default under +`NODE_ENV=production`, which is what the bundled Docker image runs. +On a plain-HTTP origin that is not `localhost` / `127.0.0.1` / `::1`, +modern browsers silently drop a `Secure` cookie before sending the +next request — the login round-trip looks like it succeeds (the +server returns 200 + `Set-Cookie`) but the very next page load +401s and bounces back to `/auth/login`. + +If you genuinely want to serve HealthLog on `http://10.0.0.42:3000` +or `http://healthlog.lan:3000` (NAS, homelab, Tailscale-only +surface) and trust the network the traffic crosses, set: + +``` +SESSION_COOKIE_SECURE=false +``` + +in your `.env`. The session, onboarding-hint, Withings OAuth state, +and Codex device-OAuth state cookies all stop emitting `Secure` and +login works over HTTP. The trade-off is explicit: the session +cookie crosses the wire unencrypted, so the network it traverses +needs to be one you control. + +**Do not set this on a deployment that ever serves plain HTTP to the +open internet.** Anyone with passive network access can capture the +session cookie and impersonate the user. The right answer for any +public-facing host is the reverse proxy + Let's Encrypt path +documented above. + +The default behaviour is unchanged when this variable is unset — +deployments behind an HTTPS-terminating proxy keep setting `Secure` +exactly as before. diff --git a/package.json b/package.json index 35bbd4d5a..18c151576 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.5.0", + "version": "1.5.1", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "AGPL-3.0-only", "homepage": "https://healthlog.dev", diff --git a/src/app/api/auth/codex/device-start/route.ts b/src/app/api/auth/codex/device-start/route.ts index b5b007106..18275d3f2 100644 --- a/src/app/api/auth/codex/device-start/route.ts +++ b/src/app/api/auth/codex/device-start/route.ts @@ -6,6 +6,7 @@ import { cookies } from "next/headers"; import { annotate } from "@/lib/logging/context"; import { encrypt } from "@/lib/crypto"; import { apiSuccess } from "@/lib/api-response"; +import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie"; /** * v1.4.7.1: Device-code flow for ChatGPT-OAuth. @@ -42,7 +43,7 @@ export const POST = apiHandler(async (request: NextRequest) => { ), { httpOnly: true, - secure: process.env.NODE_ENV === "production", + secure: shouldEmitSecureCookie(), sameSite: "lax", path: "/", maxAge: 15 * 60, diff --git a/src/app/api/withings/connect/route.ts b/src/app/api/withings/connect/route.ts index 9ed054552..f48ad81b5 100644 --- a/src/app/api/withings/connect/route.ts +++ b/src/app/api/withings/connect/route.ts @@ -11,6 +11,7 @@ import { mintWithingsOAuthStateNonce, } from "@/lib/withings/oauth-state"; import { NextRequest, NextResponse } from "next/server"; +import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie"; /** * Redirects the user to Withings OAuth authorization page. @@ -88,7 +89,7 @@ export const GET = apiHandler(async (req: NextRequest) => { const response = NextResponse.redirect(url); response.cookies.set(WITHINGS_OAUTH_STATE_COOKIE, nonce, { httpOnly: true, - secure: process.env.NODE_ENV === "production", + secure: shouldEmitSecureCookie(), sameSite: "lax", maxAge: Math.floor(WITHINGS_OAUTH_STATE_TTL_MS / 1000), path: "/", diff --git a/src/lib/auth/__tests__/secure-cookie.test.ts b/src/lib/auth/__tests__/secure-cookie.test.ts new file mode 100644 index 000000000..e56d5645e --- /dev/null +++ b/src/lib/auth/__tests__/secure-cookie.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect, afterEach, vi } from "vitest"; + +import { shouldEmitSecureCookie } from "../secure-cookie"; + +describe("shouldEmitSecureCookie", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("emits Secure under NODE_ENV=production when no override is set", () => { + vi.stubEnv("NODE_ENV", "production"); + vi.stubEnv("SESSION_COOKIE_SECURE", ""); + expect(shouldEmitSecureCookie()).toBe(true); + }); + + it("omits Secure under NODE_ENV=development when no override is set", () => { + vi.stubEnv("NODE_ENV", "development"); + vi.stubEnv("SESSION_COOKIE_SECURE", ""); + expect(shouldEmitSecureCookie()).toBe(false); + }); + + it("omits Secure when SESSION_COOKIE_SECURE=false even under NODE_ENV=production (LAN / VPN self-host opt-out)", () => { + vi.stubEnv("NODE_ENV", "production"); + vi.stubEnv("SESSION_COOKIE_SECURE", "false"); + expect(shouldEmitSecureCookie()).toBe(false); + }); + + it("emits Secure when SESSION_COOKIE_SECURE=true even under NODE_ENV=development (dev box behind HTTPS)", () => { + vi.stubEnv("NODE_ENV", "development"); + vi.stubEnv("SESSION_COOKIE_SECURE", "true"); + expect(shouldEmitSecureCookie()).toBe(true); + }); + + it("trims whitespace and accepts case-insensitive `False` / `TRUE`", () => { + vi.stubEnv("NODE_ENV", "production"); + vi.stubEnv("SESSION_COOKIE_SECURE", " False "); + expect(shouldEmitSecureCookie()).toBe(false); + vi.stubEnv("SESSION_COOKIE_SECURE", "TRUE"); + expect(shouldEmitSecureCookie()).toBe(true); + }); + + it("falls back to the NODE_ENV default for unrecognised override values", () => { + vi.stubEnv("NODE_ENV", "production"); + vi.stubEnv("SESSION_COOKIE_SECURE", "yes"); + expect(shouldEmitSecureCookie()).toBe(true); + vi.stubEnv("NODE_ENV", "development"); + expect(shouldEmitSecureCookie()).toBe(false); + }); +}); diff --git a/src/lib/auth/secure-cookie.ts b/src/lib/auth/secure-cookie.ts new file mode 100644 index 000000000..c2fb8b931 --- /dev/null +++ b/src/lib/auth/secure-cookie.ts @@ -0,0 +1,30 @@ +/** + * Should the `Secure` flag be set on session-class cookies? + * + * Default — the flag is on whenever `NODE_ENV === "production"`, which + * is the safe choice for any deployment terminated by HTTPS (the + * documented self-hosting path: Caddy / Traefik / Nginx in front). + * + * Opt-out — operators running plain HTTP on a LAN or private VPN can + * set `SESSION_COOKIE_SECURE=false` to drop the flag. Without the flag + * a browser will send the cookie back over HTTP, so login round-trips + * complete on `http://10.x.x.x:3000` and friends. This is intentional + * for evaluation, NAS / homelab deployments, and Tailscale-only + * surfaces; it is NOT appropriate for a deployment that ever serves + * plain HTTP to the open internet, because the session cookie then + * crosses the wire in cleartext. + * + * Opt-in — `SESSION_COOKIE_SECURE=true` forces the flag on even under + * `NODE_ENV !== "production"`, useful when a developer fronts their + * `pnpm dev` server with HTTPS for testing the production cookie path. + * + * The function is intentionally stateless / re-evaluated per call so a + * runtime env change (e.g. via `compose.yml` edit + `docker compose up + * -d`) takes effect on the next request without an app restart. + */ +export function shouldEmitSecureCookie(): boolean { + const override = process.env.SESSION_COOKIE_SECURE?.trim().toLowerCase(); + if (override === "false") return false; + if (override === "true") return true; + return process.env.NODE_ENV === "production"; +} diff --git a/src/lib/auth/session.ts b/src/lib/auth/session.ts index 949c8ca89..527f07bd9 100644 --- a/src/lib/auth/session.ts +++ b/src/lib/auth/session.ts @@ -3,6 +3,7 @@ import { cookies } from "next/headers"; import type { User } from "@/generated/prisma/client"; import { ensureDbCompatibility } from "@/lib/db-compat"; import { getEvent } from "@/lib/logging/context"; +import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie"; const SESSION_COOKIE = "healthlog_session"; const SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days @@ -28,7 +29,7 @@ export async function setOnboardingPendingCookie( if (pending) { cookieStore.set(ONBOARDING_COOKIE, "pending", { httpOnly: false, - secure: process.env.NODE_ENV === "production", + secure: shouldEmitSecureCookie(), // v1.4.22 W5 reconcile (Sec-MED-1) — Strict (not Lax) because // no cross-site redirect flow ever depends on this cookie. The // sibling `healthlog_session` cookie stays Lax because the @@ -70,7 +71,7 @@ export async function createSession( const cookieStore = await cookies(); cookieStore.set(SESSION_COOKIE, session.id, { httpOnly: true, - secure: process.env.NODE_ENV === "production", + secure: shouldEmitSecureCookie(), // OAuth callbacks (e.g. Withings) arrive via top-level cross-site redirect. // Lax keeps CSRF protection for unsafe methods while allowing this flow. sameSite: "lax", @@ -133,7 +134,7 @@ export async function getSession(): Promise<{ }); cookieStore.set(SESSION_COOKIE, session.id, { httpOnly: true, - secure: process.env.NODE_ENV === "production", + secure: shouldEmitSecureCookie(), sameSite: "lax", maxAge: SESSION_MAX_AGE_MS / 1000, path: "/", diff --git a/src/lib/openapi/registry.ts b/src/lib/openapi/registry.ts index 6343ce618..42e120a5d 100644 --- a/src/lib/openapi/registry.ts +++ b/src/lib/openapi/registry.ts @@ -25,6 +25,7 @@ import { createDocument, type ZodOpenApiObject } from "zod-openapi"; import { openApiPaths, openApiComponents } from "./routes"; +import { version as packageVersion } from "../../../package.json"; const openApiBase: Pick< ZodOpenApiObject, @@ -33,7 +34,11 @@ const openApiBase: Pick< openapi: "3.1.0", info: { title: "HealthLog API", - version: "1.4.23", + // v1.5.1 — pulled from package.json so the spec's info.version + // tracks the release line automatically. The CI drift gate + // (`pnpm openapi:check`) catches the case where the registry + // and the committed spec disagree. + version: packageVersion, description: "Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.\n\n" + "## Date-time contract\n\n" +