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
2 changes: 1 addition & 1 deletion src/auth/dto/auth.dto.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { IsEmail, IsString, IsEnum, IsOptional, IsNotEmpty } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { UserRole } from '../../users/entities/user.entity';
import { IsStrongPassword } from '../../common/validators/is-strong-password.validator';
import { IsStrongPassword } from '../../common/validators/password.validator';

export class RegisterDto {
@ApiProperty({ example: 'john.doe@example.com' })
Expand Down
38 changes: 38 additions & 0 deletions src/common/interceptors/global-exception.filter.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { GlobalExceptionFilter } from './global-exception.filter';
import { HttpStatus } from '@nestjs/common';
import { runWithCorrelationId } from '../utils/correlation.utils';

describe('GlobalExceptionFilter', () => {
it('adds correlation ID to error response and header', () => {
const filter = new GlobalExceptionFilter();

const req: any = { method: 'GET', url: '/test' };
const responseHeaders: Record<string, string> = {};
const res: any = {
status: (code: number) => {
res.statusCode = code;
return res;
},
json: (body: any) => {
res.body = body;
return res;
},
setHeader: (name: string, value: string) => {
responseHeaders[name.toLowerCase()] = value;
},
getHeader: (name: string) => responseHeaders[name.toLowerCase()],
};

runWithCorrelationId(() => {
filter.catch(new Error('Test error'), {
switchToHttp: () => ({ getRequest: () => req, getResponse: () => res }),
} as any);
}, 'cid-123');

const body = res.body;

expect(res.statusCode).toBe(HttpStatus.INTERNAL_SERVER_ERROR);
expect(body.correlationId).toBe('cid-123');
expect(body.message).toBe('Test error');
});
});
8 changes: 8 additions & 0 deletions src/common/interceptors/global-exception.filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import { Request, Response } from 'express';
import { QueryFailedError, EntityNotFoundError } from 'typeorm';
import { ApiError, ValidationErrorDetail } from '../../interfaces/api-error.interface';
import { CORRELATION_ID_HEADER, getCorrelationId } from '../utils/correlation.utils';

@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
Expand All @@ -22,16 +23,23 @@

const { statusCode, message, error, details, stack } = this.resolveException(exception);

const correlationId = getCorrelationId();

const errorResponse: ApiError = {
statusCode,
message,
error,
timestamp: new Date().toISOString(),
path: request.url,
correlationId,

Check failure on line 34 in src/common/interceptors/global-exception.filter.ts

View workflow job for this annotation

GitHub Actions / TypeScript Type Check

Object literal may only specify known properties, and 'correlationId' does not exist in type 'ApiError'.
...(details?.length && { details }),
...(!this.isProduction && stack && { stack }),
};

if (correlationId) {
response.setHeader(CORRELATION_ID_HEADER, correlationId);
}

this.logger.error(
`[${request.method}] ${request.url} → ${statusCode} ${error}: ${
Array.isArray(message) ? message.join(', ') : message
Expand Down
33 changes: 33 additions & 0 deletions src/common/interceptors/logging.interceptor.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { LoggingInterceptor } from './logging.interceptor';
import { of, firstValueFrom } from 'rxjs';

describe('LoggingInterceptor', () => {
it('attaches and propagates correlation ID header', async () => {
const interceptor = new LoggingInterceptor();

const req: any = { method: 'GET', url: '/spam', headers: {} };
const headers: Record<string, string> = {};
const res: any = {
statusCode: 200,
setHeader: (name: string, value: string) => {
headers[name.toLowerCase()] = value;
},
getHeader: (name: string) => headers[name.toLowerCase()],
};

const context: any = {
getType: () => 'http',
switchToHttp: () => ({ getRequest: () => req, getResponse: () => res }),
};

const next: any = {
handle: () => of({ success: true }),
};

await firstValueFrom(interceptor.intercept(context, next));

const correlationId = res.getHeader('x-request-id');
expect(typeof correlationId).toBe('string');
expect(correlationId).toMatch(/^cid-/);
});
});
12 changes: 8 additions & 4 deletions src/common/interceptors/logging.interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Injectable, NestInterceptor, ExecutionContext, CallHandler, Logger } fr
import { Observable, throwError } from 'rxjs';
import { tap, catchError } from 'rxjs/operators';
import { Request, Response } from 'express';
import { CORRELATION_ID_HEADER, getCorrelationId } from '../utils/correlation.utils';

export interface RequestLog {
requestId: string;
Expand Down Expand Up @@ -49,7 +50,10 @@ export class LoggingInterceptor implements NestInterceptor {
}

const startTime = Date.now();
const requestId = this.generateRequestId();
const requestId = getCorrelationId() || this.generateRequestId();

const response = httpCtx.getResponse<Response>();
response?.setHeader(CORRELATION_ID_HEADER, requestId);

const baseLog: RequestLog = {
requestId,
Expand All @@ -67,12 +71,12 @@ export class LoggingInterceptor implements NestInterceptor {

return next.handle().pipe(
tap(() => {
const response = httpCtx.getResponse<Response>();
const res = httpCtx.getResponse<Response>();
this.logOutgoing({
...baseLog,
statusCode: response.statusCode,
statusCode: res.statusCode,
responseTimeMs: Date.now() - startTime,
contentLength: this.getContentLength(response),
contentLength: this.getContentLength(res),
});
}),
catchError((error: unknown) => {
Expand Down
50 changes: 50 additions & 0 deletions src/common/utils/correlation.utils.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import {
correlationMiddleware,
getCorrelationId,
injectCorrelationIdToHeaders,
CORRELATION_ID_HEADER,
} from './correlation.utils';

describe('correlation.utils', () => {
it('generates and propagates correlation ID through middleware', (done) => {
const req: any = { method: 'GET', url: '/test', headers: {} };
const headers: Record<string, string> = {};
const res: any = {
setHeader: (name: string, value: string) => {
headers[name.toLowerCase()] = value;
},
getHeader: (name: string) => headers[name.toLowerCase()],
};

correlationMiddleware(req, res, () => {
const id = getCorrelationId();
expect(typeof id).toBe('string');
expect(res.getHeader(CORRELATION_ID_HEADER)).toBe(id);
done();
});
});

it('respects incoming x-request-id header', (done) => {
const incomingId = 'test-correlation-id';
const req: any = { method: 'GET', url: '/test', headers: { 'x-request-id': incomingId } };
const headers: Record<string, string> = {};
const res: any = {
setHeader: (name: string, value: string) => {
headers[name.toLowerCase()] = value;
},
getHeader: (name: string) => headers[name.toLowerCase()],
};

correlationMiddleware(req, res, () => {
expect(getCorrelationId()).toBe(incomingId);
expect(res.getHeader(CORRELATION_ID_HEADER)).toBe(incomingId);
done();
});
});

it('injects correlation header into outgoing request headers', () => {
const custom = injectCorrelationIdToHeaders({ Authorization: 'Bearer token' }, 'cid-1');
expect(custom[CORRELATION_ID_HEADER]).toBe('cid-1');
expect(custom.Authorization).toBe('Bearer token');
});
});
51 changes: 51 additions & 0 deletions src/common/utils/correlation.utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { AsyncLocalStorage } from 'async_hooks';
import { Request, Response, NextFunction } from 'express';

export const CORRELATION_ID_HEADER = 'x-request-id';

export interface CorrelationContext {
correlationId: string;
}

const correlationStorage = new AsyncLocalStorage<CorrelationContext>();

export function generateCorrelationId(): string {
return `cid-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
}

export function getCorrelationId(): string | undefined {
const store = correlationStorage.getStore();
return store?.correlationId;
}

export function setCorrelationId(req: Request, res: Response, correlationId: string): void {
(req as Request & { correlationId?: string }).correlationId = correlationId;
res.setHeader(CORRELATION_ID_HEADER, correlationId);
}

export function correlationMiddleware(req: Request, res: Response, next: NextFunction): void {
const incoming =
(req.headers[CORRELATION_ID_HEADER] as string) || (req.headers['x-correlation-id'] as string);
const correlationId = incoming || generateCorrelationId();

correlationStorage.run({ correlationId }, () => {
setCorrelationId(req, res, correlationId);
next();
});
}

export function runWithCorrelationId<T>(callback: () => T, correlationId?: string): T {
const id = correlationId || generateCorrelationId();
return correlationStorage.run({ correlationId: id }, callback);
}

export function injectCorrelationIdToHeaders(
headers: Record<string, any> = {},
correlationId?: string,
): Record<string, any> {
const id = correlationId || getCorrelationId() || generateCorrelationId();
return {
...headers,
[CORRELATION_ID_HEADER]: id,
};
}
32 changes: 32 additions & 0 deletions src/common/utils/sanitization.utils.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { sanitizeSqlLike, enforceWhitelistedValue } from './sanitization.utils';

describe('sanitization.utils', () => {
describe('sanitizeSqlLike', () => {
it('trims whitespace and escapes %, _, and \\', () => {
const raw = " test%_\\' OR 1=1 -- ";
const escaped = sanitizeSqlLike(raw);

expect(escaped).toBe("test\\%\\_\\\\' OR 1=1 --");
});

it('normalizes control characters to space', () => {
const raw = 'foo\nbar\tbaz\rqux';
const escaped = sanitizeSqlLike(raw);

expect(escaped).toBe('foo bar baz qux');
});
});

describe('enforceWhitelistedValue', () => {
it('returns value from allowlist', () => {
const value = enforceWhitelistedValue('active', ['active', 'inactive'] as const, 'status');
expect(value).toBe('active');
});

it('throws if value is not allowlisted', () => {
expect(() =>
enforceWhitelistedValue('hacked' as any, ['active', 'inactive'] as const, 'status'),
).toThrow(/Invalid value for status/);
});
});
});
32 changes: 32 additions & 0 deletions src/common/utils/sanitization.utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
export function sanitizeSqlLike(input: string): string {
if (typeof input !== 'string') {
throw new TypeError('Expected a string for SQL LIKE sanitization');
}

const trimmed = input.trim();

// Prevent CR/LF/Tab injection and normalize whitespace
const normalized = trimmed.replace(/[\r\n\t]+/g, ' ');

// Escape SQL wildcard and escape characters for LIKE operators.
// This makes sure user-supplied `%`, `_`, and `\\` are treated literally.
return normalized.replace(/[\\%_]/g, (char) => `\\${char}`);
}

export function enforceWhitelistedValue<T extends string>(
value: T | undefined,
allowlist: readonly T[],
fieldName: string,
): T | undefined {
if (value === undefined || value === null || value === '') {
return undefined;
}

if (!allowlist.includes(value as T)) {
throw new Error(
`Invalid value for ${fieldName}: ${value}. Allowed values are ${allowlist.join(', ')}`,
);
}

return value as T;
}
40 changes: 5 additions & 35 deletions src/common/validators/is-strong-password.validator.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,5 @@
import {
registerDecorator,
ValidationOptions,
ValidatorConstraint,
ValidatorConstraintInterface,
} from 'class-validator';

@ValidatorConstraint({ name: 'isStrongPassword', async: false })
export class IsStrongPasswordConstraint implements ValidatorConstraintInterface {
validate(password: string) {
if (typeof password !== 'string') return false;
const hasUpperCase = /[A-Z]/.test(password);
const hasLowerCase = /[a-z]/.test(password);
const hasNumber = /[0-9]/.test(password);
const hasSymbol = /[!@#$%^&*(),.?":{}|<>]/.test(password);

return password.length >= 8 && hasUpperCase && hasLowerCase && hasNumber && hasSymbol;
}

defaultMessage() {
return 'Password must be at least 8 characters long and contain at least one uppercase letter, one lowercase letter, one number, and one special character.';
}
}

export function IsStrongPassword(validationOptions?: ValidationOptions) {
return function (object: object, propertyName: string) {
registerDecorator({
target: object.constructor,
propertyName,
options: validationOptions,
constraints: [],
validator: IsStrongPasswordConstraint,
});
};
}
export {
IsStrongPassword,
calculatePasswordStrength,
PasswordStrengthResult,
} from './password.validator';
Loading
Loading