Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions backend/src/auth/brute-force.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,24 @@ import Redis from 'ioredis';
const LOCKOUT_PREFIX = 'brute:lock:';
const ATTEMPTS_PREFIX = 'brute:attempts:';
const MAX_ATTEMPTS = 5;
const LOCK_TIME_SECONDS = 15 * 60; // 15 minutes

@Injectable()
export class BruteForceGuard implements CanActivate {
private readonly redis: Redis;
private readonly lockTimeSeconds: number;

constructor(private readonly configService: ConfigService) {
const host = this.configService.get<string>('REDIS_HOST') || '127.0.0.1';
const port = Number(this.configService.get<string>('REDIS_PORT') || '6379');
const password =
this.configService.get<string>('REDIS_PASSWORD') || undefined;
this.redis = new Redis({ host, port, password });

const lockMinutes = parseInt(
this.configService.get<string>('BRUTE_FORCE_LOCK_MINUTES', '15'),
10,
);
this.lockTimeSeconds = lockMinutes * 60;
}

async canActivate(context: ExecutionContext): Promise<boolean> {
Expand All @@ -44,11 +50,11 @@ export class BruteForceGuard implements CanActivate {
async recordFailedLogin(email: string): Promise<void> {
const attemptsKey = `${ATTEMPTS_PREFIX}${email}`;
const count = await this.redis.incr(attemptsKey);
await this.redis.expire(attemptsKey, LOCK_TIME_SECONDS);
await this.redis.expire(attemptsKey, this.lockTimeSeconds);

if (count >= MAX_ATTEMPTS) {
const lockKey = `${LOCKOUT_PREFIX}${email}`;
await this.redis.setex(lockKey, LOCK_TIME_SECONDS, 'locked');
await this.redis.setex(lockKey, this.lockTimeSeconds, 'locked');
}
}

Expand Down
41 changes: 41 additions & 0 deletions backend/src/auth/dto/register-auth.dto.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { validate } from 'class-validator';
import { plainToInstance } from 'class-transformer';
import { RegisterAuthDto } from './register-auth.dto';

describe('RegisterAuthDto', () => {
it('should trim and lowercase email', async () => {
const dto = plainToInstance(RegisterAuthDto, {
email: ' Test@EXAMPLE.COM ',
password: 'password123',
fullName: ' John Doe ',
});
const errors = await validate(dto);
expect(errors.length).toBe(0);
expect(dto.email).toBe('test@example.com');
expect(dto.fullName).toBe('John Doe');
});

it('should reject invalid email', async () => {
const dto = plainToInstance(RegisterAuthDto, {
email: 'not-an-email',
password: 'password123',
fullName: 'John Doe',
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const emailErrors = errors.filter((e) => e.property === 'email');
expect(emailErrors.length).toBeGreaterThan(0);
});

it('should reject short password', async () => {
const dto = plainToInstance(RegisterAuthDto, {
email: 'test@example.com',
password: '12345',
fullName: 'John Doe',
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const passwordErrors = errors.filter((e) => e.property === 'password');
expect(passwordErrors.length).toBeGreaterThan(0);
});
});
3 changes: 3 additions & 0 deletions backend/src/auth/dto/register-auth.dto.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import { IsEmail, IsNotEmpty, MinLength } from 'class-validator';
import { Transform } from 'class-transformer';

export class RegisterAuthDto {
@IsEmail()
@Transform(({ value }) => value?.trim().toLowerCase())
email: string;

@IsNotEmpty()
@MinLength(6)
password: string;

@IsNotEmpty()
@Transform(({ value }) => value?.trim())
fullName: string;
}
106 changes: 106 additions & 0 deletions backend/src/common/filters/http-exception.filter.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { HttpExceptionFilter } from './http-exception.filter';
import { HttpException, HttpStatus } from '@nestjs/common';

describe('HttpExceptionFilter', () => {
const mockResponse = () => {
const res: any = {};
res.status = jest.fn().mockReturnValue(res);
res.json = jest.fn().mockReturnValue(res);
return res;
};

const mockRequest = (overrides = {}) =>
({
method: 'POST',
url: '/auth/login',
requestId: 'req-123',
...overrides,
}) as any;

const mockHost = (req: any, res: any) =>
({
switchToHttp: () => ({
getRequest: () => req,
getResponse: () => res,
}),
}) as any;

it('should pass through non-sensitive error messages', () => {
const filter = new HttpExceptionFilter(true);
const res = mockResponse();
const req = mockRequest();
const exception = new HttpException(
{ message: 'Email already registered' },
HttpStatus.CONFLICT,
);

filter.catch(exception, mockHost(req, res));

expect(res.status).toHaveBeenCalledWith(409);
const body = res.json.mock.calls[0][0];
expect(body.message).toBe('Email already registered');
});

it('should sanitize sensitive error messages in production', () => {
const filter = new HttpExceptionFilter(true);
const res = mockResponse();
const req = mockRequest();
const exception = new HttpException(
{ message: 'ER_DUP_ENTRY: Duplicate entry for key' },
HttpStatus.INTERNAL_SERVER_ERROR,
);

filter.catch(exception, mockHost(req, res));

const body = res.json.mock.calls[0][0];
expect(body.message).toBe(
'An unexpected error occurred. Please try again later.',
);
});

it('should NOT sanitize in non-production mode', () => {
const filter = new HttpExceptionFilter(false);
const res = mockResponse();
const req = mockRequest();
const exception = new HttpException(
{ message: 'ER_DUP_ENTRY: Duplicate entry' },
HttpStatus.INTERNAL_SERVER_ERROR,
);

filter.catch(exception, mockHost(req, res));

const body = res.json.mock.calls[0][0];
expect(body.message).toBe('ER_DUP_ENTRY: Duplicate entry');
});

it('should handle generic Error instances', () => {
const filter = new HttpExceptionFilter(false);
const res = mockResponse();
const req = mockRequest();
const exception = new Error('Something went wrong');

filter.catch(exception, mockHost(req, res));

expect(res.status).toHaveBeenCalledWith(500);
const body = res.json.mock.calls[0][0];
expect(body.statusCode).toBe(500);
expect(body.stack).toBeDefined();
});

it('should sanitize database connection errors in production', () => {
const filter = new HttpExceptionFilter(true);
const res = mockResponse();
const req = mockRequest();
const exception = new HttpException(
{ message: 'connect ECONNREFUSED 127.0.0.1:5432' },
HttpStatus.INTERNAL_SERVER_ERROR,
);

filter.catch(exception, mockHost(req, res));

const body = res.json.mock.calls[0][0];
expect(body.message).toBe(
'An unexpected error occurred. Please try again later.',
);
});
});
52 changes: 40 additions & 12 deletions backend/src/common/filters/http-exception.filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,24 @@ import {
} from '@nestjs/common';
import { Response, Request } from 'express';

const SENSITIVE_ERROR_PATTERNS = [
/password/i,
/secret/i,
/token/i,
/jwt/i,
/hash/i,
/bcrypt/i,
/e\.?r\.?r\.?o\.?r\.?/i,
/ENOENT/i,
/EACCES/i,
/connect.*refused/i,
/database/i,
/sql/i,
/query.*failed/i,
/constraint/i,
/duplicate/i,
];

@Catch()
export class HttpExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(HttpExceptionFilter.name);
Expand All @@ -26,21 +44,16 @@ export class HttpExceptionFilter implements ExceptionFilter {

const errorResponse = isHttp
? exception.getResponse()
: { message: (exception as Error)?.message }; // fallback message
: { message: 'An unexpected error occurred' };

const { message, error } = this.normalizeResponse(errorResponse, exception);


const requestId =
(request as any).requestId || request.headers['x-request-id'] || 'req-id';
const errorCode =
(errorResponse as any)?.errorCode || error || `ERR_${status}`;

const requestId = request.requestId || 'unknown';
const errorCode = (errorResponse as any)?.errorCode || error || `ERR_${status}`;


const payload = {
const payload: Record<string, unknown> = {
statusCode: status,
errorCode,
message,
Expand All @@ -50,7 +63,10 @@ export class HttpExceptionFilter implements ExceptionFilter {
path: request.url,
};

this.logger.error(`${status} ${request.method} ${request.url} -> ${message}`, (exception as Error)?.stack);
this.logger.error(
`${status} ${request.method} ${request.url} -> ${message}`,
(exception as Error)?.stack,
);

if (!this.isProduction && exception instanceof Error) {
Object.assign(payload, { stack: exception.stack });
Expand All @@ -67,24 +83,36 @@ export class HttpExceptionFilter implements ExceptionFilter {
let error = HttpStatus.INTERNAL_SERVER_ERROR.toString();

if (typeof response === 'string') {
message = response;
message = this.sanitizeMessage(response);
} else if (response && typeof response === 'object') {
const body = response as Record<string, any>;
if (body.message) {
message = Array.isArray(body.message)
const raw = Array.isArray(body.message)
? body.message.join(', ')
: body.message;
message = this.sanitizeMessage(raw);
} else if (exception instanceof Error && exception.message) {
message = exception.message;
message = this.sanitizeMessage(exception.message);
}

if (body.error) {
error = body.error;
}
} else if (exception instanceof Error) {
message = exception.message;
message = this.sanitizeMessage(exception.message);
}

return { message, error };
}

private sanitizeMessage(raw: string): string {
if (!this.isProduction) return raw;

for (const pattern of SENSITIVE_ERROR_PATTERNS) {
if (pattern.test(raw)) {
return 'An unexpected error occurred. Please try again later.';
}
}
return raw;
}
}
4 changes: 2 additions & 2 deletions backend/src/risk-assessment/risk-assessment.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ export class RiskAssessmentController {

@Get(':id/risk')
@UseGuards(JwtAuthGuard)
async getRisk(@Param('id') id: string) {
return this.riskService.assessDocument(id);
async getRisk(@Param('id') id: string, @Query('lang') lang?: string) {
return this.riskService.assessDocument(id, lang);
}

@Get('risk-assessments')
Expand Down
Loading