In previous parts of this series, we’ve learned how to implement logging in our REST API. Logs play an important role in debugging and maintaining reliable services. However, they can pose security and privacy risks when misused. In this article, we will learn how to prevent sensitive information from being stored in our logs.
Logging with an interceptor
With interceptors, we can access and manipulate the data flowing between the client and the server. We can apply interceptors to controllers and routes in a similar way as middleware. Since interceptors can access the request and response, we can use an interceptor that logs incoming requests and our responses.
1import { Injectable, NestInterceptor } from '@nestjs/common';
2
3@Injectable()
4export class LoggerInterceptor implements NestInterceptor {
5 intercept() {
6 // ...
7 }
8}Our interceptors need to implement the NestInterceptor interface. This requires us to implement the intercept method that NestJS calls before invoking the controller method.
1@Post('register')
2@UseInterceptors(LoggerInterceptor)
3async register(@Body() registrationData: RegisterDto) {
4 return this.authenticationService.register(registrationData);
5}This means that the intercept method is called before the register method.
1import {
2 Injectable,
3 NestInterceptor,
4 ExecutionContext,
5 CallHandler,
6} from '@nestjs/common';
7import { Request, Response } from 'express';
8
9@Injectable()
10export class LoggerInterceptor implements NestInterceptor {
11 intercept(context: ExecutionContext, next: CallHandler) {
12 const httpContext = context.switchToHttp();
13 const request = httpContext.getRequest<Request>();
14 const response = httpContext.getResponse<Response>();
15
16 // ...
17
18 return next.handle();
19 }
20}The intercept method has two arguments. The first is the context that provides the information about the incoming request. By using the context.switchToHttp() method, we can access the HTTP-specific context of a request. This allows us to use the getRequest and getResponse methods to access the Request and Response objects containing useful data related to the request and the response.
NestJS handles requests in a sequence called the request lifecycle that includes request processing, route handling, and generating the response. A request lifecycle consists of pieces such as guards, interceptors, pipes, and controller method handlers. The request lifecycle executes in a specific order. The second argument of the intercept method is the next object. Calling the next.handle() method tells NestJS to go to the next part of the request lifecycle. Forgetting about calling it would cause our API to hang.
NestJS uses interceptors before sending the response. Since we want to include the information about the response in our logs, we need to wait for NestJS to prepare the response. To do that, we can wait for the finish event to happen.
1import {
2 Injectable,
3 NestInterceptor,
4 ExecutionContext,
5 CallHandler,
6 Logger,
7} from '@nestjs/common';
8import { Request, Response } from 'express';
9
10@Injectable()
11export class LoggerInterceptor implements NestInterceptor {
12 private readonly logger = new Logger('HTTP');
13
14 intercept(context: ExecutionContext, next: CallHandler) {
15 const httpContext = context.switchToHttp();
16 const request = httpContext.getRequest<Request>();
17 const response = httpContext.getResponse<Response>();
18
19 response.on('finish', () => {
20 const { method, originalUrl } = request;
21 const { statusCode, statusMessage } = response;
22
23 const message = `${method} ${originalUrl} ${statusCode} ${statusMessage}`;
24
25 if (statusCode >= 500) {
26 return this.logger.error(message);
27 }
28
29 if (statusCode >= 400) {
30 return this.logger.warn(message);
31 }
32
33 return this.logger.log(message);
34 });
35
36 return next.handle();
37 }
38}Above, we log a message that contains the HTTP method, the URL of the request, and the status. We can use the useGlobalInterceptors to attach our interceptor to all routes.
1import { NestFactory } from '@nestjs/core';
2import { AppModule } from './app.module';
3import { LogLevel } from '@nestjs/common';
4import { LoggerInterceptor } from './utils/logger.interceptor';
5
6async function bootstrap() {
7 const isProduction = process.env.NODE_ENV === 'production';
8 const logLevels: LogLevel[] = isProduction
9 ? ['error', 'warn', 'log']
10 : ['error', 'warn', 'log', 'verbose', 'debug'];
11
12 const app = await NestFactory.create(AppModule, {
13 logger: logLevels,
14 });
15
16 app.useGlobalInterceptors(new LoggerInterceptor());
17
18 // ...
19
20 await app.listen(3000);
21}
22bootstrap();If you want to know more about logging with NestJS, check out API with NestJS #113. Logging with Prisma
Handling the sensitive information
The above log might not be enough to debug our application and determine what’s wrong. To do that, we might want to store the request body.
1const { method, originalUrl, body } = request;
2const { statusCode, statusMessage } = response;
3
4const message = `${method} ${originalUrl} ${statusCode} ${statusMessage} Request body: ${JSON.stringify(
5 body,
6)}`;The problem with the above approach is that we log sensitive information, such as plain text passwords. Let’s use the class-transformer library to create a Data Transfer Object class that hides the password.
1import { Transform } from 'class-transformer';
2
3class RegistrationRequestLoggingDto {
4 email: string;
5 name: string;
6
7 @Transform(({ obj }) => {
8 return `[${typeof obj.password}]`;
9 })
10 password: string;
11}
12
13export default RegistrationRequestLoggingDto;Above, we are using the Transform decorator to store the type of the value instead of the value itself.
Attaching the DTO to the route
We now need a way to attach our new DTO class to a specific route. To do that, we can create a decorator that uses SetMetadata.
1import { SetMetadata } from '@nestjs/common';
2import { Class } from 'type-fest';
3
4export const PARSE_REQUEST_BODY_WHEN_LOGGING_KEY = 'request_body_logging';
5export const ParseRequestBodyWhenLogging = (dtoClass: Class<unknown>) => {
6 return SetMetadata(PARSE_REQUEST_BODY_WHEN_LOGGING_KEY, dtoClass);
7};We are using the Class type from the type-fest library to represent any class.
With SetMetadata, we can assign metadata to a particular method.
1import {
2 Body,
3 Controller,
4 Post,
5 UseInterceptors,
6 ClassSerializerInterceptor,
7} from '@nestjs/common';
8import { AuthenticationService } from './authentication.service';
9import RegisterDto from './dto/register.dto';
10import { ParseRequestBodyWhenLogging } from '../utils/parseRequestBodyWhenLogging';
11import RegistrationRequestLoggingDto from './dto/registrationRequestLogging.dto';
12
13@Controller('authentication')
14@UseInterceptors(ClassSerializerInterceptor)
15export class AuthenticationController {
16 constructor(private readonly authenticationService: AuthenticationService) {}
17
18 @Post('register')
19 @ParseRequestBodyWhenLogging(RegistrationRequestLoggingDto)
20 async register(@Body() registrationData: RegisterDto) {
21 return this.authenticationService.register(registrationData);
22 }
23
24 // ...
25}Thanks to our @ParseRequestBodyWhenLogging() decorator, we’ve assigned the RegistrationRequestLoggingDto class to the register method. Let’s now modify our logging interceptor to take it into account. First, we need to give it access to the reflector that allows us to retrieve the metadata we assigned to the register method.
1import { NestFactory, Reflector } from '@nestjs/core';
2import { AppModule } from './app.module';
3import { LogLevel } from '@nestjs/common';
4import { LoggerInterceptor } from './utils/logger.interceptor';
5
6async function bootstrap() {
7 const isProduction = process.env.NODE_ENV === 'production';
8 const logLevels: LogLevel[] = isProduction
9 ? ['error', 'warn', 'log']
10 : ['error', 'warn', 'log', 'verbose', 'debug'];
11
12 const app = await NestFactory.create(AppModule, {
13 logger: logLevels,
14 });
15
16 app.useGlobalInterceptors(new LoggerInterceptor(app.get(Reflector)));
17
18 // ...
19
20 await app.listen(3000);
21}
22bootstrap();We can now create the parseRequestBody method that uses the reflector to get the DTO class and create its instance.
1import {
2 Injectable,
3 NestInterceptor,
4 ExecutionContext,
5 CallHandler,
6 Logger,
7} from '@nestjs/common';
8import { Request, Response } from 'express';
9import { Reflector } from '@nestjs/core';
10import { PARSE_REQUEST_BODY_WHEN_LOGGING_KEY } from './parseRequestBodyWhenLogging';
11import { plainToInstance } from 'class-transformer';
12import { Constructor } from 'type-fest';
13
14@Injectable()
15export class LoggerInterceptor implements NestInterceptor {
16 constructor(private readonly reflector: Reflector) {}
17
18 private readonly logger = new Logger('HTTP');
19
20 private parseRequestBody(context: ExecutionContext) {
21 const httpContext = context.switchToHttp();
22 const request = httpContext.getRequest<Request>();
23 const body: unknown = request.body;
24 if (!body) {
25 return;
26 }_
27 if (body === null || typeof body !== 'object') {
28 return body;
29 }
30 const requestBodyDto = this.reflector.getAllAndOverride<
31 Constructor<unknown>
32 >(PARSE_REQUEST_BODY_WHEN_LOGGING_KEY, [
33 context.getHandler(),
34 context.getClass(),
35 ]);
36 if (!requestBodyDto) {
37 return JSON.stringify(body);
38 }
39 const instance = plainToInstance(requestBodyDto, body);
40 return JSON.stringify(instance);
41 }
42
43 intercept(context: ExecutionContext, next: CallHandler) {
44 const httpContext = context.switchToHttp();
45 const request = httpContext.getRequest<Request>();
46 const response = httpContext.getResponse<Response>();
47
48 response.on('finish', () => {
49 const { method, originalUrl } = request;
50 const { statusCode, statusMessage } = response;
51
52 const message = `${method} ${originalUrl} ${statusCode} ${statusMessage} Request body: ${this.parseRequestBody(
53 context,
54 )}`;
55
56 if (statusCode >= 500) {
57 return this.logger.error(message);
58 }
59
60 if (statusCode >= 400) {
61 return this.logger.warn(message);
62 }
63
64 return this.logger.log(message);
65 });
66
67 return next.handle();
68 }
69}If a particular route does not have the @ParseRequestBodyWhenLogging() decorator, our interceptor does not parse the body. If it does, we use the plainToInstance function to create an instance of our DTO class. Thanks to that, we can hide the actual value of the password field.
In the above log, we can see that the endpoint responded with 400 Bad Request. The request body shows that the user provided a number as a password instead of a string. Therefore, our API is expected to respond with the error.
Summary
In this article, we’ve explained how to implement logging using interceptors. We’ve also created a custom decorator that allows us to avoid storing sensitive data in our logs. Feel free to use this solution in more routes, such as the one used for logging in. We can also use a similar approach to parsing the response body by creating a similar decorator. All of the above gives us much more control over what we store in our logs.