fix(fastify): answer request targets that are not a path with 404

Node.js accepts request targets such as "*admin/users", which
find-my-way routes as "/admin/users" while neither middie nor the paths
Nest middleware is mounted on ever match them, so the route ran without
its middleware.

Requests whose target is neither origin-form nor an http(s)
absolute-form URL are now passed to the not-found handler from an
onRequest hook that runs before middie, as the ExpressAdapter answers
them. This includes "OPTIONS *", which find-my-way served with the
"OPTIONS /" route.

Discovered by Anthropic's OSS Scanner, as vulnerability ANT-2026-D0ACIOGH.
This commit is contained in:
Kamil Myśliwiec committed 2026-10-16 10:53:18 +02:00
1 parent c666b1abaa
commit a03c7a1db6
3 files changed
+155 -3

No files matched your search

@@ -1074,7 +1074,7 @@ describe('Middleware (FastifyAdapter)', () => {
});
});
describe('absolute-form request target', () => {
describe('request target forms', () => {
const MIDDLEWARE_RETURN_VALUE = 'middleware_return';
const HANDLER_RETURN_VALUE = 'handler_return';
@@ -1103,12 +1103,12 @@ describe('Middleware (FastifyAdapter)', () => {
* "supertest" and "light-my-request" always send origin-form request
* targets, so the request has to be written to the socket manually.
*/
const sendRawRequest = (requestTarget: string) =>
const sendRawRequest = (requestTarget: string, method = 'GET') =>
new Promise<{ statusCode: number; body: string }>((resolve, reject) => {
let response = '';
const socket = connect(port, '127.0.0.1', () => {
socket.write(
`GET ${requestTarget} HTTP/1.1\r\n` +
`${method} ${requestTarget} HTTP/1.1\r\n` +
`Host: 127.0.0.1:${port}\r\n` +
`Connection: close\r\n\r\n`,
);
@@ -1166,6 +1166,30 @@ describe('Middleware (FastifyAdapter)', () => {
expect(response.statusCode).toBe(200);
expect(response.body).toBe(MIDDLEWARE_RETURN_VALUE);
});
it('does not run the route without its middleware for a request target starting with "*"', async () => {
await createApp(new FastifyAdapter());
const response = await sendRawRequest('*users');
expect(response.statusCode).toBe(404);
expect(JSON.parse(response.body)).toEqual({
message: 'Cannot GET *users',
error: 'Not Found',
statusCode: 404,
});
});
it('answers "OPTIONS *" with the not-found handler', async () => {
await createApp(new FastifyAdapter());
const response = await sendRawRequest('*', 'OPTIONS');
expect(response.statusCode).toBe(404);
expect(JSON.parse(response.body)).toEqual({
message: 'Cannot OPTIONS *',
error: 'Not Found',
statusCode: 404,
});
});
});
/**
@@ -392,6 +392,20 @@ export class FastifyAdapter<
}
});
// Passes requests whose target middleware cannot be matched against to
// the not-found handler (see `isRoutableRequestTarget()`). Added here,
// like the hooks above, so that it runs before Nest middleware, and
// after the security hook, so that the `404` carries its headers, as on
// Express. A request the router did not match is already on its way to
// the not-found handler.
this.instance.addHook('onRequest', (request, reply, done) => {
if (request.is404 || this.isRoutableRequestTarget(request.raw.url)) {
done();
} else {
reply.callNotFound();
}
});
this.instance.addHook('onResponse', (request, reply, done) => {
if (this.onResponseHook) {
this.runLifecycleHook(
@@ -1421,6 +1435,27 @@ export class FastifyAdapter<
return pathStart === -1 ? '/' : url.slice(pathStart);
}
/**
* Whether the request target is in origin-form ("/users") or in the
* absolute-form of an "http(s)" URL ("http://host/users"), the only forms
* middleware paths can match.
*
* Node.js accepts other targets too (e.g. "GET *users HTTP/1.1").
* "find-my-way" routes those as if their first character were the leading
* "/" ("*users" is served by the "/users" route), while no middleware path
* matches them, so the route would run without its middleware. They are
* answered by the not-found handler instead, as with the ExpressAdapter,
* whose router never matches a path that does not start with "/".
*
* That includes the asterisk-form "OPTIONS *" (RFC 9112, section 3.2.4),
* which asks about the server as a whole rather than about a resource:
* Nest has no handler for it, and "find-my-way" would serve it with the
* "OPTIONS /" route.
*/
private isRoutableRequestTarget(url: string | undefined): boolean {
return !!url && this.getPathFromRequestTarget(url).charCodeAt(0) === 47;
}
private trackOpenConnections() {
const track = (socket: Duplex) => {
if (this.isClosing) {
@@ -15,6 +15,7 @@ import {
VersioningType,
} from '@nestjs/common';
import { FastifyReply, FastifyRequest, fastify } from 'fastify';
import { inject } from 'light-my-request';
describe('FastifyAdapter', () => {
let fastifyAdapter: FastifyAdapter;
@@ -480,6 +481,98 @@ describe('FastifyAdapter', () => {
});
});
describe('request targets', () => {
const handler = vi.fn(() => 'found');
const middleware = vi.fn((_req, _res, next: Function) => next());
/**
* "light-my-request" always sends an origin-form request target, so the
* target is set on the request before it reaches the router.
*/
const injectRequestTarget = async (method: string, target: string) => {
const instance = fastifyAdapter.getInstance();
await instance.ready();
return inject(
(req, res) => {
req.url = target;
instance.routing(req as any, res as any);
},
{ method: method as any, url: '/' },
);
};
beforeEach(async () => {
handler.mockClear();
middleware.mockClear();
fastifyAdapter.setNotFoundHandler((_req, reply) =>
reply.code(404).send('not found'),
);
const register = await fastifyAdapter.createMiddlewareFactory(
RequestMethod.ALL,
);
register('/users', middleware);
fastifyAdapter.get('/users', handler);
fastifyAdapter.options('/', handler);
});
afterEach(async () => {
await fastifyAdapter.close();
});
it.each(['/users', 'http://localhost/users', 'HTTPS://localhost/users'])(
'should run the middleware and the route for "%s"',
async target => {
const res = await injectRequestTarget('GET', target);
expect(res.statusCode).toBe(200);
expect(res.body).toBe('found');
expect(middleware).toHaveBeenCalledOnce();
},
);
it.each(['*users', 'users', '*', 'ftp://localhost/users'])(
'should pass a request for "%s" to the not-found handler',
async target => {
const res = await injectRequestTarget('GET', target);
expect(res.statusCode).toBe(404);
expect(res.body).toBe('not found');
expect(handler).not.toHaveBeenCalled();
expect(middleware).not.toHaveBeenCalled();
},
);
it('should pass "OPTIONS *" to the not-found handler rather than to the "OPTIONS /" route', async () => {
const res = await injectRequestTarget('OPTIONS', '*');
expect(res.statusCode).toBe(404);
expect(res.body).toBe('not found');
expect(handler).not.toHaveBeenCalled();
});
it('should pass a request the router did not match to the not-found handler', async () => {
const warn = vi.spyOn(fastifyAdapter.getInstance().log, 'warn');
const res = await injectRequestTarget('GET', '*unknown');
expect(res.statusCode).toBe(404);
expect(res.body).toBe('not found');
expect(warn).not.toHaveBeenCalled();
});
it('should set the headers of the security hook on the response', async () => {
fastifyAdapter.registerSecurityHook((_req, res) => {
res.setHeader('x-content-type-options', 'nosniff');
return undefined;
});
const res = await injectRequestTarget('GET', '*users');
expect(res.statusCode).toBe(404);
expect(res.headers['x-content-type-options']).toBe('nosniff');
});
});
describe('applyVersionFilter', () => {
const registerVersionNeutralRoute = (
type: VersioningType.MEDIA_TYPE | VersioningType.HEADER,