mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-10-10 05:37:53 +00:00
fix(security): configure persistent JWT secret and add startup warnings
This commit is contained in:
parent
d756889a1f
commit
86e03f7b00
4 changed files with 172 additions and 21 deletions
|
|
@ -3993,3 +3993,7 @@
|
|||
{"type":"task.created","taskId":"task_20260128_DitDSJ","status":"todo","id":"evt_DlTp17BOGLes","timestamp":"2026-01-28T23:41:55.036Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_SN_PU_","status":"todo","id":"evt__1cavZPnhbtN","timestamp":"2026-01-28T23:41:55.050Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_EKURfN","status":"todo","id":"evt_aY2RTwNmT6xc","timestamp":"2026-01-28T23:41:55.068Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260128_ilLJ0m","project":"veritas-kanban","status":"done","previousStatus":"in-progress","id":"evt_flSz3NXHtfju","timestamp":"2026-01-28T23:42:35.221Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260128_HRYh0i","project":"veritas-kanban","status":"done","previousStatus":"in-progress","id":"evt_9fnaNGiBHc0j","timestamp":"2026-01-28T23:43:07.199Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260128_MIuR31","project":"veritas-kanban","status":"in-progress","previousStatus":"todo","id":"evt_vpomzr2xER-c","timestamp":"2026-01-28T23:43:15.220Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260128_N5mwaB","project":"veritas-kanban","status":"in-progress","previousStatus":"todo","id":"evt_E8bkQrPM61eB","timestamp":"2026-01-28T23:43:42.234Z"}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,77 @@
|
|||
[
|
||||
{
|
||||
"id": "activity_1769643822234_p72ucq50f",
|
||||
"type": "status_changed",
|
||||
"taskId": "task_20260128_N5mwaB",
|
||||
"taskTitle": "SECURITY: Replace unsafe-inline/unsafe-eval CSP with nonces in dev mode",
|
||||
"details": {
|
||||
"from": "todo",
|
||||
"status": "in-progress"
|
||||
},
|
||||
"timestamp": "2026-01-28T23:43:42.234Z"
|
||||
},
|
||||
{
|
||||
"id": "activity_1769643804595_uw3xbec7l",
|
||||
"type": "task_deleted",
|
||||
"taskId": "task_20260128_DnZzn9",
|
||||
"taskTitle": "<script>alert(1)</script>Test Task",
|
||||
"timestamp": "2026-01-28T23:43:24.595Z"
|
||||
},
|
||||
{
|
||||
"id": "activity_1769643795220_e1itoqlgf",
|
||||
"type": "status_changed",
|
||||
"taskId": "task_20260128_MIuR31",
|
||||
"taskTitle": "SECURITY: Configure production API keys and persistent JWT secret",
|
||||
"details": {
|
||||
"from": "todo",
|
||||
"status": "in-progress"
|
||||
},
|
||||
"timestamp": "2026-01-28T23:43:15.220Z"
|
||||
},
|
||||
{
|
||||
"id": "activity_1769643787217_keetuu934",
|
||||
"type": "comment_added",
|
||||
"taskId": "task_20260128_HRYh0i",
|
||||
"taskTitle": "SECURITY: Add server-side HTML sanitization for XSS payloads",
|
||||
"details": {
|
||||
"author": "Veritas",
|
||||
"preview": "Added server-side HTML sanitization using sanitize..."
|
||||
},
|
||||
"timestamp": "2026-01-28T23:43:07.217Z"
|
||||
},
|
||||
{
|
||||
"id": "activity_1769643787199_lmnmclef9",
|
||||
"type": "status_changed",
|
||||
"taskId": "task_20260128_HRYh0i",
|
||||
"taskTitle": "SECURITY: Add server-side HTML sanitization for XSS payloads",
|
||||
"details": {
|
||||
"from": "in-progress",
|
||||
"status": "done"
|
||||
},
|
||||
"timestamp": "2026-01-28T23:43:07.199Z"
|
||||
},
|
||||
{
|
||||
"id": "activity_1769643755232_1bm32yfuf",
|
||||
"type": "comment_added",
|
||||
"taskId": "task_20260128_ilLJ0m",
|
||||
"taskTitle": "SECURITY: Replace weak development admin key",
|
||||
"details": {
|
||||
"author": "Veritas",
|
||||
"preview": "Replaced weak dev-admin-key with a cryptographical..."
|
||||
},
|
||||
"timestamp": "2026-01-28T23:42:35.232Z"
|
||||
},
|
||||
{
|
||||
"id": "activity_1769643755221_5h1l14vqu",
|
||||
"type": "status_changed",
|
||||
"taskId": "task_20260128_ilLJ0m",
|
||||
"taskTitle": "SECURITY: Replace weak development admin key",
|
||||
"details": {
|
||||
"from": "in-progress",
|
||||
"status": "done"
|
||||
},
|
||||
"timestamp": "2026-01-28T23:42:35.221Z"
|
||||
},
|
||||
{
|
||||
"id": "activity_1769643722992_qgjkxc0xc",
|
||||
"type": "task_deleted",
|
||||
|
|
|
|||
|
|
@ -54,12 +54,15 @@ let runtimeJwtSecret: string | null = null;
|
|||
*/
|
||||
export function getSecurityConfig(): SecurityConfig {
|
||||
const now = Date.now();
|
||||
|
||||
|
||||
// Use cache in production, refresh in dev
|
||||
if (cachedConfig && (process.env.NODE_ENV === 'production' || now - lastLoadTime < CACHE_TTL_MS)) {
|
||||
if (
|
||||
cachedConfig &&
|
||||
(process.env.NODE_ENV === 'production' || now - lastLoadTime < CACHE_TTL_MS)
|
||||
) {
|
||||
return cachedConfig;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
if (fs.existsSync(SECURITY_CONFIG_PATH)) {
|
||||
const data = fs.readFileSync(SECURITY_CONFIG_PATH, 'utf-8');
|
||||
|
|
@ -70,7 +73,7 @@ export function getSecurityConfig(): SecurityConfig {
|
|||
} catch (err) {
|
||||
console.error('Error loading security config:', err);
|
||||
}
|
||||
|
||||
|
||||
// Default config
|
||||
cachedConfig = {
|
||||
authEnabled: false, // Disabled until setup
|
||||
|
|
@ -85,7 +88,7 @@ const SECRET_GRACE_PERIOD_MS = 7 * 24 * 60 * 60 * 1000;
|
|||
/**
|
||||
* Get the current (latest) JWT signing secret.
|
||||
* Used for **signing** new tokens.
|
||||
*
|
||||
*
|
||||
* Priority: VERITAS_JWT_SECRET env var > jwtSecrets array (latest) > legacy jwtSecret > runtime-generated
|
||||
*/
|
||||
export function getJwtSecret(): string {
|
||||
|
|
@ -135,7 +138,7 @@ export function getValidJwtSecrets(): string[] {
|
|||
// If we have the jwtSecrets array, filter out expired entries
|
||||
if (config.jwtSecrets && config.jwtSecrets.length > 0) {
|
||||
const validEntries = config.jwtSecrets
|
||||
.filter(entry => {
|
||||
.filter((entry) => {
|
||||
// No expiresAt means it's the current secret — always valid
|
||||
if (!entry.expiresAt) return true;
|
||||
// Check grace period
|
||||
|
|
@ -144,7 +147,7 @@ export function getValidJwtSecrets(): string[] {
|
|||
.sort((a, b) => b.version - a.version);
|
||||
|
||||
if (validEntries.length > 0) {
|
||||
return validEntries.map(e => e.secret);
|
||||
return validEntries.map((e) => e.secret);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -158,7 +161,7 @@ export function getValidJwtSecrets(): string[] {
|
|||
* - Moves the previous current secret to a grace period (default 7 days)
|
||||
* - Purges any secrets past their grace period
|
||||
* - Returns the new version number
|
||||
*
|
||||
*
|
||||
* NOTE: Has no effect when VERITAS_JWT_SECRET env var is set (rotation
|
||||
* must be done externally in that case).
|
||||
*/
|
||||
|
|
@ -173,7 +176,8 @@ export function rotateJwtSecret(gracePeriodMs: number = SECRET_GRACE_PERIOD_MS):
|
|||
success: false,
|
||||
newVersion: 0,
|
||||
prunedCount: 0,
|
||||
message: 'Cannot rotate: JWT secret is managed via VERITAS_JWT_SECRET environment variable. Rotate externally.',
|
||||
message:
|
||||
'Cannot rotate: JWT secret is managed via VERITAS_JWT_SECRET environment variable. Rotate externally.',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -196,7 +200,7 @@ export function rotateJwtSecret(gracePeriodMs: number = SECRET_GRACE_PERIOD_MS):
|
|||
}
|
||||
|
||||
// 1. Set grace period on the current (latest) secret
|
||||
const latestEntry = secrets.find(s => !s.expiresAt);
|
||||
const latestEntry = secrets.find((s) => !s.expiresAt);
|
||||
if (latestEntry) {
|
||||
latestEntry.expiresAt = new Date(now.getTime() + gracePeriodMs).toISOString();
|
||||
}
|
||||
|
|
@ -213,7 +217,7 @@ export function rotateJwtSecret(gracePeriodMs: number = SECRET_GRACE_PERIOD_MS):
|
|||
|
||||
// 3. Prune expired secrets
|
||||
const beforeCount = secrets.length;
|
||||
secrets = secrets.filter(entry => {
|
||||
secrets = secrets.filter((entry) => {
|
||||
if (!entry.expiresAt) return true;
|
||||
return new Date(entry.expiresAt).getTime() > now.getTime();
|
||||
});
|
||||
|
|
@ -229,7 +233,9 @@ export function rotateJwtSecret(gracePeriodMs: number = SECRET_GRACE_PERIOD_MS):
|
|||
};
|
||||
saveSecurityConfig(updatedConfig);
|
||||
|
||||
console.log(`JWT secret rotated to version ${newVersion}. ${prunedCount} expired secret(s) pruned.`);
|
||||
console.log(
|
||||
`JWT secret rotated to version ${newVersion}. ${prunedCount} expired secret(s) pruned.`
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
|
@ -255,8 +261,8 @@ export function getJwtRotationStatus(): {
|
|||
if (usingEnvVar || !config.jwtSecrets || config.jwtSecrets.length === 0) {
|
||||
return {
|
||||
currentVersion: config.jwtSecretVersion || 0,
|
||||
totalSecrets: usingEnvVar ? 1 : (config.jwtSecret ? 1 : 0),
|
||||
validSecrets: usingEnvVar ? 1 : (config.jwtSecret ? 1 : 0),
|
||||
totalSecrets: usingEnvVar ? 1 : config.jwtSecret ? 1 : 0,
|
||||
validSecrets: usingEnvVar ? 1 : config.jwtSecret ? 1 : 0,
|
||||
usingEnvVar,
|
||||
secrets: [],
|
||||
};
|
||||
|
|
@ -268,9 +274,10 @@ export function getJwtRotationStatus(): {
|
|||
return {
|
||||
currentVersion,
|
||||
totalSecrets: sorted.length,
|
||||
validSecrets: sorted.filter(s => !s.expiresAt || new Date(s.expiresAt).getTime() > now).length,
|
||||
validSecrets: sorted.filter((s) => !s.expiresAt || new Date(s.expiresAt).getTime() > now)
|
||||
.length,
|
||||
usingEnvVar,
|
||||
secrets: sorted.map(s => ({
|
||||
secrets: sorted.map((s) => ({
|
||||
version: s.version,
|
||||
createdAt: s.createdAt,
|
||||
expiresAt: s.expiresAt,
|
||||
|
|
@ -288,16 +295,16 @@ export function saveSecurityConfig(config: SecurityConfig): void {
|
|||
if (!fs.existsSync(DATA_DIR)) {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
|
||||
// Write atomically (write to temp, then rename)
|
||||
const tempPath = SECURITY_CONFIG_PATH + '.tmp';
|
||||
fs.writeFileSync(tempPath, JSON.stringify(config, null, 2), 'utf-8');
|
||||
fs.renameSync(tempPath, SECURITY_CONFIG_PATH);
|
||||
|
||||
|
||||
// Update cache
|
||||
cachedConfig = config;
|
||||
lastLoadTime = Date.now();
|
||||
|
||||
|
||||
console.log('Security config saved');
|
||||
} catch (err) {
|
||||
console.error('Error saving security config:', err);
|
||||
|
|
@ -313,14 +320,14 @@ export function generateRecoveryKey(): string {
|
|||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // Omit confusing chars (0/O, 1/I/L)
|
||||
let key = '';
|
||||
const bytes = crypto.randomBytes(16);
|
||||
|
||||
|
||||
for (let i = 0; i < 16; i++) {
|
||||
key += chars[bytes[i] % chars.length];
|
||||
if (i === 3 || i === 7 || i === 11) {
|
||||
key += '-';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
|
|
@ -349,6 +356,60 @@ export function resetSecurityConfig(): void {
|
|||
console.log('Security config reset. Next load will show setup screen.');
|
||||
}
|
||||
|
||||
/** Warning about JWT secret configuration */
|
||||
export interface JwtSecretWarning {
|
||||
level: 'critical' | 'warning' | 'info';
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check JWT secret configuration and return startup warnings.
|
||||
* Returns an array of warnings (empty if the secret is properly configured).
|
||||
*/
|
||||
export function checkJwtSecretConfig(): JwtSecretWarning[] {
|
||||
const warnings: JwtSecretWarning[] = [];
|
||||
const envSecret = process.env.VERITAS_JWT_SECRET;
|
||||
|
||||
if (envSecret) {
|
||||
// Env var set — best practice
|
||||
if (envSecret.length < 64) {
|
||||
warnings.push({
|
||||
level: 'warning',
|
||||
message: `VERITAS_JWT_SECRET is only ${envSecret.length} characters. Recommend at least 64 characters (use: node -e "console.log(require('crypto').randomBytes(64).toString('hex'))")`,
|
||||
});
|
||||
}
|
||||
return warnings;
|
||||
}
|
||||
|
||||
// Check if security.json has a persisted secret
|
||||
const config = getSecurityConfig();
|
||||
if (config.jwtSecrets && config.jwtSecrets.length > 0) {
|
||||
warnings.push({
|
||||
level: 'info',
|
||||
message:
|
||||
'JWT secret loaded from security.json. Consider setting VERITAS_JWT_SECRET env var for explicit configuration.',
|
||||
});
|
||||
return warnings;
|
||||
}
|
||||
if (config.jwtSecret) {
|
||||
warnings.push({
|
||||
level: 'info',
|
||||
message:
|
||||
'JWT secret loaded from security.json (legacy format). Consider setting VERITAS_JWT_SECRET env var.',
|
||||
});
|
||||
return warnings;
|
||||
}
|
||||
|
||||
// No env var, no persisted config — will be ephemeral
|
||||
warnings.push({
|
||||
level: 'critical',
|
||||
message:
|
||||
'No VERITAS_JWT_SECRET env var set and no persisted secret found. JWT secret will be generated at runtime (ephemeral) — all sessions will be invalidated on server restart. Set VERITAS_JWT_SECRET in .env for persistence.',
|
||||
});
|
||||
|
||||
return warnings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if password is configured
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import {
|
|||
type AuthenticatedWebSocket,
|
||||
} from './middleware/auth.js';
|
||||
import authRoutes from './routes/auth.js';
|
||||
import { checkJwtSecretConfig } from './config/security.js';
|
||||
import { apiRateLimit } from './middleware/rate-limit.js';
|
||||
import { apiVersionMiddleware } from './middleware/api-version.js';
|
||||
import { apiCacheHeaders } from './middleware/cache-control.js';
|
||||
|
|
@ -599,4 +600,16 @@ server.listen(PORT, () => {
|
|||
log.warn({ security: 'admin-key' }, warning.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Security warnings for JWT secret configuration
|
||||
const jwtWarnings = checkJwtSecretConfig();
|
||||
for (const warning of jwtWarnings) {
|
||||
if (warning.level === 'critical') {
|
||||
log.warn({ security: 'jwt-secret' }, `⚠️ SECURITY: ${warning.message}`);
|
||||
} else if (warning.level === 'warning') {
|
||||
log.warn({ security: 'jwt-secret' }, warning.message);
|
||||
} else {
|
||||
log.info({ security: 'jwt-secret' }, warning.message);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue