mirror of
https://github.com/nestjs/nest.git
synced 2026-10-28 17:56:01 +08:00
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:
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,
|
||||
|
||||
Reference in new issue
Block a user