Merge pull request #16384 from nestjs/fix/fastify-middleware-vulnerability

fix(fastify): fastify middleware bypass cve
This commit is contained in:
Kamil Mysliwiec
2026-02-17 17:23:24 +01:00
committed by GitHub
3 changed files with 272 additions and 7 deletions

View File

@@ -612,4 +612,158 @@ describe('Middleware (FastifyAdapter)', () => {
await app.close();
});
});
describe('should respect fastify routing options', () => {
const MIDDLEWARE_RETURN_VALUE = 'middleware_return';
@Controller()
class TestController {
@Get('abc/def')
included() {
return 'whatnot';
}
}
@Module({
imports: [AppModule],
controllers: [TestController],
})
class TestModule {
configure(consumer: MiddlewareConsumer) {
consumer
.apply((req, res, next) => res.end(MIDDLEWARE_RETURN_VALUE))
.forRoutes({ path: 'abc/def', method: RequestMethod.GET });
}
}
describe('[ignoreTrailingSlash] attribute', () => {
beforeEach(async () => {
app = (
await Test.createTestingModule({
imports: [TestModule],
}).compile()
).createNestApplication<NestFastifyApplication>(
new FastifyAdapter({
ignoreTrailingSlash: true,
// routerOptions: {
// ignoreTrailingSlash: true,
// },
}),
);
await app.init();
});
it(`GET forRoutes(GET /abc/def/)`, () => {
return app
.inject({
method: 'GET',
url: '/abc/def/', // trailing slash
})
.then(({ payload }) =>
expect(payload).to.be.eql(MIDDLEWARE_RETURN_VALUE),
);
});
afterEach(async () => {
await app.close();
});
});
describe('[ignoreDuplicateSlashes] attribute', () => {
beforeEach(async () => {
app = (
await Test.createTestingModule({
imports: [TestModule],
}).compile()
).createNestApplication<NestFastifyApplication>(
new FastifyAdapter({
routerOptions: {
ignoreDuplicateSlashes: true,
},
}),
);
await app.init();
});
it(`GET forRoutes(GET /abc//def)`, () => {
return app
.inject({
method: 'GET',
url: '/abc//def', // duplicate slashes
})
.then(({ payload }) =>
expect(payload).to.be.eql(MIDDLEWARE_RETURN_VALUE),
);
});
afterEach(async () => {
await app.close();
});
});
describe('[caseSensitive] attribute', () => {
beforeEach(async () => {
app = (
await Test.createTestingModule({
imports: [TestModule],
}).compile()
).createNestApplication<NestFastifyApplication>(
new FastifyAdapter({
routerOptions: {
caseSensitive: true,
},
}),
);
await app.init();
});
it(`GET forRoutes(GET /ABC/DEF)`, () => {
return app
.inject({
method: 'GET',
url: '/ABC/DEF', // different case
})
.then(({ payload }) =>
expect(payload).to.be.eql(MIDDLEWARE_RETURN_VALUE),
);
});
afterEach(async () => {
await app.close();
});
});
describe('[useSemicolonDelimiter] attribute', () => {
beforeEach(async () => {
app = (
await Test.createTestingModule({
imports: [TestModule],
}).compile()
).createNestApplication<NestFastifyApplication>(
new FastifyAdapter({
routerOptions: { useSemicolonDelimiter: true } as any,
}),
);
await app.init();
});
it(`GET forRoutes(GET /abc/def;foo=bar)`, () => {
return app
.inject({
method: 'GET',
url: '/abc/def;foo=bar', // semicolon delimiter
})
.then(({ payload }) =>
expect(payload).to.be.eql(MIDDLEWARE_RETURN_VALUE),
);
});
afterEach(async () => {
await app.close();
});
});
});
});

View File

@@ -708,7 +708,8 @@ export class FastifyAdapter<
queryParamsIndex >= 0
? req.originalUrl.slice(0, queryParamsIndex)
: req.originalUrl;
pathname = safeDecodeURI(pathname).path;
pathname = this.sanitizeUrl(pathname);
if (!re.exec(pathname + '/') && normalizedPath) {
return next();
@@ -867,4 +868,50 @@ export class FastifyAdapter<
}
return this.instance.route(routeToInject);
}
private sanitizeUrl(url: string): string {
const initialConfig = this.instance.initialConfig as FastifyServerOptions;
const routerOptions =
initialConfig.routerOptions as Partial<FastifyServerOptions>;
if (
routerOptions.ignoreDuplicateSlashes ||
initialConfig.ignoreDuplicateSlashes
) {
url = this.removeDuplicateSlashes(url);
}
if (
routerOptions.ignoreTrailingSlash ||
initialConfig.ignoreTrailingSlash
) {
url = this.trimLastSlash(url);
}
if (
routerOptions.caseSensitive === false ||
initialConfig.caseSensitive === false
) {
url = url.toLowerCase();
}
return safeDecodeURI(
url,
routerOptions.useSemicolonDelimiter ||
initialConfig.useSemicolonDelimiter,
).path;
}
private removeDuplicateSlashes(path: string) {
const REMOVE_DUPLICATE_SLASHES_REGEXP = /\/\/+/g;
return path.indexOf('//') !== -1
? path.replace(REMOVE_DUPLICATE_SLASHES_REGEXP, '/')
: path;
}
private trimLastSlash(path: string) {
if (path.length > 1 && path.charCodeAt(path.length - 1) === 47) {
return path.slice(0, -1);
}
return path;
}
}

View File

@@ -5,6 +5,7 @@ import {
FastifyPluginCallback,
FastifyReply,
FastifyRequest,
FastifyServerOptions,
HookHandlerDoneFunction,
} from 'fastify';
import fp from 'fastify-plugin';
@@ -28,6 +29,17 @@ interface MiddlewareEntry<
fn: MiddlewareFn<Req, Res, Ctx>;
}
function bindLast<F extends (...args: any[]) => any>(
fn: F,
last: Last<Parameters<F>>,
): (...args: DropLast<Parameters<F>>) => ReturnType<F> {
return (...args: any[]) => fn(...args, last);
}
// Helper types
type Last<T extends any[]> = T extends [...any[], infer L] ? L : never;
type DropLast<T extends any[]> = T extends [...infer Rest, any] ? Rest : never;
/**
* A clone of `@fastify/middie` engine https://github.com/fastify/middie
* with an extra vulnerability fix. Path is now decoded before matching to
@@ -37,13 +49,16 @@ function middie<
Req extends { url: string; originalUrl?: string },
Res extends { finished?: boolean; writableEnded?: boolean },
Ctx = unknown,
>(complete: (err: unknown, req: Req, res: Res, ctx: Ctx) => void) {
>(
complete: (err: unknown, req: Req, res: Res, ctx: Ctx) => void,
initialConfig: FastifyServerOptions | null,
) {
const middlewares: MiddlewareEntry<Req, Res, Ctx>[] = [];
const pool = reusify(Holder as any);
return {
use,
run,
run: bindLast(run, initialConfig),
};
function use(
@@ -79,7 +94,12 @@ function middie<
return this;
}
function run(req: Req, res: Res, ctx: Ctx) {
function run(
req: Req,
res: Res,
ctx: Ctx,
initialConfig: FastifyServerOptions | null,
) {
if (!middlewares.length) {
complete(null, req, res, ctx);
return;
@@ -92,6 +112,7 @@ function middie<
holder.res = res;
holder.url = sanitizeUrl(req.url);
holder.context = ctx;
holder.initialConfig = initialConfig;
holder.done();
}
@@ -100,6 +121,7 @@ function middie<
res: Res | null;
url: string | null;
context: Ctx | null;
initialConfig: FastifyServerOptions | null;
i: number;
done: (err?: unknown) => void;
}
@@ -109,6 +131,7 @@ function middie<
this.res = null;
this.url = null;
this.context = null;
this.initialConfig = null;
this.i = 0;
const that = this;
@@ -135,7 +158,33 @@ function middie<
if (regexp) {
// Decode URL before matching to avoid bypassing middleware
const decodedUrl = safeDecodeURI(url).path;
let sanitizedUrl = url;
if (
that.initialConfig!.ignoreDuplicateSlashes ||
that.initialConfig!.routerOptions?.ignoreDuplicateSlashes
) {
sanitizedUrl = removeDuplicateSlashes(sanitizedUrl);
}
if (
that.initialConfig!.ignoreTrailingSlash ||
that.initialConfig!.routerOptions?.ignoreTrailingSlash
) {
sanitizedUrl = trimLastSlash(sanitizedUrl);
}
if (
that.initialConfig!.caseSensitive === false ||
that.initialConfig!.routerOptions?.caseSensitive === false
) {
sanitizedUrl = sanitizedUrl.toLowerCase();
}
const decodedUrl = safeDecodeURI(
sanitizedUrl,
(that.initialConfig?.routerOptions as any)?.useSemicolonDelimiter ||
that.initialConfig?.useSemicolonDelimiter,
).path;
const result = regexp.exec(decodedUrl);
if (result) {
req.url = req.url.replace(result[0], '');
@@ -154,12 +203,27 @@ function middie<
that.req = null;
that.res = null;
that.context = null;
that.initialConfig = null;
that.i = 0;
pool.release(that as any);
}
}
}
function removeDuplicateSlashes(path: string) {
const REMOVE_DUPLICATE_SLASHES_REGEXP = /\/\/+/g;
return path.indexOf('//') !== -1
? path.replace(REMOVE_DUPLICATE_SLASHES_REGEXP, '/')
: path;
}
function trimLastSlash(path: string) {
if (path.length > 1 && path.charCodeAt(path.length - 1) === 47) {
return path.slice(0, -1);
}
return path;
}
function sanitizeUrl(url: string): string {
for (let i = 0, len = url.length; i < len; i++) {
const charCode = url.charCodeAt(i);
@@ -214,7 +278,7 @@ function fastifyMiddie(
fastify.decorate('use', use as any);
fastify[kMiddlewares] = [];
fastify[kMiddieHasMiddlewares] = false;
fastify[kMiddie] = middie(onMiddieEnd);
fastify[kMiddie] = middie(onMiddieEnd, fastify.initialConfig);
const hook = options.hook || 'onRequest';
@@ -295,7 +359,7 @@ function fastifyMiddie(
function onRegister(instance: FastifyInstance) {
const middlewares = instance[kMiddlewares].slice() as Array<Array<unknown>>;
instance[kMiddlewares] = [];
instance[kMiddie] = middie(onMiddieEnd);
instance[kMiddie] = middie(onMiddieEnd, instance.initialConfig);
instance[kMiddieHasMiddlewares] = false;
instance.decorate('use', use as any);
for (const middleware of middlewares) {