HTTP Verb Tampering in NestJS: Bypassing Middleware with HEAD Requests
How Express treating HEAD as GET lets NestJS middleware scoped to GET miss auth — and mutate state anyway.
HTTP Verb Tampering is an old attack that still shows up in modern stacks. Authentication and access control often lock down the common verbs (GET, POST, PUT) and leave the rest open.
“HTTP Verb Tampering is an attack that exploits vulnerabilities in the authentication and access control mechanisms of HTTP verbs (also known as HTTP methods). Many authentication mechanisms only restrict access to the most common HTTP methods, allowing unauthorized access to restricted resources using other HTTP methods.” — Imperva
GET vs HEAD
GET asks for a representation of a resource. It should only retrieve data and should not carry a request body.
HEAD asks for the same response as GET, but without a body. (MDN)
Many frameworks treat HEAD like GET. Express documents this explicitly, and the router does it in code:
Route.prototype._handlesMethod = function _handlesMethod (method) {
if (this.methods._all) {
return true
}
// normalize name
let name = typeof method === 'string'
? method.toLowerCase()
: method
if (name === 'head' && !this.methods.head) {
name = 'get'
}
// https://github.com/pillarjs/router/blob/master/lib/route.js#L64
That mapping is the hinge for NestJS.
The problem in NestJS
NestJS sits on Express by default. It adds Middleware, Guards, and the rest, but routing still follows Express. The middleware docs push a pattern like “apply this middleware to RequestMethod.GET on path X.”
Two mistakes stack:
- Teams put auth/authorization in Nest middleware instead of Guards (what Nest recommends for that job).
- Middleware is registered only for
RequestMethod.GET, so HEAD never hits it — while Express still runs the GET handler for HEAD.
A minimal reproduction:
import 'reflect-metadata';
import {
Controller,
Get,
Injectable,
MiddlewareConsumer,
Module,
NestMiddleware,
NestModule,
RequestMethod,
UnauthorizedException,
} from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { NextFunction, Request, Response } from 'express';
@Injectable()
class ApiKeyMiddleware implements NestMiddleware {
use(req: Request, _res: Response, next: NextFunction) {
const apiKey = req.header('x-api-key');
const validApiKey = process.env.API_KEY || 'mi-api-key-secreta';
if (!apiKey || apiKey !== validApiKey) {
throw new UnauthorizedException('API key invalida');
}
next();
}
}
@Injectable()
class CatsService {
private counter = 0;
incrementAndGetCount() {
this.counter += 1;
console.log("Actual counter: " + this.counter)
return this.counter;
}
}
@Controller('cats')
class CatsController {
constructor(private readonly catsService: CatsService) {}
@Get()
getCats() {
const count = this.catsService.incrementAndGetCount();
return {
message: 'Counter: ' + count,
count,
};
}
}
@Module({
controllers: [CatsController],
providers: [CatsService],
})
class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer
.apply(ApiKeyMiddleware)
.forRoutes({ path: 'cats', method: RequestMethod.GET });
}
}
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(3000);
console.log('Server running on http://localhost:3000');
}
bootstrap();
The dangerous line is the route filter:
.forRoutes({ path: 'cats', method: RequestMethod.GET });
Observed behavior:
- GET with a valid API key — middleware allows it; the counter increments (side effect on a GET).
- GET without an API key — middleware blocks it.
- HEAD without an API key — middleware never runs; Express maps HEAD → GET handler; response is 200 with empty body; the counter still increments.
No body comes back, but the handler ran. If your “GET” mutates state, HEAD becomes an unauthenticated write.
Takeaway
Verb tampering is old; Nest + Express still make it easy. Put auth in Guards (or at least register HEAD alongside GET in middleware), and keep GETs free of side effects. Add HEAD (and other verbs) to your AppSec checklist.
Related reading
Originally published on Medium ↗