Skip to content

Commit

Permalink
Merge pull request #822 from amarlankri/logging/feat/header-mask
Browse files Browse the repository at this point in the history
[Logging] Masking authorization header
  • Loading branch information
g-ongenae authored Dec 7, 2023
2 parents 254f990 + 502c8e7 commit 13ee221
Show file tree
Hide file tree
Showing 3 changed files with 218 additions and 3 deletions.
8 changes: 8 additions & 0 deletions packages/logging-interceptor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,14 @@ import { LoggingInterceptor } from '@algoan/nestjs-logging-interceptor';
new LoggingInterceptor({
disableMasking: true, // Ignore masking options in the entire applications
maskingPlaceholder: 'hidden', // Replace the default placeholder '****' by a custom one
mask: {
requestHeader: {
password: true, // Mask the header 'password' in the request
authorization: (header: string | string[]) => {
... // Handle the header value to keep non sensitive data for instance
}
},
},
}),
},
],
Expand Down
107 changes: 105 additions & 2 deletions packages/logging-interceptor/src/logging.interceptor.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { IncomingHttpHeaders } from 'http';
import {
CallHandler,
ExecutionContext,
Expand All @@ -14,6 +15,48 @@ import { tap } from 'rxjs/operators';
import { parse, stringify } from 'flatted';
import { LogOptions, METHOD_LOG_METADATA } from './log.decorator';

/**
* Logging interceptor options
*/
export interface LoggingInterceptorOptions {
/**
* User prefix to add to the logs
*/
userPrefix?: string;
/**
* Disable masking
*/
disableMasking?: boolean;
/**
* Masking placeholder
*/
maskingPlaceholder?: string;
/**
* Masking options to apply to all routes
*/
mask?: LoggingInterceptorMaskingOptions;
}

/**
* Masking options of the logging interceptor
*/
export interface LoggingInterceptorMaskingOptions {
/**
* Masking options to apply to the headers of the request
*/
requestHeader?: RequestHeaderMask;
}

/**
* Masking options of the request headers
*/
export interface RequestHeaderMask {
/**
* Mask of a request header. The key is the header name and the value is a boolean or a function that returns the data to log.
*/
[headerKey: string]: boolean | ((headerValue: string | string[]) => unknown);
}

/**
* Interceptor that logs input/output requests
*/
Expand All @@ -24,11 +67,13 @@ export class LoggingInterceptor implements NestInterceptor {
private userPrefix: string;
private disableMasking: boolean;
private maskingPlaceholder: string | undefined;
private mask: LoggingInterceptorMaskingOptions | undefined;

constructor(@Optional() options?: { userPrefix?: string; disableMasking?: boolean; maskingPlaceholder?: string }) {
constructor(@Optional() options?: LoggingInterceptorOptions) {
this.userPrefix = options?.userPrefix ?? '';
this.disableMasking = options?.disableMasking ?? false;
this.maskingPlaceholder = options?.maskingPlaceholder ?? '****';
this.mask = options?.mask;
}

/**
Expand All @@ -54,6 +99,15 @@ export class LoggingInterceptor implements NestInterceptor {
public setMaskingPlaceholder(placeholder: string | undefined): void {
this.maskingPlaceholder = placeholder;
}

/**
* Set the masking options
* @param mask
*/
public setMask(mask: LoggingInterceptorMaskingOptions): void {
this.mask = mask;
}

/**
* Intercept method, logs before and after the request being processed
* @param context details about the current request
Expand All @@ -68,13 +122,14 @@ export class LoggingInterceptor implements NestInterceptor {

// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
const maskedBody = options?.mask?.request ? this.maskData(body, options.mask.request) : body;
const maskedHeaders = this.maskHeaders(headers);

this.logger.log(
{
message,
method,
body: maskedBody,
headers,
headers: maskedHeaders,
},
ctx,
);
Expand Down Expand Up @@ -209,4 +264,52 @@ export class LoggingInterceptor implements NestInterceptor {

return parsedData;
}

/**
* Mask the given headers
* @param headers the headers to mask
* @returns the masked headers
*/
private maskHeaders(headers: IncomingHttpHeaders): 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 (err) {
this.logger.warn(`LoggingInterceptor - Masking error for header ${headerKey}`, err);

return {
...maskedHeaders,
[headerKey]: this.maskingPlaceholder,
};
}
}

return maskedHeaders;
},
headers,
);
}
}
106 changes: 105 additions & 1 deletion packages/logging-interceptor/test/logging.interceptor.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable max-lines */
import {
BadRequestException,
HttpStatus,
Expand Down Expand Up @@ -154,7 +155,7 @@ describe('Logging interceptor', () => {
});
});

describe('Masking options', () => {
describe('@Log - Masking options', () => {
const placeholder = '****';

it('allows to mask given properties of the request body', async () => {
Expand Down Expand Up @@ -397,4 +398,107 @@ describe('Logging interceptor', () => {
]);
});
});

describe('LoggingInterceptor - Masking options', () => {
const placeholder = '****';

it('allows to mask the whole content of a request header', async () => {
const interceptor = app.get(ApplicationConfig).getGlobalInterceptors()[0] as LoggingInterceptor;
interceptor.setMask({ requestHeader: { authorization: true } });
const logSpy: jest.SpyInstance = jest.spyOn(Logger.prototype, 'log');
const url: string = `/cats/ok`;

await request(app.getHttpServer()).get(url).set('authorization', 'access-token').expect(HttpStatus.OK);

expect(logSpy.mock.calls[0][0].headers.authorization).toEqual(placeholder);
});

it('allows to mask a request header with a specific handler function', async () => {
const interceptor = app.get(ApplicationConfig).getGlobalInterceptors()[0] as LoggingInterceptor;
interceptor.setMask({
requestHeader: {
authorization: (header: string | string[]) => {
if (typeof header === 'string') {
const [type, value] = header.split(' ');

return { type, value };
} else {
return header;
}
},
},
});
const logSpy: jest.SpyInstance = jest.spyOn(Logger.prototype, 'log');
const url: string = `/cats/ok`;

await request(app.getHttpServer()).get(url).set('authorization', 'Bearer JWT').expect(HttpStatus.OK);

expect(logSpy.mock.calls[0][0].headers.authorization).toEqual({
type: 'Bearer',
value: 'JWT',
});
});

it('should not mask a request header if the corresponding mask is undefined', async () => {
const interceptor = app.get(ApplicationConfig).getGlobalInterceptors()[0] as LoggingInterceptor;
interceptor.setMask({ requestHeader: {} });
const logSpy: jest.SpyInstance = jest.spyOn(Logger.prototype, 'log');
const url: string = `/cats/ok`;

await request(app.getHttpServer()).get(url).set('authorization', 'Bearer JWT').expect(HttpStatus.OK);

expect(logSpy.mock.calls[0][0].headers.authorization).toBe('Bearer JWT');
});

it('should not mask a request header if the corresponding mask is false', async () => {
const interceptor = app.get(ApplicationConfig).getGlobalInterceptors()[0] as LoggingInterceptor;
interceptor.setMask({ requestHeader: { authorization: false } });
const logSpy: jest.SpyInstance = jest.spyOn(Logger.prototype, 'log');
const url: string = `/cats/ok`;

await request(app.getHttpServer()).get(url).set('authorization', 'Bearer JWT').expect(HttpStatus.OK);

expect(logSpy.mock.calls[0][0].headers.authorization).toBe('Bearer JWT');
});

it('should not modify the request header if it is not passed with the request but defined in masking option', async () => {
const interceptor = app.get(ApplicationConfig).getGlobalInterceptors()[0] as LoggingInterceptor;
interceptor.setMask({ requestHeader: { authorization: true } });
const logSpy: jest.SpyInstance = jest.spyOn(Logger.prototype, 'log');
const url: string = `/cats/ok`;

await request(app.getHttpServer()).get(url).expect(HttpStatus.OK);

expect(logSpy.mock.calls[0][0].headers.authorization).toBeUndefined();
});

it('should not fail if the masking function throws an error and mask the whole header as fallback', async () => {
const interceptor = app.get(ApplicationConfig).getGlobalInterceptors()[0] as LoggingInterceptor;
interceptor.setMask({
requestHeader: {
authorization: () => {
throw new Error('This is an error');
},
},
});
const logSpy: jest.SpyInstance = jest.spyOn(Logger.prototype, 'log');
const url: string = `/cats/ok`;

await request(app.getHttpServer()).get(url).set('authorization', 'Bearer JWT').expect(HttpStatus.OK);

expect(logSpy.mock.calls[0][0].headers.authorization).toBe(placeholder);
});

it('should not mask request headers if masking is disabled', async () => {
const interceptor = app.get(ApplicationConfig).getGlobalInterceptors()[0] as LoggingInterceptor;
interceptor.setMask({ requestHeader: { authorization: true } });
interceptor.setDisableMasking(true);
const logSpy: jest.SpyInstance = jest.spyOn(Logger.prototype, 'log');
const url: string = `/cats/ok`;

await request(app.getHttpServer()).get(url).set('authorization', 'Bearer JWT').expect(HttpStatus.OK);

expect(logSpy.mock.calls[0][0].headers.authorization).toBe('Bearer JWT');
});
});
});

0 comments on commit 13ee221

Please sign in to comment.