fix(security): mandatory cron secret + reject placeholder JWT secrets (SEC-07, OPS-02, SEC-09)

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 <noreply@anthropic.com>
This commit is contained in:
leubeem
2026-06-17 09:37:45 +02:00
parent 58f9bfad4c
commit 0154c6d624
3 changed files with 76 additions and 17 deletions

View File

@@ -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=<value> 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.' })