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" +