diff --git a/docker-compose.yml b/docker-compose.yml index ff1a3e6..aaa74c3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,6 +15,11 @@ services: # AUTH_PASSWORD: change-me # SESSION_SECRET: long-random-string-at-least-32-chars # SESSION_TTL_HOURS: "168" + # Cookie Secure: auto (default) follows http/https per request — use for WireGuard IP + public HTTPS. + # COOKIE_SECURE: auto + # Force: always | never (or true | false) + # Behind a TLS-terminating reverse proxy, keep TRUST_PROXY on so X-Forwarded-Proto is honored. + # TRUST_PROXY: "true" volumes: - ./data:/app/data restart: unless-stopped diff --git a/src/server/auth.js b/src/server/auth.js index 1bd1950..d687f7b 100644 --- a/src/server/auth.js +++ b/src/server/auth.js @@ -7,6 +7,10 @@ const PUBLIC_AUTH_PATHS = new Set([ "POST /api/auth/logout", ]); +/** + * @typedef {"auto"|"always"|"never"} CookieSecureMode + */ + /** * @typedef {object} AuthConfig * @property {boolean} enabled - Whether AUTH_PASSWORD is set. @@ -14,8 +18,63 @@ const PUBLIC_AUTH_PATHS = new Set([ * @property {string} password - Expected password when enabled. * @property {string} secret - HMAC secret for session cookies. * @property {number} ttlMs - Session lifetime in milliseconds. + * @property {CookieSecureMode} cookieSecureMode - How to set the Secure cookie flag. */ +/** + * Parses COOKIE_SECURE for dual HTTP/HTTPS access (e.g. WireGuard IP + public domain). + * - unset / "auto": Secure follows the request scheme (https → Secure) + * - "true" / "1" / "always": always Secure + * - "false" / "0" / "never": never Secure + * @returns {CookieSecureMode} Cookie Secure policy. + */ +function parseCookieSecureMode() { + const raw = (process.env.COOKIE_SECURE || "").trim().toLowerCase(); + if (raw === "true" || raw === "1" || raw === "always") { + return "always"; + } + if (raw === "false" || raw === "0" || raw === "never") { + return "never"; + } + return "auto"; +} + +/** + * Whether this request was made over HTTPS (direct TLS or reverse-proxy proto). + * With Fastify trustProxy enabled, request.protocol reflects X-Forwarded-Proto. + * @param {import("fastify").FastifyRequest} request - Incoming request. + * @returns {boolean} True when the client connection is HTTPS. + */ +function requestIsHttps(request) { + if (request.protocol === "https") { + return true; + } + const forwarded = request.headers["x-forwarded-proto"]; + if (typeof forwarded === "string" && forwarded.length > 0) { + return forwarded.split(",")[0].trim().toLowerCase() === "https"; + } + if (Array.isArray(forwarded) && forwarded.length > 0) { + return String(forwarded[0]).split(",")[0].trim().toLowerCase() === "https"; + } + return false; +} + +/** + * Resolves the Secure flag for a Set-Cookie on this request. + * @param {import("fastify").FastifyRequest} request - Incoming request. + * @param {AuthConfig} config - Auth config. + * @returns {boolean} Whether the cookie should include Secure. + */ +function resolveCookieSecure(request, config) { + if (config.cookieSecureMode === "always") { + return true; + } + if (config.cookieSecureMode === "never") { + return false; + } + return requestIsHttps(request); +} + /** * Reads auth settings from the environment. * @returns {AuthConfig} Parsed config (enabled=false when AUTH_PASSWORD is empty). @@ -41,6 +100,7 @@ function loadAuthConfig() { password, secret, ttlMs: Math.round(ttlHours * 60 * 60 * 1000), + cookieSecureMode: parseCookieSecureMode(), }; } @@ -130,16 +190,17 @@ function sessionFromRequest(request, config) { /** * Cookie options for setting/clearing the session. * @param {AuthConfig} config - Auth config. + * @param {import("fastify").FastifyRequest} request - Request used for Secure auto-detect. * @param {number} [maxAgeMs] - Max-Age in ms (omit when clearing). * @returns {import("@fastify/cookie").CookieSerializeOptions} Cookie options. */ -function sessionCookieOptions(config, maxAgeMs) { +function sessionCookieOptions(config, request, maxAgeMs) { /** @type {import("@fastify/cookie").CookieSerializeOptions} */ const opts = { path: "/", httpOnly: true, sameSite: "lax", - secure: process.env.NODE_ENV === "production", + secure: resolveCookieSecure(request, config), }; if (typeof maxAgeMs === "number") { opts.maxAge = Math.floor(maxAgeMs / 1000); @@ -197,7 +258,11 @@ function registerAuth(app, config) { const expiresAt = Date.now() + config.ttlMs; const token = createSessionToken(config.username, config.secret, expiresAt); - reply.setCookie(COOKIE_NAME, token, sessionCookieOptions(config, config.ttlMs)); + reply.setCookie( + COOKIE_NAME, + token, + sessionCookieOptions(config, request, config.ttlMs), + ); return { ok: true, @@ -207,8 +272,8 @@ function registerAuth(app, config) { }; }); - app.post("/api/auth/logout", async (_request, reply) => { - reply.clearCookie(COOKIE_NAME, sessionCookieOptions(config)); + app.post("/api/auth/logout", async (request, reply) => { + reply.clearCookie(COOKIE_NAME, sessionCookieOptions(config, request)); return { ok: true, authenticated: false }; }); @@ -240,8 +305,11 @@ module.exports = { COOKIE_NAME, createSessionToken, loadAuthConfig, + parseCookieSecureMode, parseSessionToken, registerAuth, + requestIsHttps, + resolveCookieSecure, sessionCookieOptions, sessionFromRequest, verifyPassword, diff --git a/src/server/index.js b/src/server/index.js index f716f86..77ee970 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -29,7 +29,12 @@ async function main() { const db = openDatabase(DATA_DIR); const queue = new DownloadQueue(db); const authConfig = loadAuthConfig(); - const app = Fastify({ logger: true }); + // Honor X-Forwarded-Proto / X-Forwarded-Host when behind a reverse proxy so + // session cookies can set Secure automatically on the public HTTPS domain + // while still working over plain HTTP (e.g. WireGuard IP). + const trustProxyRaw = (process.env.TRUST_PROXY || "true").trim().toLowerCase(); + const trustProxy = !(trustProxyRaw === "false" || trustProxyRaw === "0"); + const app = Fastify({ logger: true, trustProxy }); // Allow POST/PATCH with Content-Type: application/json and an empty body app.addContentTypeParser( @@ -90,9 +95,10 @@ async function main() { app.log.info(`Data dir: ${DATA_DIR}`); app.log.info(`Watch cron: ${WATCH_CRON}`); app.log.info(`Watch title interval: ${String(WATCH_TITLE_INTERVAL_HOURS)}h (one title per tick)`); + app.log.info(`Trust proxy: ${trustProxy}`); app.log.info( authConfig.enabled - ? `Auth: enabled (user=${authConfig.username})` + ? `Auth: enabled (user=${authConfig.username}, cookieSecure=${authConfig.cookieSecureMode})` : "Auth: disabled (set AUTH_PASSWORD to enable)", ); }