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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user