Merge pull request #300 from the-luap/feat/cookie-secure-auto

feat: add COOKIE_SECURE=auto for mixed HTTPS/HTTP deployments (#298)
This commit is contained in:
Paul Nothaft
2026-04-11 23:24:24 +02:00
committed by GitHub
3 changed files with 124 additions and 16 deletions
+28
View File
@@ -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
+28
View File
@@ -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
+68 -16
View File
@@ -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);
}
});
}