Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Logging] Masking authorization header #822

Merged
merged 7 commits into from
Dec 7, 2023
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,
Copy link
Contributor

Choose a reason for hiding this comment

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

In case of exception, we could log the error message instead.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Not sure to understand your proposition. You mean passing the message of the exception as the value of the header?

Copy link
Contributor

Choose a reason for hiding this comment

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

This always logs the masked headers. I meant we should have an another flow to log the exception occured during the header masking.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I proposed an implementation in my last commit. Tell me if you thought about another solution.

},
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> {
g-ongenae marked this conversation as resolved.
Show resolved Hide resolved
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,
Copy link
Contributor

Choose a reason for hiding this comment

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

I thought of logging the error and leaving the hearder value unmasked. But masking with default place holder is a better solution I think.

};
}
}

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');
});
});
});
Loading