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
68 changes: 63 additions & 5 deletions src/shared/filters/validation-exception.filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,23 @@ import {
HttpStatus,
Logger,
} from '@nestjs/common';
import { Response } from 'express';
import { Request, Response } from 'express';
import { ValidationError } from 'class-validator';
import { sanitizeForLogging } from '../util/log-sanitizer.util';

@Catch(HttpException)
export class ValidationExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(ValidationExceptionFilter.name);

catch(exception: HttpException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const request = ctx.getRequest<Request>();
const response = ctx.getResponse<Response>();
const status = exception.getStatus();
const exceptionResponse = exception.getResponse();

let message = exception.message;
let errors: any = null;
let errors: Record<string, string> | null = null;

// Handle class-validator ValidationPipe errors specifically
if (
Expand Down Expand Up @@ -57,17 +59,36 @@ export class ValidationExceptionFilter implements ExceptionFilter {
message = (exceptionResponse as { message?: string }).message || message;
}

if (status === HttpStatus.BAD_REQUEST) {
const reason = this.extractBadRequestReason(
exceptionResponse,
message,
errors,
);
this.logger.warn(
`400 Bad Request: ${request.method} ${request.originalUrl} - Reason: ${reason}`,
);

if (this.isCreateUserRequest(request.method, request.originalUrl)) {
this.logger.warn(
`400 Request Body: ${JSON.stringify(sanitizeForLogging(request.body))}`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ correctness]
Consider handling potential JSON serialization errors when logging the request body. If request.body contains circular references or non-serializable values, JSON.stringify will throw an error.

);
}
}

response.status(status).json({
statusCode: status,
message: message,
errors: errors, // Include formatted validation errors if present
timestamp: new Date().toISOString(),
path: ctx.getRequest().url,
path: request.url,
});
}

private formatValidationErrors(validationErrors: ValidationError[]) {
const formattedErrors = {};
private formatValidationErrors(
validationErrors: ValidationError[],
): Record<string, string> {
const formattedErrors: Record<string, string> = {};
validationErrors.forEach((err) => {
formattedErrors[err.property] = Object.values(err.constraints || {}).join(
', ',
Expand All @@ -81,4 +102,41 @@ export class ValidationExceptionFilter implements ExceptionFilter {
});
return formattedErrors;
}

private extractBadRequestReason(
exceptionResponse: unknown,
fallbackMessage: string,
errors: Record<string, string> | null,
): string {
if (typeof exceptionResponse === 'string') {
return exceptionResponse;
}

if (
typeof exceptionResponse === 'object' &&
exceptionResponse !== null &&
'message' in exceptionResponse
) {
const responseMessage = (exceptionResponse as { message?: unknown })
.message;
if (Array.isArray(responseMessage)) {
return responseMessage.map((entry) => String(entry)).join('; ');
}
if (typeof responseMessage === 'string') {
return responseMessage;
}
return JSON.stringify(sanitizeForLogging(responseMessage));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[❗❗ security]
Ensure that sanitizeForLogging is robust against all types of input, especially since it is used to sanitize potentially sensitive data before logging. If responseMessage contains complex structures, ensure that the sanitizer handles them appropriately.

}

if (errors && Object.keys(errors).length > 0) {
return JSON.stringify(sanitizeForLogging(errors));
}

return fallbackMessage;
}

private isCreateUserRequest(method: string, originalUrl: string): boolean {
const pathOnly = originalUrl.split('?')[0];
return method.toUpperCase() === 'POST' && /\/v6\/users\/?$/.test(pathOnly);
}
}
24 changes: 23 additions & 1 deletion src/shared/middleware/request-logger.middleware.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Injectable, NestMiddleware, Logger } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import { sanitizeForLogging } from '../util/log-sanitizer.util';

@Injectable()
export class RequestLoggerMiddleware implements NestMiddleware {
Expand All @@ -12,7 +13,15 @@ export class RequestLoggerMiddleware implements NestMiddleware {
this.logger.log(
`---> ${method} ${originalUrl} - User-Agent: ${userAgent} - IP: ${req.ip}`,
);
this.logger.debug(`---> Request Headers: ${JSON.stringify(headers)}`);
this.logger.debug(
`---> Request Headers: ${JSON.stringify(sanitizeForLogging(headers))}`,
);

if (this.shouldLogCreateUserBody(method, originalUrl)) {
this.logger.debug(
`---> Request Body: ${JSON.stringify(sanitizeForLogging(req.body))}`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ correctness]
Consider handling potential errors from JSON.stringify when logging the request body. If req.body contains circular references, JSON.stringify will throw an error, which could disrupt the logging process.

);
}

// Optionally log when the request finishes
res.on('finish', () => {
Expand All @@ -24,4 +33,17 @@ export class RequestLoggerMiddleware implements NestMiddleware {

next();
}

private shouldLogCreateUserBody(
method: string,
originalUrl: string,
): boolean {
const normalizedMethod = method.toUpperCase();
if (normalizedMethod !== 'POST') {
return false;
}

const pathOnly = originalUrl.split('?')[0];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ correctness]
The use of originalUrl.split('?')[0] to extract the path might not handle edge cases where the URL contains encoded characters or unusual structures. Consider using a more robust URL parsing method to ensure accuracy.

return /\/v6\/users\/?$/.test(pathOnly);
}
}
67 changes: 67 additions & 0 deletions src/shared/util/log-sanitizer.util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
const CIRCULAR_REFERENCE = '[Circular]';
const REDACTED = '[REDACTED]';

function normalizeKey(key: string): string {
return key.toLowerCase().replace(/[^a-z0-9]/g, '');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ correctness]
The normalizeKey function replaces all non-alphanumeric characters with an empty string. Consider whether this might lead to unintended key collisions, especially if keys differ only by special characters.

}

function isSensitiveKey(key: string): boolean {
const normalizedKey = normalizeKey(key);
return (
normalizedKey.includes('password') ||
normalizedKey.includes('token') ||
normalizedKey.includes('secret') ||
normalizedKey.includes('authorization') ||
normalizedKey.includes('cookie') ||
normalizedKey === 'otp' ||
normalizedKey.endsWith('otp') ||
normalizedKey === 'apikey' ||
normalizedKey.endsWith('apikey')
);
}

function sanitizeValue(value: unknown, seen: WeakSet<object>): unknown {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ correctness]
The sanitizeValue function uses a WeakSet to track seen objects and prevent circular references. Ensure that all potential object types that might be logged are compatible with WeakSet, as it only accepts objects as keys.

if (value === null || value === undefined) {
return value;
}

if (Array.isArray(value)) {
return value.map((item) => sanitizeValue(item, seen));
}

if (value instanceof Date) {
return value.toISOString();
}

if (typeof value !== 'object') {
return value;
}

if (Buffer.isBuffer(value)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ correctness]
The check for Buffer.isBuffer(value) assumes that the environment supports Node.js Buffers. If this code is intended to run in environments where Buffers are not available, consider adding a check for Buffer existence.

return `[Buffer:${value.length}]`;
}

if (seen.has(value)) {
return CIRCULAR_REFERENCE;
}

seen.add(value);

const source = value as Record<string, unknown>;
const sanitized: Record<string, unknown> = {};

for (const [key, nestedValue] of Object.entries(source)) {
if (isSensitiveKey(key)) {
sanitized[key] = REDACTED;
continue;
}

sanitized[key] = sanitizeValue(nestedValue, seen);
}

return sanitized;
}

export function sanitizeForLogging(value: unknown): unknown {
return sanitizeValue(value, new WeakSet<object>());
}
Loading