Shield Your NestJS API from Attacks

Shield Your NestJS API from Attacks

Sunny Sun Lv4

Ever wonder how a simple oversight can expose sensitive data? Look no further than the recent Optus data breach. It all stemmed from an API endpoint lacking basic authentication, allowing unauthorized access to customer information. This incident serves as a stark reminder: even robust APIs need thorough security measures to prevent such vulnerabilities. By taking proactive steps, we can ensure our APIs remain secure and user data stays protected.

Unprotected API is one of the top OWASP vulnerabilities. It is defined as below:

Modern applications often involve rich client applications and APIs, such as JavaScript in the browser and mobile apps, that connect to an API of some kind. These APIs are often unprotected and contain numerous vulnerabilities.

To avoid unprotected API, the first line of defense is via unit test. In this article, we are going to discuss how to ensure NestJS controllers and Endpoints are protected using the Unit test.

Authentication/Authorization Guards

In NestJS, we use authentication/authorization guards to protect controllers or endpoints. Using Guards, we can ensure only authorized users have access to the API. Below is an example of an authentication Guard.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Injectable()
export class AuthGuard implements CanActivate {
canActivate(
context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> {
const request = context.switchToHttp().getRequest();
return this.validateRequest(request);
}

private validateRequest(request: any) {
// Authenticate user request to ensure it is authenticated
// i.e. validate a token
}
}

The AuthGuard class contains canActivate method which takes an ExecutionContext object as an argument. The method performs validation to determine whether the request should be allowed.

To apply the AuthGuard to a controller, we can use the @UseGuards decorator

1
2
3
@UseGuards(AuthGuard)
@Controller()
export class AppController {}

If the @UseGuards decorator is accidentally removed, the API will become unprotected and vulnerable to unauthorized access. To ensure that the Guard is properly applied to the controller, we can use unit tests to verify its presence.

To achieve that, we need to use the reflect-metadata library and NestJS undocumented DiscoveryService API. Let’s look at the reflect-metadata library first.

Reflect-MetaData

The reflect-metadata library provides support for the Reflect API, which is part of the ECMAScript specification. We can use it to get metadata at runtime. It is worth noting that NestJS also uses the reflect-metadata under the hood to work with metadata.

The below example demonstrates how to retrieve the Guards metadata from the AppController.

1
const guards = Reflect.getMetadata('__guards__', AppController);

The getMetaData method takes two arguments:

  • metadataKey: A key used to store and retrieve metadata. In this case, the key is guards, it is used by NestJs to reference the Guards.

  • target: The target object on which the metadata is defined.

with getMetadata method, we can write a simple Unit test to verify the AppController is protected.

1
2
3
4
5
it('should AuthGard be applied to the AppController', async () => {
const guards = Reflect.getMetadata('__guards__', AppController);
const guard = new guards[0]();
expect(guard).toBeInstanceOf(AuthGuard);
});

If there are multiple controllers in the app, it is possible to write a unit test that covers all of them at once? The answer is positive, but we need to use the NestJS Discovery Service.

Make use of the Discovery Service

The NestJS discovery service is an undocumented public API. It is important to note that an “undocumented” feature may be subject to change or breakage in the future. While it is a handy feature to use, it is generally best to avoid relying on undocumented features in your app. In my personal opinion, using it in unit test is acceptable as long as you are aware of the risk.

As shown below, using this.discoveryService.getControllers(), we can get a collection of type InstanceWrapper = { metatype, name, instance, … }.

1
const controllers = await discoveryService.getControllers({});

To extract the guards metadata from the InstanceWrapper, we can use the getEnhancersMetadata method. In the test below, we loop through each controller and verify that they are protected by the AuthGuard.

1
2
3
4
5
6
7
8
9
10
11
it('should have AuthGard applied for all controllers', async () => {
const controllers = await discoveryService.getControllers({});
controllers.map((c) => {
const guard = c
.getEnhancersMetadata()
?.filter(
({ instance }: InstanceWrapper) => instance instanceof AuthGuard,
);
expect(guard[0].name).toEqual('AuthGuard');
});
});

To achieve a fine level of access control, we can define a RoleGuard and apply it to individual endpoints. We use SetMetadata in the function below to assign metadata with a specific key. SetMetadata is an out-of-box NestJS decorator function.

1
2
3
import { SetMetadata } from '@nestjs/common';

export const Roles = (...roles: string[]) => SetMetadata('roles', roles);

To apply a Roles decorator to an endpoint, we need to pass in a role to the decorator

1
2
3
4
5
@Get()
@Roles('Admin')
getAll() {
return [];
}

Detecting whether an Endpoint is secure

To detect whether an endpoint is associated with the Roles decorator, we use the getMetadata as below.

1
2
3
4
const decorators = Reflect.getMetadata(
'roles',
DevController.prototype.getAll,
);

In a unit test, we can verify whether an endpoint is protected by the roles decorator with a role

1
2
3
4
5
6
7
it('should getAll be accessible by Admin role only', () => {
const decorators = Reflect.getMetadata(
'roles',
DevController.prototype.getAll,
);
expect(decorators).toContain('Admin');
});

The Reflect API can be used to get other metadata of the endPoint.

1
2
3
4
5
6
it('should getHello has correct path and http method', () => {
const path = Reflect.getMetadata('path', appController.getHello);
expect(path).toBe('/');
const method = Reflect.getMetadata('method', appController.getHello);
expect(method).toBe(RequestMethod.GET);
});

In this above usage of getMetaData, the keys being used are ‘path’ and ‘method’, which corresponds to the path and HTTP method of the getHello method, respectively.

Use Discovery Service to dynamically get a list of Services

Discovery service can be a graceful solution to certain problems. For example, in one of my recent NestJS projects, there is a MapperResolver class:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Injectable()
export class MapperResolver {
private mapperList: IMapper[];

constructor(
private serviceAMapper: ServiceAMapper,
private serviceBMapper: ServiceBMapper,
private serviceCMapper: ServiceCMapper,
private serviceDMapper: ServiceDMapper,
) {
this.mapperList= [
serviceAMapper,
serviceBMapper,
serviceCMapper,
serviceDMapper
];
}
public Resolve(serviceType: string):IMapper {
const mapper = this.mapperList.find(c => c.serviceType=== serviceType);
if (mapper) {
return mapper;
}
throw new Error(`No Mapper found`);
}

In the real-world project, there are more than 10 Mapper classes injected into the MapperResolver class constructor, this number continues to grow as new features are added. This has become a maintenance issue.

We can use Discovery Service to solve this problem. As this topic is outside the scope of this article, I will only give a brief description of the solution.

  • create a decorator @ServiceRegister that takes an argument

  • add the decorator to each Mapper class i.e. @ServiceRegister(‘Mapper’)

  • use discoveryService.getProviders() to retrieve all providers and filter out the mapper services using the metadata.

The end result is that we are able to remove all the injected Mapper services in the MapperResolver class. But again, please be careful in using DiscoveryService as it is an undocumented API.

Addtional consideration

Building a secure NestJS application requires a multi-faceted approach. Here are some essential security measures to consider:

  • Keep dependencies up-to-date: Regularly update your project’s dependencies to address security vulnerabilities.
  • Security testing: Conduct regular penetration testing and vulnerability assessments to identify and fix weaknesses.
  • Security awareness: Educate your team about security best practices and encourage them to report potential vulnerabilities.
  • Monitoring and logging: Implement robust logging and monitoring to detect and respond to security incidents promptly.

Conclusion

Building a secure NestJS application requires a proactive and ongoing commitment to security. By understanding and addressing the OWASP Top 10 vulnerabilities, implementing robust authentication and authorization mechanisms, and conducting regular security assessments, you can significantly enhance the protection of your application and user data.

Remember, security is a journey, not a destination. Continuous monitoring, testing, and updates are essential to stay ahead of emerging threats.

  • Title: Shield Your NestJS API from Attacks
  • Author: Sunny Sun
  • Created at : 2023-01-05 00:00:00
  • Updated at : 2024-07-29 21:06:33
  • Link: http://coffeethinkcode.com/2023/01/05/shield-your-nestjs-api-from-attack/
  • License: This work is licensed under CC BY-NC-SA 4.0.