From b75244ffd2e668a5ccf2f790edb521092608f412 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kamil=20My=C5=9Bliwiec?= Date: Fri, 16 Oct 2026 11:48:37 +0200 Subject: [PATCH] fix(express): run wildcard middleware on "/api/" for a named wildcard Under a global prefix or a URI version, a wildcard middleware is registered on the exact "/api" next to "/api/". As Express routes are not strict, the exact entry also matches "/api/", and it left that path to the wildcard entry, assuming an optional "{*path}". A named wildcard such as forRoutes('*path') needs one more segment, so "GET /api/" was served by the "/api" handler without the middleware. A middleware now runs for the first of its paths that matches a request, and the others skip that request, so "/api/" runs it once through the exact entry whatever the wildcard. With forRoutes('*') or '{*path}', the middleware then sees req.url "/api/" for that request, as for "/api", instead of "/" with req.baseUrl "/api". Discovered by Anthropic's OSS Scanner, as vulnerability ANT-2026-08GP7DTU. --- .../hello-world/e2e/middleware.spec.ts | 50 +++++++++++++++++++ .../adapters/express-adapter.ts | 21 +++++--- .../test/adapters/express-adapter.spec.ts | 36 +++++++++---- 3 files changed, 89 insertions(+), 18 deletions(-) diff --git a/integration/hello-world/e2e/middleware.spec.ts b/integration/hello-world/e2e/middleware.spec.ts index 43d896ca45..cac709cd33 100644 --- a/integration/hello-world/e2e/middleware.spec.ts +++ b/integration/hello-world/e2e/middleware.spec.ts @@ -4,6 +4,7 @@ import { INestApplication, MiddlewareConsumer, Module, + Req, } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import request from 'supertest'; @@ -105,6 +106,55 @@ describe('Middleware', () => { }); }); +@Controller() +class RootController { + @Get() + root(@Req() req: { runs?: number }) { + return `${req.runs ?? 0}`; + } +} + +describe('Middleware - wildcard at the global prefix root', () => { + let app: INestApplication; + + const createAppWithWildcard = async (wildcard: string) => { + @Module({ + controllers: [RootController], + }) + class RootModule { + configure(consumer: MiddlewareConsumer) { + consumer + .apply((req, res, next) => { + req.runs = (req.runs ?? 0) + 1; + next(); + }) + .forRoutes(wildcard); + } + } + + app = ( + await Test.createTestingModule({ + imports: [RootModule], + }).compile() + ).createNestApplication(); + app.setGlobalPrefix('api'); + await listenOnLoopback(app); + }; + + afterEach(async () => { + await app.close(); + }); + + it.each(['*', '{*path}', '*path', '/*splat'])( + 'should run the middleware once for "/api" and "/api/" (%s)', + async wildcard => { + await createAppWithWildcard(wildcard); + await request(app.getHttpServer()).get('/api').expect(200, '1'); + await request(app.getHttpServer()).get('/api/').expect(200, '1'); + }, + ); +}); + async function createApp( beforeInit?: (app: INestApplication) => void, ): Promise { diff --git a/packages/platform-express/adapters/express-adapter.ts b/packages/platform-express/adapters/express-adapter.ts index c7f79c2a96..a0fa6da719 100644 --- a/packages/platform-express/adapters/express-adapter.ts +++ b/packages/platform-express/adapters/express-adapter.ts @@ -400,6 +400,7 @@ export class ExpressAdapter extends AbstractHttpAdapter< public createMiddlewareFactory( requestMethod: RequestMethod, ): (path: string, callback: Function) => any { + const handledRequests = new WeakMap>(); return (path: string, callback: Function) => { try { // The core marks an exact-match path with a trailing "$" (e.g. "/api$"). @@ -413,13 +414,19 @@ export class ExpressAdapter extends AbstractHttpAdapter< if (isExactPath && router === this.instance.use) { router = this.instance.all; } - // Express routes are not strict, so "/api" also matches "/api/". That - // path is left to the wildcard entry registered next to it (an optional - // "{*path}" matches it), so the middleware doesn't run twice. - const handler = isExactPath - ? (req: any, res: any, next: Function) => - req.path.endsWith('/') ? next() : callback(req, res, next) - : callback; + // Express routes are not strict, so "/api" also matches "/api/", which + // the wildcard path registered next to it may match too ("/api/{*path}" + // does, "/api/*path" does not). The middleware runs for the first of + // its paths that matches a request, and not again for the others. + const requests = handledRequests.get(callback) ?? new WeakSet(); + handledRequests.set(callback, requests); + const handler = (req: any, res: any, next: Function) => { + if (requests.has(req)) { + return next(); + } + requests.add(req); + return callback(req, res, next); + }; return router.call(this.instance, convertedPath, handler); } catch (e) { if (e instanceof TypeError) { diff --git a/packages/platform-express/test/adapters/express-adapter.spec.ts b/packages/platform-express/test/adapters/express-adapter.spec.ts index 0606a1ec1c..3cf89f19a7 100644 --- a/packages/platform-express/test/adapters/express-adapter.spec.ts +++ b/packages/platform-express/test/adapters/express-adapter.spec.ts @@ -198,24 +198,38 @@ describe('ExpressAdapter', () => { }, ); - it('should leave "/api/" of the exact-match path to the wildcard entry', () => { + it('should run the middleware once for a request matching more than one of its paths', () => { const allSpy = vi.spyOn(expressAdapter.getInstance(), 'all'); + const useSpy = vi.spyOn(expressAdapter.getInstance(), 'use'); const middleware = vi.fn(); const next = vi.fn(); - expressAdapter.createMiddlewareFactory(NO_REQUEST_METHOD)( - '/api$', - middleware, - ); - const [, handler] = allSpy.mock.calls[0] as unknown as [string, Function]; + const registerMiddleware = + expressAdapter.createMiddlewareFactory(NO_REQUEST_METHOD); + registerMiddleware('/api$', middleware); + registerMiddleware('/api/{*path}', middleware); + const [, exactHandler] = allSpy.mock.calls[0] as unknown as [ + string, + Function, + ]; + const [, wildcardHandler] = useSpy.mock.calls[0] as unknown as [ + string, + Function, + ]; - handler({ path: '/api/' }, {}, next); - expect(middleware).not.toHaveBeenCalled(); + // Both "/api" (not strict) and "/api/{*path}" match "/api/", while + // "/api/*path" would not + const req = { path: '/api/' }; + exactHandler(req, {}, next); + expect(middleware).toHaveBeenCalledExactlyOnceWith(req, {}, next); + + wildcardHandler(req, {}, next); + expect(middleware).toHaveBeenCalledOnce(); expect(next).toHaveBeenCalledOnce(); - const req = { path: '/api' }; - handler(req, {}, next); - expect(middleware).toHaveBeenCalledExactlyOnceWith(req, {}, next); + const anotherReq = { path: '/api/' }; + wildcardHandler(anotherReq, {}, next); + expect(middleware).toHaveBeenLastCalledWith(anotherReq, {}, next); }); });