From ca3f18e40cbcc248902b14860b4827b7831ffd89 Mon Sep 17 00:00:00 2001 From: Guillaume Ongenae Date: Fri, 21 Jun 2024 15:38:32 +0200 Subject: [PATCH] feat(http-exception-filter): add option to mask headers --- .../src/http-exception-filter.ts | 108 +++++++++++++++++- .../test/http-exception-filter.test.ts | 38 +++++- 2 files changed, 142 insertions(+), 4 deletions(-) diff --git a/packages/http-exception-filter/src/http-exception-filter.ts b/packages/http-exception-filter/src/http-exception-filter.ts index 8b2a3e4b..f7d6526d 100644 --- a/packages/http-exception-filter/src/http-exception-filter.ts +++ b/packages/http-exception-filter/src/http-exception-filter.ts @@ -1,9 +1,53 @@ import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus, Logger } from '@nestjs/common'; import { HttpArgumentsHost } from '@nestjs/common/interfaces'; -import { Response } from 'express'; +import { Request, Response } from 'express'; import { get } from 'lodash'; import { getCode, getErrorMessage } from './error.utils'; +/** + * Option to mask headers + */ +export type MaskHeaders = Record unknown)>; + +/** + * HttpExceptionFilter options + */ +export interface HttpExceptionFilterOptions { + /** + * Disable the masking of headers + * @default false + */ + disableMasking?: boolean; + + /** + * Placeholder to use when masking a header + * @default '****'; + */ + maskingPlaceholder?: string; + + /** + * Mask configuration + */ + mask?: { + /** + * The headers to mask with their mask configuration + * - `true` to replace the header value with the `maskingPlaceholder` + * - a function to replace the header value with the result of the function + * @example + * ```ts + * mask: { + * requestHeader: { + * // log authorization type only + * 'authorization': (headerValue: string) => headerValue.split(' ')[0], + * 'x-api-key': true, + * } + * } + * ``` + */ + requestHeader?: MaskHeaders; + }; +} + /** * Catch and format thrown exception in NestJS application based on Express */ @@ -11,6 +55,16 @@ import { getCode, getErrorMessage } from './error.utils'; export class HttpExceptionFilter implements ExceptionFilter { private readonly logger: Logger = new Logger(HttpExceptionFilter.name); + private readonly disableMasking: boolean; + private readonly maskingPlaceholder: string; + private readonly mask: HttpExceptionFilterOptions['mask']; + + constructor(options?: HttpExceptionFilterOptions) { + this.disableMasking = options?.disableMasking ?? false; + this.maskingPlaceholder = options?.maskingPlaceholder ?? '****'; + this.mask = options?.mask ?? {}; + } + /** * Catch and format thrown exception */ @@ -50,7 +104,7 @@ export class HttpExceptionFilter implements ExceptionFilter { this.logger.error( { message: `${status} [${request.method} ${request.url}] has thrown a critical error`, - headers: request.headers, + headers: this.maskHeaders(request.headers), }, exceptionStack, ); @@ -58,7 +112,7 @@ export class HttpExceptionFilter implements ExceptionFilter { this.logger.warn({ message: `${status} [${request.method} ${request.url}] has thrown an HTTP client error`, exceptionStack, - headers: request.headers, + headers: this.maskHeaders(request.headers), }); } response.status(status).send({ @@ -67,4 +121,52 @@ export class HttpExceptionFilter implements ExceptionFilter { status, }); } + + /** + * Mask the given headers + * @param headers the headers to mask + * @returns the masked headers + */ + private maskHeaders(headers: Request['headers']): Record { + if (this.disableMasking || this.mask?.requestHeader === undefined) { + return headers; + } + + return Object.keys(headers).reduce>( + (maskedHeaders: Record, headerKey: string): Record => { + const headerValue = headers[headerKey]; + const mask = this.mask?.requestHeader?.[headerKey]; + + if (headerValue === undefined) { + return maskedHeaders; + } + + if (mask === true) { + return { + ...maskedHeaders, + [headerKey]: this.maskingPlaceholder, + }; + } + + if (typeof mask === 'function') { + try { + return { + ...maskedHeaders, + [headerKey]: mask(headerValue), + }; + } catch (error) { + this.logger.warn(`HttpFilterOptions - Masking error for header ${headerKey}`, { error, mask, headerKey }); + + return { + ...maskedHeaders, + [headerKey]: this.maskingPlaceholder, + }; + } + } + + return maskedHeaders; + }, + headers, + ); + } } diff --git a/packages/http-exception-filter/test/http-exception-filter.test.ts b/packages/http-exception-filter/test/http-exception-filter.test.ts index 5af0d233..f87b0871 100644 --- a/packages/http-exception-filter/test/http-exception-filter.test.ts +++ b/packages/http-exception-filter/test/http-exception-filter.test.ts @@ -15,7 +15,17 @@ describe('Http Exception Filter', () => { app = moduleRef.createNestApplication(); app.useLogger(Logger); app.useGlobalPipes(new ValidationPipe()); - app.useGlobalFilters(new HttpExceptionFilter()); + app.useGlobalFilters( + new HttpExceptionFilter({ + mask: { + requestHeader: { + authorization: (headerValue: string | string[]) => + typeof headerValue === 'string' ? headerValue.split(' ')[0] : undefined, + 'x-api-key': true, + }, + }, + }), + ); await app.init(); }); @@ -156,4 +166,30 @@ describe('Http Exception Filter', () => { status: 413, }); }); + + it('should mask the headers', async () => { + const warnSpy: jest.SpyInstance = jest.spyOn(Logger.prototype, 'warn'); + const url: string = `/cats/notfound`; + + const { body: resBody } = await request(app.getHttpServer()) + .get(url) + .set('Authorization', 'Bearer 123456') + .set('X-API-Key', '123456') + .expect(HttpStatus.NOT_FOUND); + + expect(resBody).toEqual({ + code: 'UNKNOWN_ENTITY', + message: 'Id notfound could not be found', + status: 404, + }); + + expect(warnSpy).toHaveBeenCalledWith({ + message: `404 [GET ${url}] has thrown an HTTP client error`, + exceptionStack: expect.any(String), + headers: expect.objectContaining({ + authorization: 'Bearer', + 'x-api-key': '****', + }), + }); + }); });