Files
OpenSquawk/server/plugins/validate-secrets.ts
leubeem 0154c6d624 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>
2026-06-17 11:07:27 +02:00

49 lines
1.8 KiB
TypeScript

// 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)`)
})