Skip to content

Commit

Permalink
feat(http-exception-filter): add option to mask headers
Browse files Browse the repository at this point in the history
  • Loading branch information
g-ongenae committed Jun 21, 2024
1 parent 3559108 commit ca3f18e
Show file tree
Hide file tree
Showing 2 changed files with 142 additions and 4 deletions.
108 changes: 105 additions & 3 deletions packages/http-exception-filter/src/http-exception-filter.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,70 @@
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<string, boolean | ((headerValue: string | string[]) => 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
*/
@Catch()
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
*/
Expand Down Expand Up @@ -50,15 +104,15 @@ 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,
);
} else if (status >= HttpStatus.BAD_REQUEST) {
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({
Expand All @@ -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<string, unknown> {
if (this.disableMasking || this.mask?.requestHeader === undefined) {
return headers;
}

return Object.keys(headers).reduce<Record<string, unknown>>(
(maskedHeaders: Record<string, unknown>, headerKey: string): Record<string, unknown> => {
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,
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand Down Expand Up @@ -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': '****',
}),
});
});
});

0 comments on commit ca3f18e

Please sign in to comment.