mirror of
https://github.com/nestjs/nest.git
synced 2026-10-28 09:45:43 +08:00
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:
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 => {
|
||||
|
||||
@@ -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'],
|
||||
|
||||
Reference in new issue
Block a user