fix(security): resolve audit findings — secrets, env contract, migrations
Removes hardcoded fallback secrets and makes a misconfigured deploy fail loudly instead of silently falling back to development defaults. - Remove insecure JWT fallback secrets (messages.module, configuration) - Remove the 'default-secret' fallback for the 2FA TOTP encryption key and allow a dedicated TWO_FACTOR_ENCRYPTION_KEY so rotating JWT_SECRET no longer locks out every 2FA user (see docs/2fa-key-rotation.md) - Require EMAIL_API_URL; drop the hardcoded vendor email endpoint - Drive WebSocket CORS from CORS_ORIGINS instead of origin:'*' - Load .env before any Nest module is imported (src/load-env.ts). Decorator arguments evaluate at import time, so the gateway previously froze its CORS config to the localhost fallback even when CORS_ORIGINS was set - Add boot-time env validation: missing required vars, weak JWT_SECRET, and inverted access/refresh token lifetimes now abort startup - Enable Redis TLS certificate verification - Require ADMIN_EMAIL/ADMIN_PASSWORD for the seed; remove the published default super-admin credentials and stop printing them - Add the initial Prisma migration and stop gitignoring prisma/migrations - Make .env.example an accurate configuration contract (admin bootstrap, REDIS_TLS, S3_ENDPOINT, 2FA key, Firebase path; drop the dead SMTP block) - Add handover documentation: architecture, ER model, sequence and data-flow diagrams, 2FA key rotation runbook Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -21,8 +21,21 @@ export class TwoFactorService {
|
||||
private readonly configService: ConfigService,
|
||||
private readonly jwtService: JwtService,
|
||||
) {
|
||||
// Use JWT secret as base for encryption key (derive a 32-byte key)
|
||||
const secret = this.configService.get<string>('jwt.secret') || 'default-secret';
|
||||
// Key for the stored TOTP secrets. Prefer a dedicated key so that rotating
|
||||
// JWT_SECRET does not make every existing 2FA secret undecryptable; fall
|
||||
// back to JWT_SECRET for records encrypted before that split existed.
|
||||
// No hardcoded default — an unset key must fail loudly, not silently
|
||||
// encrypt every user's TOTP secret under a publicly known value.
|
||||
const secret =
|
||||
this.configService.get<string>('TWO_FACTOR_ENCRYPTION_KEY') ||
|
||||
this.configService.get<string>('jwt.secret');
|
||||
|
||||
if (!secret) {
|
||||
throw new Error(
|
||||
'TWO_FACTOR_ENCRYPTION_KEY (or JWT_SECRET) must be set — refusing to encrypt 2FA secrets with a default key',
|
||||
);
|
||||
}
|
||||
|
||||
this.encryptionKey = crypto.scryptSync(secret, 'salt', 32);
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ export class RedisIoAdapter extends IoAdapter {
|
||||
enableReadyCheck: false,
|
||||
};
|
||||
if (useTls) {
|
||||
opts.tls = { rejectUnauthorized: false };
|
||||
opts.tls = {};
|
||||
}
|
||||
|
||||
const pubClient = new Redis(opts);
|
||||
|
||||
@@ -18,7 +18,7 @@ export const redisProvider = {
|
||||
},
|
||||
};
|
||||
if (useTls) {
|
||||
opts.tls = { rejectUnauthorized: false };
|
||||
opts.tls = {};
|
||||
}
|
||||
const client = new Redis(opts);
|
||||
|
||||
|
||||
@@ -16,9 +16,9 @@ export default () => ({
|
||||
|
||||
// JWT Authentication
|
||||
jwt: {
|
||||
secret: process.env.JWT_SECRET || 'super-secret-key',
|
||||
accessExpiration: process.env.JWT_ACCESS_EXPIRATION || '7d',
|
||||
refreshExpiration: process.env.JWT_REFRESH_EXPIRATION || '365d',
|
||||
secret: process.env.JWT_SECRET,
|
||||
accessExpiration: process.env.JWT_ACCESS_EXPIRATION || '15m',
|
||||
refreshExpiration: process.env.JWT_REFRESH_EXPIRATION || '7d',
|
||||
},
|
||||
|
||||
// Password Hashing
|
||||
|
||||
106
src/config/env.validation.ts
Normal file
106
src/config/env.validation.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Fail-fast validation of the environment.
|
||||
*
|
||||
* Runs before Nest boots so a misconfigured deploy dies at startup with a
|
||||
* readable message instead of half-working (or silently falling back to a
|
||||
* development default) in production.
|
||||
*/
|
||||
|
||||
const DURATION_RE = /^(\d+)\s*(ms|s|m|h|d|w|y)$/i;
|
||||
|
||||
const DURATION_MS: Record<string, number> = {
|
||||
ms: 1,
|
||||
s: 1000,
|
||||
m: 60_000,
|
||||
h: 3_600_000,
|
||||
d: 86_400_000,
|
||||
w: 604_800_000,
|
||||
y: 31_536_000_000,
|
||||
};
|
||||
|
||||
function parseDuration(value: string | undefined): number | null {
|
||||
if (!value) return null;
|
||||
const match = DURATION_RE.exec(value.trim());
|
||||
if (!match) return null;
|
||||
return Number(match[1]) * DURATION_MS[match[2].toLowerCase()];
|
||||
}
|
||||
|
||||
export function validateEnv(): void {
|
||||
const env = process.env;
|
||||
const isProduction = env.NODE_ENV === 'production';
|
||||
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
// --- Always required -----------------------------------------------------
|
||||
const required = ['DATABASE_URL', 'JWT_SECRET', 'EMAIL_API_URL'];
|
||||
for (const key of required) {
|
||||
if (!env[key]) errors.push(`${key} is not set`);
|
||||
}
|
||||
|
||||
// --- Required in production ---------------------------------------------
|
||||
// CORS_ORIGINS must be explicit: without it both the HTTP CORS config and
|
||||
// the WebSocket gateway fall back to localhost, which rejects every real
|
||||
// browser request.
|
||||
if (isProduction) {
|
||||
for (const key of ['CORS_ORIGINS', 'FRONTEND_URL']) {
|
||||
if (!env[key]) errors.push(`${key} must be set when NODE_ENV=production`);
|
||||
}
|
||||
|
||||
if (env.CORS_ORIGINS?.includes('localhost')) {
|
||||
warnings.push('CORS_ORIGINS contains localhost in a production deploy');
|
||||
}
|
||||
|
||||
if (env.JWT_SECRET && env.JWT_SECRET.length < 32) {
|
||||
errors.push('JWT_SECRET must be at least 32 characters in production');
|
||||
}
|
||||
|
||||
if (env.REDIS_TLS !== 'true') {
|
||||
warnings.push('REDIS_TLS is not enabled — Redis traffic is unencrypted');
|
||||
}
|
||||
|
||||
if (env.DATABASE_URL?.includes('sslmode=no-verify')) {
|
||||
warnings.push(
|
||||
'DATABASE_URL uses sslmode=no-verify — the database certificate is not verified',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Token lifetimes -----------------------------------------------------
|
||||
// A refresh token shorter than the access token it renews is always a
|
||||
// misconfiguration: refresh stops working before the access token expires.
|
||||
const accessRaw = env.JWT_ACCESS_EXPIRATION;
|
||||
const refreshRaw = env.JWT_REFRESH_EXPIRATION;
|
||||
const access = parseDuration(accessRaw);
|
||||
const refresh = parseDuration(refreshRaw);
|
||||
|
||||
if (accessRaw && access === null) {
|
||||
errors.push(`JWT_ACCESS_EXPIRATION is not a valid duration: "${accessRaw}"`);
|
||||
}
|
||||
if (refreshRaw && refresh === null) {
|
||||
errors.push(`JWT_REFRESH_EXPIRATION is not a valid duration: "${refreshRaw}"`);
|
||||
}
|
||||
|
||||
if (access !== null && refresh !== null && refresh <= access) {
|
||||
errors.push(
|
||||
`JWT_REFRESH_EXPIRATION (${refreshRaw}) must be longer than JWT_ACCESS_EXPIRATION (${accessRaw}) — these look inverted`,
|
||||
);
|
||||
}
|
||||
|
||||
if (access !== null && access > DURATION_MS.h) {
|
||||
warnings.push(
|
||||
`JWT_ACCESS_EXPIRATION is ${accessRaw} — a leaked access token stays valid that long; 15m is recommended`,
|
||||
);
|
||||
}
|
||||
|
||||
// --- Report --------------------------------------------------------------
|
||||
for (const warning of warnings) {
|
||||
console.warn(`[env] WARNING: ${warning}`);
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new Error(
|
||||
`Invalid environment configuration:\n${errors.map((e) => ` - ${e}`).join('\n')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -23,9 +23,11 @@ export class EmailService {
|
||||
private readonly endpoint: string;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
this.endpoint =
|
||||
this.configService.get<string>('EMAIL_API_URL') ||
|
||||
'https://email.routes.hiffi.com/superlabs/v1/send';
|
||||
const endpoint = this.configService.get<string>('EMAIL_API_URL');
|
||||
if (!endpoint) {
|
||||
throw new Error('EMAIL_API_URL is not configured');
|
||||
}
|
||||
this.endpoint = endpoint;
|
||||
this.logger.log(`Email service initialized → ${this.endpoint}`);
|
||||
}
|
||||
|
||||
|
||||
26
src/load-env.ts
Normal file
26
src/load-env.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Loads .env files into process.env BEFORE any Nest module is imported.
|
||||
*
|
||||
* Why this file exists: decorator arguments are evaluated at module-import
|
||||
* time, which is strictly earlier than ConfigModule.forRoot(). MessagesGateway
|
||||
* reads process.env.CORS_ORIGINS inside its @WebSocketGateway decorator, so
|
||||
* without this the WebSocket server is built with the localhost fallback even
|
||||
* when CORS_ORIGINS is set in .env — silently breaking real-time messaging in
|
||||
* production.
|
||||
*
|
||||
* Must be imported as the very first import of main.ts. It has to be a
|
||||
* side-effect import (not a function call) because TypeScript emits all
|
||||
* require() calls ahead of any statement in the file.
|
||||
*
|
||||
* Precedence matches ConfigModule's envFilePath: ['.env.local', '.env'].
|
||||
* dotenv never overwrites an already-set variable, so real process env
|
||||
* (Docker/systemd) always wins over both files.
|
||||
*/
|
||||
import * as dotenv from 'dotenv';
|
||||
import { validateEnv } from './config/env.validation';
|
||||
|
||||
dotenv.config({ path: '.env.local' });
|
||||
dotenv.config({ path: '.env' });
|
||||
|
||||
// Fail fast on a bad deploy rather than booting into a broken state.
|
||||
validateEnv();
|
||||
@@ -1,3 +1,6 @@
|
||||
// MUST stay the first import — see the comment in load-env.ts.
|
||||
import './load-env';
|
||||
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe, Logger, BadRequestException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
@@ -25,7 +25,10 @@ interface AuthenticatedSocket extends Socket {
|
||||
|
||||
@WebSocketGateway({
|
||||
cors: {
|
||||
origin: '*',
|
||||
origin: process.env.CORS_ORIGINS?.split(',') || [
|
||||
'http://localhost:3000',
|
||||
'http://localhost:3002',
|
||||
],
|
||||
credentials: true,
|
||||
},
|
||||
transports: ['websocket', 'polling'],
|
||||
|
||||
@@ -19,7 +19,7 @@ import { RedisPresenceService } from '../common/services/redis-presence.service'
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
secret: configService.get<string>('JWT_SECRET') || 'fallback-secret',
|
||||
secret: configService.get<string>('JWT_SECRET'),
|
||||
signOptions: {
|
||||
expiresIn: '15m',
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user