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); } }); }