fix(core): stop wildcard exclusions from covering their parent route

An exclusion ending in an optional wildcard ("x/*", "x/(.*)" or
"x/{*path}") matches "/x/" but not "/x". On a router that ignores a
trailing slash (Express by default, Fastify with ignoreTrailingSlash),
"/x/" is served by the "/x" route, so MiddlewareConsumer.exclude()
skipped the middleware, and enableCsrfProtection({ exclude }) the
cross-origin check, for that parent route: "GET /invoices/" returned
the listing that "GET /invoices" protects.

On such a router, a request path ending in "/" is now excluded only if
the path without the slash is excluded too. A "/x/" served by an
excluded optional-wildcard route itself, such as "GET /files/" for
@Get('files/{*path}'), now runs the middleware and the cross-origin
check, unless "x" is excluded as well or the exclusion is written
"x{/*path}". Routers that match a trailing slash exactly are unchanged.

Discovered by Anthropic's OSS Scanner, as vulnerability ANT-2026-P8IB7YGY.
This commit is contained in:
Kamil Myśliwiec committed 2026-10-16 15:14:08 +02:00
1 parent fffdaf9b22
commit 7b01d0e7a9
6 files changed
+191 -14

No files matched your search

@@ -193,6 +193,61 @@ describe('Exclude middleware - request targets', () => {
});
});
@Controller('invoices')
class InvoicesController {
@Get()
findAll() {
return RETURN_VALUE;
}
@Get(':token')
findShared() {
return RETURN_VALUE;
}
}
@Module({
controllers: [InvoicesController],
})
class TrailingSlashModule {
configure(consumer: MiddlewareConsumer) {
consumer
.apply((req, res, next) => res.send(MIDDLEWARE_VALUE))
.exclude('invoices/*')
.forRoutes(InvoicesController);
}
}
describe('Exclude middleware - trailing slash', () => {
let app: INestApplication;
beforeEach(async () => {
app = (
await Test.createTestingModule({
imports: [TrailingSlashModule],
}).compile()
).createNestApplication();
await listenOnLoopback(app);
});
afterEach(async () => {
await app.close();
});
it(`should exclude "/invoices/abc" endpoint`, () => {
return request(app.getHttpServer())
.get('/invoices/abc')
.expect(200, RETURN_VALUE);
});
it(`should not exclude "/invoices/", which the router serves with "/invoices"`, () => {
return request(app.getHttpServer())
.get('/invoices/')
.expect(200, MIDDLEWARE_VALUE);
});
});
describe('Exclude middleware - route matching', () => {
let app: INestApplication;
@@ -306,6 +306,32 @@ describe.each(adapters)('CSRF protection (%s)', (_name, createAdapter) => {
});
});
describe('with an exclusion ending in an optional wildcard', () => {
beforeEach(() =>
init(app => {
app.setGlobalPrefix('api');
app.enableCsrfProtection({ exclude: ['hooks/github/*'] });
}),
);
it('does not exclude the parent route through a trailing slash', async () => {
const post = (path: string) =>
request(app.getHttpServer())
.post(path)
.set('Host', HOST)
.set('Sec-Fetch-Site', 'cross-site')
.set('Origin', 'https://github.example');
await post('/api/hooks/github/push').expect(201, {
handler: 'github/:event',
});
await post('/api/hooks/github').expect(403);
// Express serves it with the "github" route, which is not excluded;
// Fastify does not serve it (strict routing, no empty "event")
await post('/api/hooks/github/').expect(_name === 'Express' ? 403 : 404);
});
});
describe('with a global exception filter', () => {
beforeEach(() =>
init(app => {
+26 -9
View File
@@ -95,14 +95,20 @@ export const filterMiddleware = <T extends Function | Type<any> = any>(
// the adapter does not tell how its router matches paths, they match
// exactly, so that a more lenient router runs the middleware for the
// variants of an excluded path rather than skipping it
const excludedRoutes = mapToExcludeRoute(
routes,
httpAdapter.getRouteMatchingOptions?.() ?? EXACT_ROUTE_MATCHING,
);
const matchingOptions =
httpAdapter.getRouteMatchingOptions?.() ?? EXACT_ROUTE_MATCHING;
const excludedRoutes = mapToExcludeRoute(routes, matchingOptions);
return iterate([])
.concat(middleware)
.filter(isFunction)
.map((item: T) => mapToClass(item, excludedRoutes, httpAdapter))
.map((item: T) =>
mapToClass(
item,
excludedRoutes,
httpAdapter,
matchingOptions.ignoreTrailingSlash,
),
)
.toArray();
};
@@ -110,6 +116,7 @@ export const mapToClass = <T extends Function | Type<any>>(
middleware: T,
excludedRoutes: ExcludeRouteMetadata[],
httpAdapter: HttpServer,
ignoreTrailingSlash = false,
) => {
if (isMiddlewareClass(middleware)) {
if (excludedRoutes.length <= 0) {
@@ -122,6 +129,7 @@ export const mapToClass = <T extends Function | Type<any>>(
req,
excludedRoutes,
httpAdapter,
ignoreTrailingSlash,
);
if (isExcluded) {
return next();
@@ -139,6 +147,7 @@ export const mapToClass = <T extends Function | Type<any>>(
req,
excludedRoutes,
httpAdapter,
ignoreTrailingSlash,
);
if (isExcluded) {
return next();
@@ -171,6 +180,7 @@ export function isMiddlewareRouteExcluded(
req: Record<string, any>,
excludedRoutes: ExcludeRouteMetadata[],
httpAdapter: HttpServer,
ignoreTrailingSlash = false,
): boolean {
if (excludedRoutes.length <= 0) {
return false;
@@ -192,9 +202,16 @@ export function isMiddlewareRouteExcluded(
: originalUrl;
// HEAD requests are served by GET handlers, so a GET exclusion covers them too
return (
isRouteExcluded(excludedRoutes, pathname, RequestMethod[reqMethod]) ||
const isExcluded = (path: string) =>
isRouteExcluded(excludedRoutes, path, RequestMethod[reqMethod]) ||
(reqMethod === RequestMethod[RequestMethod.HEAD] &&
isRouteExcluded(excludedRoutes, pathname, RequestMethod.GET))
);
isRouteExcluded(excludedRoutes, path, RequestMethod.GET));
// A router that ignores a trailing slash serves "/x/" with the "/x" route,
// which an exclusion that matches "/x/" may not cover (e.g. "x/{*path}"),
// so the request is excluded only if its path without the slash is too
if (ignoreTrailingSlash && pathname.length > 1 && pathname.endsWith('/')) {
return isExcluded(pathname) && isExcluded(pathname.replace(/\/+$/, ''));
}
return isExcluded(pathname);
}
@@ -221,6 +221,7 @@ export class CrossOriginProtection<TRequest = any> {
private readonly excludedRoutes: RouteInfo[] = [];
private readonly excludePredicate?: (request: TRequest) => boolean;
private resolvedExclusions: ExcludeRouteMetadata[] = [];
private ignoreTrailingSlash = false;
/**
* Validates the options, so that a bad configuration fails as soon as
@@ -280,6 +281,8 @@ export class CrossOriginProtection<TRequest = any> {
.extractPathFrom(route)
.map(path => compileExclusion(path, route.method, true)),
);
this.ignoreTrailingSlash =
!!this.httpAdapter.getRouteMatchingOptions?.()?.ignoreTrailingSlash;
}
/**
@@ -322,11 +325,25 @@ export class CrossOriginProtection<TRequest = any> {
return false;
}
const method = this.httpAdapter.getRequestMethod!(request);
return isRouteExcluded(
this.resolvedExclusions,
pathname,
RequestMethod[method as keyof typeof RequestMethod],
);
const isPathExcluded = (path: string) =>
isRouteExcluded(
this.resolvedExclusions,
path,
RequestMethod[method as keyof typeof RequestMethod],
);
// A router that ignores a trailing slash serves "/x/" with the "/x" route,
// which an exclusion that matches "/x/" may not cover (e.g. "x/{*path}"),
// so the request is excluded only if its path without the slash is too
if (
this.ignoreTrailingSlash &&
pathname.length > 1 &&
pathname.endsWith('/')
) {
return (
isPathExcluded(pathname) && isPathExcluded(pathname.replace(/\/+$/, ''))
);
}
return isPathExcluded(pathname);
}
}
@@ -266,6 +266,47 @@ describe('middleware utils', () => {
);
});
});
describe('when the router ignores a trailing slash', () => {
const isExcluded = (exclusions: string[], url: string) => {
vi.spyOn(adapter, 'getRequestUrl').mockReturnValue(url);
return isMiddlewareRouteExcluded(
{},
mapToExcludeRoute(exclusions, {
caseSensitive: false,
ignoreTrailingSlash: true,
}),
adapter,
true,
);
};
it('should not exclude "/x/", served by the "/x" route, when only its wildcard is excluded', () => {
expect(isExcluded(['invoices/*'], '/invoices/')).toBe(false);
expect(isExcluded(['invoices/{*path}'], '/invoices//')).toBe(false);
expect(isExcluded(['invoices/*'], '/invoices/abc')).toBe(true);
expect(isExcluded(['invoices/*'], '/invoices/abc/')).toBe(true);
});
it('should exclude "/x/" when "/x" is excluded too', () => {
expect(isExcluded(['invoices', 'invoices/*'], '/invoices/')).toBe(true);
expect(isExcluded(['invoices{/*path}'], '/invoices/')).toBe(true);
});
});
describe('when the router matches a trailing slash exactly', () => {
it('should exclude "/x/" when it matches an excluded route', () => {
vi.spyOn(adapter, 'getRequestUrl').mockReturnValue('/invoices/');
expect(
isMiddlewareRouteExcluded(
{},
mapToExcludeRoute(['invoices/*'], {
caseSensitive: true,
ignoreTrailingSlash: false,
}),
adapter,
),
).toBe(true);
});
});
describe('when a HEAD request targets a route excluded for GET', () => {
const excludedRoutes = mapToExcludeRoute([
{
@@ -423,6 +423,27 @@ describe('CrossOriginProtection', () => {
expect(isExcluded(protection, '/items')).toBe(false);
});
it('does not exclude "/x/" for a wildcard of "x" when the router serves it with "/x"', () => {
class LenientRouterAdapter extends TestAdapter {
getRouteMatchingOptions() {
return { caseSensitive: false, ignoreTrailingSlash: true };
}
}
const exclude = ['webhooks/*', 'hooks', 'hooks/{*path}'];
const lenient = new CrossOriginProtection(new LenientRouterAdapter({}), {
exclude,
});
lenient.resolveExclusions(
new RouteInfoPathExtractor(new ApplicationConfig()),
);
expect(isExcluded(lenient, '/webhooks/')).toBe(false);
expect(isExcluded(lenient, '/webhooks/stripe/')).toBe(true);
expect(isExcluded(lenient, '/hooks/')).toBe(true);
const strict = createProtection(exclude);
expect(isExcluded(strict, '/webhooks/')).toBe(true);
});
it('excludes nothing until the exclusions are resolved (app.init())', () => {
const protection = new CrossOriginProtection(adapter, {
exclude: ['webhooks'],