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:
2026-08-04 11:30:36 +05:30
parent 4df4d8c9b5
commit c9b38dc6ab
20 changed files with 2167 additions and 27 deletions

View File

@@ -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);
}

View File

@@ -32,7 +32,7 @@ export class RedisIoAdapter extends IoAdapter {
enableReadyCheck: false,
};
if (useTls) {
opts.tls = { rejectUnauthorized: false };
opts.tls = {};
}
const pubClient = new Redis(opts);

View File

@@ -18,7 +18,7 @@ export const redisProvider = {
},
};
if (useTls) {
opts.tls = { rejectUnauthorized: false };
opts.tls = {};
}
const client = new Redis(opts);

View File

@@ -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

View 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')}`,
);
}
}

View File

@@ -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
View 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();

View File

@@ -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';

View File

@@ -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'],

View File

@@ -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',
},