From 15a8ab41fd1c94e3397d300b161cd1fdd459ea05 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Sat, 11 Apr 2026 22:41:15 +0200 Subject: [PATCH] feat: add COOKIE_SECURE=auto for mixed HTTPS/HTTP deployments (#298) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a third value for the COOKIE_SECURE environment variable that decides the cookie Secure flag per-request based on req.secure. This unblocks a common self-hosted setup where the same PicPeak deployment is reachable over both HTTPS (via reverse proxy) and plain HTTP (e.g. LAN access at http://192.168.x.x:3001). Behavior unset - legacy default: follows NODE_ENV (production=true, dev=false) true - always set Secure (unchanged) false - never set Secure (unchanged) auto - NEW: use req.secure per request. In practice this means Secure on HTTPS requests (when X-Forwarded-Proto: https reaches Express via a trusted proxy) and no Secure flag on plain HTTP requests. The existing trust proxy config (`app.set('trust proxy', 'loopback, linklocal, uniquelocal')` in server.js) means X-Forwarded-Proto is honored when forwarded from local/private-network proxies, which covers Docker network setups and most self-hosted deployments behind NPM, Traefik, or Caddy. auto is strictly opt-in. The default behavior is unchanged, so existing users see no difference. A follow-up release can consider promoting auto to the default after real-world feedback. Also fixed (latent bug, benefits everyone) Cookie clear operations (clearAdminAuthCookie, clearGalleryAuthCookies) previously wrote the same `secure` attribute as the set path. When a cookie was set with Secure=true over HTTPS and the clear request came over HTTP (or vice versa under auto mode), some browsers would reject the Set-Cookie delete header, leaving the cookie in place. Browsers match cookies by (name, domain, path) for deletion and don't care about Secure, so the new buildClearCookieOptions() helper simply omits the secure attribute. Implementation - secureCookie string is replaced by secureCookieMode which can hold true, false, or 'auto'. - New resolveSecureFlag(res) returns the boolean for a specific response, delegating to res.req.secure when in auto mode. - buildCookieBaseOptions and buildCookieOptionsWithExpiry now take res and pass it through. - New buildClearCookieOptions() deliberately omits `secure`. - setAdminAuthCookie / setGalleryAuthCookies / clearAdminAuthCookie / clearGalleryAuthCookies all updated to thread res where needed. Public signatures unchanged — every caller already has res in scope. Testing Verified against a real Express instance inside the backend container with trust proxy configured, covering: - (unset) + NODE_ENV=production -> secure: true (legacy) - (unset) + NODE_ENV=development -> secure: false (legacy) - COOKIE_SECURE=true + req.secure=false -> secure: true (literal wins) - COOKIE_SECURE=false + req.secure=true -> secure: false (literal wins) - COOKIE_SECURE=auto + X-Forwarded-Proto: https -> secure: true - COOKIE_SECURE=auto + plain HTTP -> secure: false - clearCookie always omits the secure attribute Documentation Added a COOKIE_SECURE block to both .env.example files (root for docker-compose, backend/.env.example for native install) explaining the four values, when to use auto, and the two requirements (proxy must forward X-Forwarded-Proto, proxy IP must be in the trust list). Also documented COOKIE_SAMESITE and COOKIE_DOMAIN alongside, which were previously undocumented. --- .env.example | 28 +++++++++++ backend/.env.example | 28 +++++++++++ backend/src/utils/tokenUtils.js | 84 ++++++++++++++++++++++++++------- 3 files changed, 124 insertions(+), 16 deletions(-) diff --git a/.env.example b/.env.example index 9fd62522..f3145e52 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,34 @@ NODE_ENV=production # JWT Secret (generate with: openssl rand -base64 64) JWT_SECRET=your_very_long_random_jwt_secret_here +# Auth cookie Secure flag +# unset - default: follows NODE_ENV (production=true, dev=false) +# true - always set Secure (HTTPS-only cookies; breaks plain-HTTP access) +# false - never set Secure (allows HTTP; cookies not protected on HTTPS) +# auto - decide per request: Secure on HTTPS, not on HTTP +# +# Use COOKIE_SECURE=auto if your deployment is reachable over both HTTPS +# (via reverse proxy like Nginx Proxy Manager, Traefik, Caddy) AND plain +# HTTP (e.g. LAN access at http://192.168.x.x:3010). The backend reads +# req.secure from Express, which respects the X-Forwarded-Proto header +# when the proxy is in the trust list. +# +# Requirements for auto mode: +# 1. Your reverse proxy MUST send X-Forwarded-Proto: https on HTTPS +# requests. Standard configs for NPM/Traefik/Caddy do this by default. +# 2. The proxy must be on a trusted IP range. By default PicPeak trusts +# loopback and private networks (127.0.0.1, 10.x, 172.16-31.x, +# 192.168.x, link-local). Proxies outside those ranges need custom +# trust proxy configuration. +# COOKIE_SECURE=auto + +# Cookie SameSite attribute (Lax | Strict | None). Default: Lax +# COOKIE_SAMESITE=Lax + +# Cookie Domain — set this if serving auth cookies across subdomains. +# Leave unset for same-origin setups. +# COOKIE_DOMAIN=.example.com + # Database Configuration (PostgreSQL) DATABASE_CLIENT=pg DB_USER=picpeak diff --git a/backend/.env.example b/backend/.env.example index 7d7a12d2..ba134257 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -9,6 +9,34 @@ PORT=3001 # Generate with: openssl rand -base64 32 JWT_SECRET=your-very-secure-jwt-secret-at-least-32-characters-long-example123456 +# Auth cookie Secure flag +# unset - default: follows NODE_ENV (production=true, dev=false) +# true - always set Secure (HTTPS-only cookies; breaks plain-HTTP access) +# false - never set Secure (allows HTTP; cookies not protected on HTTPS) +# auto - decide per request: Secure on HTTPS, not on HTTP +# +# Use COOKIE_SECURE=auto if your deployment is reachable over both HTTPS +# (via a reverse proxy like Nginx Proxy Manager, Traefik, Caddy) AND plain +# HTTP (e.g. LAN access at http://192.168.x.x:3001). The backend reads +# req.secure from Express, which respects the X-Forwarded-Proto header +# when the proxy is in the trust list. +# +# Requirements for auto mode: +# 1. Your reverse proxy MUST send X-Forwarded-Proto: https on HTTPS +# requests. Standard configs for NPM/Traefik/Caddy do this by default. +# 2. The proxy must be on a trusted IP range. By default PicPeak trusts +# loopback and private networks (127.0.0.1, 10.x, 172.16-31.x, +# 192.168.x, link-local). Proxies outside those ranges need custom +# trust proxy configuration. +# COOKIE_SECURE=auto + +# Cookie SameSite attribute (Lax | Strict | None). Default: Lax +# COOKIE_SAMESITE=Lax + +# Cookie Domain — set this if serving auth cookies across subdomains. +# Leave unset for same-origin setups. +# COOKIE_DOMAIN=.example.com + # URLs (adjust for your domain) ADMIN_URL=https://photos.example.com FRONTEND_URL=https://photos.example.com diff --git a/backend/src/utils/tokenUtils.js b/backend/src/utils/tokenUtils.js index a664d1be..e5a7963a 100644 --- a/backend/src/utils/tokenUtils.js +++ b/backend/src/utils/tokenUtils.js @@ -5,20 +5,49 @@ const GUEST_COOKIE_PREFIX = 'guest_token_'; const DEFAULT_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours -const secureCookie = (() => { - if (typeof process.env.COOKIE_SECURE === 'string') { - return process.env.COOKIE_SECURE.toLowerCase() === 'true'; - } - // Default to true in production (HTTPS expected), false in development +/** + * Cookie "Secure" flag mode: + * - true → always set Secure (HTTPS-only) + * - false → never set Secure (allow plain HTTP) + * - 'auto' → decide per-request based on req.secure (X-Forwarded-Proto + * via Express `trust proxy`). Useful when the same deployment + * is reachable over both HTTPS (via reverse proxy) and LAN HTTP. + * + * Default: follows NODE_ENV (production → true, dev → false) — unchanged + * from previous behavior. Users who want the auto mode must opt in with + * COOKIE_SECURE=auto in their .env. + */ +const secureCookieMode = (() => { + const raw = typeof process.env.COOKIE_SECURE === 'string' + ? process.env.COOKIE_SECURE.toLowerCase() + : ''; + if (raw === 'auto') return 'auto'; + if (raw === 'true') return true; + if (raw === 'false') return false; + // No env var set → legacy default return process.env.NODE_ENV === 'production'; })(); const sameSiteDefault = process.env.COOKIE_SAMESITE || 'Lax'; const cookieDomain = process.env.COOKIE_DOMAIN; -function buildCookieBaseOptions() { +/** + * Resolve the Secure flag for a specific response. When in 'auto' mode, + * checks req.secure (which reflects the X-Forwarded-Proto header when the + * proxy is in the trust list set by `app.set('trust proxy', ...)`). When + * called without a `res`, falls back to false — this only happens in code + * paths that don't yet have a response object, which we avoid. + */ +function resolveSecureFlag(res) { + if (secureCookieMode === 'auto') { + return Boolean(res?.req?.secure); + } + return secureCookieMode; +} + +function buildCookieBaseOptions(res) { const options = { httpOnly: true, - secure: secureCookie, + secure: resolveSecureFlag(res), sameSite: sameSiteDefault, path: '/', }; @@ -30,29 +59,52 @@ function buildCookieBaseOptions() { return options; } -function buildCookieOptionsWithExpiry(maxAgeMs = DEFAULT_MAX_AGE_MS) { +function buildCookieOptionsWithExpiry(res, maxAgeMs = DEFAULT_MAX_AGE_MS) { return { - ...buildCookieBaseOptions(), + ...buildCookieBaseOptions(res), maxAge: maxAgeMs, }; } +/** + * Options for clearing a cookie. We deliberately omit `secure` here: when + * a cookie was set over HTTPS (Secure=true) and we later need to clear it + * from a response on an HTTP path (or vice versa, in mixed-protocol + * deployments with COOKIE_SECURE=auto), specifying `secure` in the clear + * options causes some browsers to reject the Set-Cookie delete header. + * Browsers match the cookie by (name, domain, path) for deletion, so + * leaving Secure off produces a header the browser always accepts. + */ +function buildClearCookieOptions() { + const options = { + httpOnly: true, + sameSite: sameSiteDefault, + path: '/', + }; + + if (cookieDomain) { + options.domain = cookieDomain; + } + + return options; +} + function sanitizeSlugForCookie(slug = '') { return String(slug).replace(/[^A-Za-z0-9_-]/g, '_'); } function setAdminAuthCookie(res, token) { if (!token) return; - res.cookie(ADMIN_COOKIE_NAME, token, buildCookieOptionsWithExpiry()); + res.cookie(ADMIN_COOKIE_NAME, token, buildCookieOptionsWithExpiry(res)); } function clearAdminAuthCookie(res) { - res.clearCookie(ADMIN_COOKIE_NAME, buildCookieBaseOptions()); + res.clearCookie(ADMIN_COOKIE_NAME, buildClearCookieOptions()); } function setGalleryAuthCookies(res, token, slug) { if (!token) return; - const options = buildCookieOptionsWithExpiry(); + const options = buildCookieOptionsWithExpiry(res); res.cookie(GALLERY_COOKIE_NAME, token, options); if (slug) { const cookieName = `${GALLERY_COOKIE_PREFIX}${sanitizeSlugForCookie(slug)}`; @@ -61,18 +113,18 @@ function setGalleryAuthCookies(res, token, slug) { } function clearGalleryAuthCookies(res, slug) { - const baseOptions = buildCookieBaseOptions(); - res.clearCookie(GALLERY_COOKIE_NAME, baseOptions); + const clearOptions = buildClearCookieOptions(); + res.clearCookie(GALLERY_COOKIE_NAME, clearOptions); const cookies = res.req?.cookies || {}; if (slug) { const cookieName = `${GALLERY_COOKIE_PREFIX}${sanitizeSlugForCookie(slug)}`; - res.clearCookie(cookieName, baseOptions); + res.clearCookie(cookieName, clearOptions); } else { Object.keys(cookies).forEach((name) => { if (name.startsWith(GALLERY_COOKIE_PREFIX)) { - res.clearCookie(name, baseOptions); + res.clearCookie(name, clearOptions); } }); }