InteractiveFrameworks

Hardening

Get the service ready for strangers: responses serialized so an entity's secrets cannot leak, a rate limit on everything and a tight one on sign-in, helmet's security headers, and CORS for the shelter's own front end and nobody else's.

What you'll learn

  • Return entities safely: @Exclude() on a secret column and the ClassSerializerInterceptor for the whole app
  • Limit every route with the ThrottlerGuard, and tighten one route with @Throttle()
  • Add helmet's security headers and read what each one does
  • Allow one front end's origin with CORS, and check both a preflight it passes and one from an origin it refuses

The service works for clients that behave. The public internet is not that: a script tries ten thousand passwords for sam, a page on another site calls the API with a visitor's token, a refactor returns a whole user entity, hash and all. None of this needs a new route. It needs a few defences in front of all of them, each one line of configuration and each one easy to forget. This lesson adds four, and a new GET /auth/me that reads the user from the database instead of trusting the token, which is what makes the first one necessary.

Responses that cannot leak

Lesson 3 kept the hash out by building a new object for every response. That holds until someone returns the entity itself, and /auth/me now does: it reads the user as stored, so a role changed by staff shows at once. The defence belongs on the class. @Exclude() from class-transformer marks a property that must never be serialized, and ClassSerializerInterceptor, registered with APP_INTERCEPTOR, applies the class's rules to everything the app returns.

The interceptor works on instances. An entity loaded by TypeORM is one; { ...user } is a plain object with the same keys and none of the class's rules, so spreading an entity into a response quietly brings the hash back.

Limits

@nestjs/throttler counts requests per client and route. ThrottlerModule.forRoot([{ ttl: 60_000, limit: 100 }]) sets a ceiling, and the ThrottlerGuard as an APP_GUARD applies it to every route. One route deserves less: sign-in, where every request is a password guess. @Throttle() overrides the limit there:

@Throttle({ default: { limit: 5, ttl: 60_000 } })
@Post('reset-password')

Past the limit the answer is 429 Too Many Requests, and it does not matter whether the password was right: the attacker learns nothing from the fourth try, and the owner waits a minute.

Headers

helmet() adds the security headers a browser acts on: X-Content-Type-Options: nosniff stops it guessing a response's type, X-Frame-Options stops another site from framing yours, and a content security policy limits what a page may load. An API serves few pages, but its documentation is one (next lesson), and headers cost nothing.

Who may call from a browser

A page on another origin calling this API with fetch() is refused by the browser unless the API says otherwise, through CORS headers. Before a cross-origin request with a token, the browser sends a preflight, OPTIONS with Origin and the method and headers it wants to use, and sends the real request only if the answer allows them:

app.enableCors({ origin: 'https://adopt.example', methods: ['GET', 'POST'], allowedHeaders: ['Content-Type'] });

Name the origins. origin: true would allow every site on the internet to call the API from its visitors' browsers.

Your task

  1. Responses are serialized with class-transformer for the whole app, and a user's hash is never in one. POST /auth/register may now answer the stored user.
  2. GET /auth/me answers the user as stored in the database, found by the token's sub.
  3. Every route is limited to 100 requests a minute; sign-in to 3, after which it answers 429.
  4. helmet's headers on every response.
  5. CORS for https://whiskers.example only: GET, POST, PATCH and DELETE, with the Content-Type and Authorization headers.

Everything from lessons 1 to 3 still holds.

When it fails

  • passwordHash in a response: the interceptor is not registered, the property has no @Exclude(), or the entity was spread into a plain object on its way out.
  • The fourth sign-in is a 200: @Throttle() is missing from login, or the ThrottlerGuard is not an APP_GUARD, so no limit is enforced anywhere.
  • header access-control-allow-origin is missing on the preflight: the origin in enableCors differs from the request's by a character; https://whiskers.example/, with a slash, is another string.
  • header x-content-type-options is missing: helmet() is not applied in main.ts.

Remember

  • Put @Exclude() on secrets and let ClassSerializerInterceptor apply it; return instances, not spreads.
  • A global ceiling with the ThrottlerGuard, and @Throttle() where guessing is the risk.
  • helmet() for the headers, enableCors() with named origins for browsers.
Stuck? Show a hint

main.ts: app.use(helmet()) and app.enableCors({ origin: 'https://whiskers.example', methods: [...], allowedHeaders: ['Content-Type', 'Authorization'] }). app.module.ts: ThrottlerModule.forRoot([{ ttl: 60_000, limit: 100 }]), APP_GUARD ThrottlerGuard, APP_INTERCEPTOR ClassSerializerInterceptor. User: @Exclude() on passwordHash. Login: @Throttle({ default: { limit: 3, ttl: 60_000 } }). me: read the user by the token's sub with UsersService.findById().