mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-08 18:45:33 +08:00
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:
18
.env.example
18
.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
|
||||
|
||||
48
server/plugins/validate-secrets.ts
Normal file
48
server/plugins/validate-secrets.ts
Normal file
@@ -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)`)
|
||||
})
|
||||
@@ -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.' })
|
||||
|
||||
Reference in New Issue
Block a user