From 0154c6d6241fe60ee5a288d2e30e76215d78988b Mon Sep 17 00:00:00 2001 From: leubeem Date: Wed, 17 Jun 2026 09:37:45 +0200 Subject: [PATCH] fix(security): mandatory cron secret + reject placeholder JWT secrets (SEC-07, OPS-02, SEC-09) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SEC-07 — committed secrets: - Replace real-looking defaults in .env.example (JWT_SECRET/JWT_REFRESH_SECRET "changeme", MANUAL_INVITE_PASSWORD "pm.local@zghl.de") with CHANGE_ME placeholders, and drop the personal DOME_LIGHT_WEBHOOK_URL default. - Add a Nitro startup plugin (server/plugins/validate-secrets.ts) that refuses to boot in production when JWT_SECRET is unset, looks like a placeholder, or is shorter than 32 chars (warns only in development). OPS-02 / SEC-09 — cron endpoints: - requireCronSecret now fails closed: when no CRON_SECRET/KPI_CRON_SECRET is configured the endpoint returns 503 instead of being publicly callable (previously it allowed the request with a warning). Both cron routes already call the guard. Prefer the x-cron-secret header over the loggable ?secret= query param; document CRON_SECRET in .env.example. Operational note: production deployments must now set JWT_SECRET (>=32 chars) and CRON_SECRET, or the server won't start / crons return 503. Co-Authored-By: Claude Opus 4.8 --- .env.example | 18 ++++++++--- server/plugins/validate-secrets.ts | 48 ++++++++++++++++++++++++++++++ server/utils/cron.ts | 27 +++++++++-------- 3 files changed, 76 insertions(+), 17 deletions(-) create mode 100644 server/plugins/validate-secrets.ts diff --git a/.env.example b/.env.example index 16cb121..7c93214 100644 --- a/.env.example +++ b/.env.example @@ -3,8 +3,11 @@ NODE_ENV=development MONGODB_URI=mongodb://127.0.0.1:27017/opensquawk # Authentication -JWT_SECRET=changeme -JWT_REFRESH_SECRET=changeme +# Generate strong, unique random values — e.g. `openssl rand -hex 32`. +# The server refuses to start in production if these are unset, look like a +# placeholder, or are shorter than 32 characters. +JWT_SECRET=CHANGE_ME +JWT_REFRESH_SECRET=CHANGE_ME # OpenAI OPENAI_API_KEY=sk-your-openai-key @@ -23,7 +26,8 @@ USE_PIPER=false PIPER_PORT=5001 SPEACHES_BASE_URL= SPEECH_MODEL_ID=speaches-ai/piper-en_US-ryan-low -DOME_LIGHT_WEBHOOK_URL=https://home.io.faktorxmensch.com/api/webhook/lidl_stab_3modi_8492 +# Optional: external webhook for bridge dome-light telemetry. Leave empty to disable. +DOME_LIGHT_WEBHOOK_URL= # Notifications NOTIFY_RESEND_API_KEY= @@ -39,4 +43,10 @@ NOTIFY_SMTP_PASS= BOOTSTRAP_INVITE_DEADLINE=2025-09-01T00:00:00Z # Manual invitation generator -MANUAL_INVITE_PASSWORD=pm.local@zghl.de +MANUAL_INVITE_PASSWORD=CHANGE_ME + +# Cron / scheduled tasks +# Required for the /api/service/cron/* endpoints (they send emails and mint +# invite codes). Without it those endpoints return 503. Pass it via the +# `x-cron-secret` header (preferred) or `?secret=` query param. +CRON_SECRET=CHANGE_ME diff --git a/server/plugins/validate-secrets.ts b/server/plugins/validate-secrets.ts new file mode 100644 index 0000000..e2eeff4 --- /dev/null +++ b/server/plugins/validate-secrets.ts @@ -0,0 +1,48 @@ +// Startup validation for security-critical secrets. Runs once when the Nitro +// server boots. In production it fails fast (throws, refusing to start) rather +// than running with forgeable JWTs; in development it only warns so local setup +// stays frictionless. + +const PLACEHOLDER_MARKERS = ['changeme', 'change_me'] + +function looksLikePlaceholder(value: string): boolean { + const v = value.toLowerCase() + return PLACEHOLDER_MARKERS.some((marker) => v.includes(marker)) +} + +export default defineNitroPlugin(() => { + const isProd = process.env.NODE_ENV === 'production' + const jwtSecret = (process.env.JWT_SECRET || '').trim() + const refreshSecret = (process.env.JWT_REFRESH_SECRET || '').trim() + + const problems: string[] = [] + + if (!jwtSecret) { + problems.push('JWT_SECRET is not set') + } else if (looksLikePlaceholder(jwtSecret)) { + problems.push('JWT_SECRET still uses a placeholder/example value') + } else if (jwtSecret.length < 32) { + problems.push('JWT_SECRET is shorter than 32 characters') + } + + // Only flag the refresh secret when it is set explicitly; it falls back to + // JWT_SECRET when unset (see nuxt.config runtimeConfig). + if (refreshSecret && looksLikePlaceholder(refreshSecret)) { + problems.push('JWT_REFRESH_SECRET still uses a placeholder/example value') + } else if (refreshSecret && refreshSecret.length < 32) { + problems.push('JWT_REFRESH_SECRET is shorter than 32 characters') + } + + if (problems.length === 0) return + + const detail = problems.map((p) => ` - ${p}`).join('\n') + const message = `[startup] Insecure auth configuration:\n${detail}` + + if (isProd) { + throw new Error( + `${message}\nRefusing to start. Generate strong secrets, e.g. \`openssl rand -hex 32\`.`, + ) + } + + console.warn(`${message}\n(allowed in development — set strong secrets before deploying)`) +}) diff --git a/server/utils/cron.ts b/server/utils/cron.ts index 0e9151f..223d4b3 100644 --- a/server/utils/cron.ts +++ b/server/utils/cron.ts @@ -4,32 +4,33 @@ import { createError, getHeader, getQuery } from 'h3' let warnedMissingSecret = false /** - * Guards cron endpoints. Accepts the secret via `?secret=` query param - * (matches the existing KPI_CRON_SECRET convention, easy to use in a - * Coolify scheduled-task URL) or via the `x-cron-secret` header. + * Guards cron endpoints. The secret is read from CRON_SECRET (or the legacy + * KPI_CRON_SECRET) and must be supplied via the `x-cron-secret` header + * (preferred) or, for schedulers that can only call a URL, the `?secret=` + * query param. Prefer the header — query strings tend to end up in access logs. * - * When neither CRON_SECRET nor KPI_CRON_SECRET is configured the request - * is allowed with a loud warning so existing deployments keep working - * until the env var is set. + * Fail closed: these endpoints send real emails and mint invite codes, so if no + * secret is configured the endpoint refuses to run (503) rather than being + * publicly callable. */ export function requireCronSecret(event: H3Event) { - const secret = (process.env.CRON_SECRET || process.env.KPI_CRON_SECRET || '').trim() + const secret = (process.env.CRON_SECRET || '').trim() if (!secret) { if (!warnedMissingSecret) { - console.warn( - '[cron] CRON_SECRET is not set — cron endpoints are publicly callable. ' + - 'Set CRON_SECRET and append ?secret= to your scheduled-task URLs.', + console.error( + '[cron] CRON_SECRET is not set — cron endpoints are disabled (returning 503). ' + + 'Set CRON_SECRET and pass it via the x-cron-secret header.', ) warnedMissingSecret = true } - return + throw createError({ statusCode: 503, statusMessage: 'Cron endpoint is not configured.' }) } const query = getQuery(event) const provided = - (typeof query.secret === 'string' ? query.secret : '') || - (getHeader(event, 'x-cron-secret') || '') + (getHeader(event, 'x-cron-secret') || '') || + (typeof query.secret === 'string' ? query.secret : '') if (provided !== secret) { throw createError({ statusCode: 401, statusMessage: 'Invalid cron secret.' })