diff --git a/Overlord-Server/src/server/routes/auth-cookie.ts b/Overlord-Server/src/server/routes/auth-cookie.ts new file mode 100644 index 0000000..50249a6 --- /dev/null +++ b/Overlord-Server/src/server/routes/auth-cookie.ts @@ -0,0 +1,47 @@ +function envFlagEnabled(value: string): boolean { + const v = value.trim().toLowerCase(); + return v === "1" || v === "true" || v === "yes" || v === "on"; +} + +function envFlagDisabled(value: string): boolean { + const v = value.trim().toLowerCase(); + return v === "0" || v === "false" || v === "no" || v === "off"; +} + +export function shouldUseSecureAuthCookie(req: Request): boolean { + const mode = String(process.env.OVERLORD_AUTH_COOKIE_SECURE || "auto"); + if (envFlagEnabled(mode)) { + return true; + } + if (envFlagDisabled(mode)) { + return false; + } + + const forwardedProto = req.headers + .get("x-forwarded-proto") + ?.split(",")[0] + ?.trim() + ?.toLowerCase(); + if (forwardedProto === "https") { + return true; + } + if (forwardedProto === "http") { + return false; + } + + try { + return new URL(req.url).protocol === "https:"; + } catch { + return false; + } +} + +export function makeAuthCookie(token: string, maxAgeSeconds: number, req: Request): string { + const securePart = shouldUseSecureAuthCookie(req) ? "Secure; " : ""; + return `overlord_token=${token}; HttpOnly; ${securePart}SameSite=Strict; Path=/; Max-Age=${maxAgeSeconds}`; +} + +export function makeAuthCookieClear(req: Request): string { + const securePart = shouldUseSecureAuthCookie(req) ? "Secure; " : ""; + return `overlord_token=; HttpOnly; ${securePart}SameSite=Strict; Path=/; Max-Age=0`; +} diff --git a/Overlord-Server/src/server/routes/auth-routes.ts b/Overlord-Server/src/server/routes/auth-routes.ts index 866249a..1af07f4 100644 --- a/Overlord-Server/src/server/routes/auth-routes.ts +++ b/Overlord-Server/src/server/routes/auth-routes.ts @@ -15,6 +15,7 @@ import { recordSuccessfulAttempt, } from "../../rateLimit"; import { getUserById } from "../../users"; +import { makeAuthCookie, makeAuthCookieClear } from "./auth-cookie"; type RequestIpProvider = { requestIP: (req: Request) => { address?: string } | null | undefined; @@ -95,7 +96,7 @@ export async function handleAuthRoutes( { headers: { "Content-Type": "application/json", - "Set-Cookie": `overlord_token=${token}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=${sessionTtlSeconds}`, + "Set-Cookie": makeAuthCookie(token, sessionTtlSeconds, req), }, }, ); @@ -148,7 +149,7 @@ export async function handleAuthRoutes( return new Response(JSON.stringify({ ok: true }), { headers: { "Content-Type": "application/json", - "Set-Cookie": `overlord_token=; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=0`, + "Set-Cookie": makeAuthCookieClear(req), }, }); } diff --git a/Overlord-Server/src/server/routes/users-routes.ts b/Overlord-Server/src/server/routes/users-routes.ts index c769710..5c9e2c8 100644 --- a/Overlord-Server/src/server/routes/users-routes.ts +++ b/Overlord-Server/src/server/routes/users-routes.ts @@ -18,6 +18,7 @@ import { updateUserPassword, updateUserRole, } from "../../users"; +import { makeAuthCookie } from "./auth-cookie"; type RequestIpProvider = { requestIP: (req: Request) => { address?: string } | null | undefined; @@ -131,7 +132,7 @@ export async function handleUsersRoutes( { headers: { "Content-Type": "application/json", - "Set-Cookie": `overlord_token=${newToken}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=${sessionTtlSeconds}`, + "Set-Cookie": makeAuthCookie(newToken, sessionTtlSeconds, req), }, }, ); diff --git a/README.md b/README.md index c26a799..23f47f4 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,7 @@ services: - OVERLORD_TLS_KEY=/app/certs/server.key - OVERLORD_TLS_CA= - OVERLORD_TLS_OFFLOAD=false + - OVERLORD_AUTH_COOKIE_SECURE=auto - OVERLORD_TLS_CERTBOT_ENABLED=false - OVERLORD_TLS_CERTBOT_LIVE_PATH=/etc/letsencrypt/live - OVERLORD_TLS_CERTBOT_DOMAIN= @@ -66,7 +67,7 @@ services: networks: - overlord-network healthcheck: - test: ["CMD", "curl", "-f", "-k", "https://localhost:5173/health"] + test: ["CMD-SHELL", "curl -f ${OVERLORD_HEALTHCHECK_URL:-https://localhost:5173/health} >/dev/null 2>&1 || exit 1"] interval: 30s timeout: 10s retries: 3 @@ -348,6 +349,8 @@ Override with: If your platform terminates TLS before traffic reaches Overlord, set: - `OVERLORD_TLS_OFFLOAD=true` +- `OVERLORD_HEALTHCHECK_URL=http://localhost:5173/health` +- `OVERLORD_PUBLISH_HOST=127.0.0.1` (recommended for local proxies like ngrok) When enabled: @@ -355,3 +358,22 @@ When enabled: - external URL remains `https://...` through your platform proxy - health checks should use `http://localhost:$PORT/health` inside the container - do not expose internal container HTTP port directly to the internet + +For ngrok/local reverse proxy use, a common setup is: + +```sh +OVERLORD_TLS_OFFLOAD=true +OVERLORD_HEALTHCHECK_URL=http://localhost:5173/health +OVERLORD_PUBLISH_HOST=127.0.0.1 +``` + +Then point ngrok at local HTTP: + +```sh +ngrok http http://127.0.0.1:5173 +``` + +Notes: + +- Keep `HOST=0.0.0.0` inside the container. Limiting exposure should be done with publish binding (`OVERLORD_PUBLISH_HOST`), not server bind host. +- If your `.env` secret/password includes `$`, escape as `$$` to avoid Docker Compose variable-expansion warnings. diff --git a/docker-compose.quickstart.yml b/docker-compose.quickstart.yml index 88d5a42..77fa75a 100644 --- a/docker-compose.quickstart.yml +++ b/docker-compose.quickstart.yml @@ -10,7 +10,8 @@ services: - type=local,dest=.docker-cache/buildx,mode=max container_name: overlord-server ports: - - "5173:5173" + # Bind host-published port. Use 127.0.0.1 for local reverse proxies (ngrok/caddy/traefik). + - "${OVERLORD_PUBLISH_HOST:-0.0.0.0}:5173:5173" environment: # Optional username for first admin user - OVERLORD_USER=admin @@ -28,6 +29,9 @@ services: # Set true only behind a trusted reverse proxy that terminates TLS (for example Render). # Default false keeps current behavior: Overlord terminates TLS in-process. - OVERLORD_TLS_OFFLOAD=false + # Cookie security mode: auto (default), true, or false. + # auto: Secure cookie on HTTPS requests / X-Forwarded-Proto=https. + - OVERLORD_AUTH_COOKIE_SECURE=auto # Optional certbot integration (used only when NODE_ENV=production) - OVERLORD_TLS_CERTBOT_ENABLED=false - OVERLORD_TLS_CERTBOT_LIVE_PATH=/etc/letsencrypt/live @@ -50,7 +54,8 @@ services: networks: - overlord-network healthcheck: - test: ["CMD", "curl", "-f", "-k", "https://localhost:5173/health"] + # Use http:// when OVERLORD_TLS_OFFLOAD=true. + test: ["CMD-SHELL", "curl -f ${OVERLORD_HEALTHCHECK_URL:-https://localhost:5173/health} >/dev/null 2>&1 || exit 1"] interval: 30s timeout: 10s retries: 3 diff --git a/docker-compose.yml b/docker-compose.yml index a701b83..252b22d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,7 +10,8 @@ services: - type=local,dest=.docker-cache/buildx,mode=max container_name: overlord-server ports: - - "${PORT:-5173}:5173" + # Bind host-published port. Use 127.0.0.1 for local reverse proxies (ngrok/caddy/traefik). + - "${OVERLORD_PUBLISH_HOST:-0.0.0.0}:${PORT:-5173}:5173" environment: PORT: ${PORT:-5173} HOST: ${HOST:-0.0.0.0} @@ -28,6 +29,9 @@ services: # Set true only behind a trusted reverse proxy that terminates TLS (for example Render). # Default false keeps current behavior: Overlord terminates TLS in-process. OVERLORD_TLS_OFFLOAD: ${OVERLORD_TLS_OFFLOAD:-false} + # Cookie security mode: auto (default), true, or false. + # auto: Secure cookie on HTTPS requests / X-Forwarded-Proto=https. + OVERLORD_AUTH_COOKIE_SECURE: ${OVERLORD_AUTH_COOKIE_SECURE:-auto} # Optional certbot integration (used only when NODE_ENV=production) OVERLORD_TLS_CERTBOT_ENABLED: ${OVERLORD_TLS_CERTBOT_ENABLED:-false} OVERLORD_TLS_CERTBOT_LIVE_PATH: ${OVERLORD_TLS_CERTBOT_LIVE_PATH:-/etc/letsencrypt/live} @@ -53,7 +57,8 @@ services: networks: - overlord-network healthcheck: - test: ["CMD", "curl", "-f", "-k", "https://localhost:5173/health"] + # Use http:// when OVERLORD_TLS_OFFLOAD=true. + test: ["CMD-SHELL", "curl -f ${OVERLORD_HEALTHCHECK_URL:-https://localhost:5173/health} >/dev/null 2>&1 || exit 1"] interval: 30s timeout: 10s retries: 3