Don't Leave Your NestJS API Exposed

Don't Leave Your NestJS API Exposed

Sunny Sun Lv4

Using Reflect and the NestJS Undocumented API to Identify Unsecured Endpoints in Unit Tests

It is a given that an API should be secured to prevent potential attacks. However, it is surprising how often simple human mistakes can lead to an API endpoint being unprotected. A recent example of this is the Optus data breach , which was caused by “*an API that did not require authentication to access customer data.*”. This highlights the importance of thorough testing and strict security measures for APIs to prevent such incidents from occurring.

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.

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.

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 [];
}

Secure an Endpoint

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.

Bonus Content: 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.

Summary

It’s important to regularly test and monitor the security of your application to ensure that it is secure and reliable. By embedding the security checking into Unit tests, and regularly running these tests, we can catch any issues and fix them before they cause problems in production.

Happy programming!

  • Title: Don't Leave Your NestJS API Exposed
  • Author: Sunny Sun
  • Created at : 2023-01-05 00:00:00
  • Updated at : 2024-07-07 14:13:35
  • Link: http://coffeethinkcode.com/2023/01/05/donnot-export-your-nestjs-endpoint/
  • License: This work is licensed under CC BY-NC-SA 4.0.