Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
84 changes: 84 additions & 0 deletions backend/src/auth/brute-force.guard.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { ConfigService } from '@nestjs/config';
import { BruteForceGuard } from './brute-force.guard';

describe('BruteForceGuard', () => {
it('should be defined', () => {
const guard = new BruteForceGuard({
get: () => '30',
} as unknown as ConfigService);
expect(guard).toBeDefined();
});

it('should lock account after 5 failed attempts', () => {
const guard = new BruteForceGuard({
get: () => '15',
} as unknown as ConfigService);
const email = 'target@example.com';

for (let i = 0; i < 5; i++) {
guard.recordFailedLogin(email);
}

const mockCtx = {
switchToHttp: () => ({
getRequest: () => ({ body: { email } }),
}),
};

expect(() => guard.canActivate(mockCtx as any)).toThrow(
'Account locked due to multiple failed login attempts',
);
});

it('should use configurable lock time from env', () => {
const guard = new BruteForceGuard({
get: (key: string, defaultVal: string) =>
key === 'BRUTE_FORCE_LOCK_MINUTES' ? '30' : defaultVal,
} as unknown as ConfigService);

const email = 'test@example.com';
guard.recordFailedLogin(email);
guard.recordFailedLogin(email);
guard.recordFailedLogin(email);
guard.recordFailedLogin(email);
guard.recordFailedLogin(email);

const record = (guard as any).failedAttempts.get(email);
const lockDurationMs = record.lockUntil - Date.now();
expect(lockDurationMs).toBeGreaterThan(29 * 60 * 1000);
expect(lockDurationMs).toBeLessThanOrEqual(30 * 60 * 1000);
});

it('should reset attempts', () => {
const guard = new BruteForceGuard({
get: () => '15',
} as unknown as ConfigService);
const email = 'reset@example.com';

guard.recordFailedLogin(email);
guard.recordFailedLogin(email);
guard.resetAttempts(email);

const mockCtx = {
switchToHttp: () => ({
getRequest: () => ({ body: { email } }),
}),
};

expect(() => guard.canActivate(mockCtx as any)).not.toThrow();
});

it('should allow access when no email in body', () => {
const guard = new BruteForceGuard({
get: () => '15',
} as unknown as ConfigService);

const mockCtx = {
switchToHttp: () => ({
getRequest: () => ({ body: {} }),
}),
};

expect(guard.canActivate(mockCtx as any)).toBe(true);
});
});
11 changes: 10 additions & 1 deletion backend/src/auth/brute-force.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
ExecutionContext,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';

@Injectable()
export class BruteForceGuard implements CanActivate {
Expand All @@ -12,7 +13,15 @@ export class BruteForceGuard implements CanActivate {
{ count: number; lockUntil?: Date }
>();
private readonly MAX_ATTEMPTS = 5;
private readonly LOCK_TIME_MS = 15 * 60 * 1000; // 15 mins
private readonly LOCK_TIME_MS: number;

constructor(private readonly configService: ConfigService) {
const lockMinutes = parseInt(
this.configService.get<string>('BRUTE_FORCE_LOCK_MINUTES', '15'),
10,
);
this.LOCK_TIME_MS = lockMinutes * 60 * 1000;
}

canActivate(context: ExecutionContext): boolean {
const req = context.switchToHttp().getRequest();
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;
}
}
6 changes: 3 additions & 3 deletions backend/src/risk-assessment/risk-assessment.controller.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';

import { RiskAssessmentService } from './risk-assessment.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
Expand All @@ -9,7 +9,7 @@ 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);
}
}
Loading