mirror of
https://github.com/nestjs/nest.git
synced 2026-02-21 15:08:37 +00:00
fix(fastify): fastify middleware bypass cve
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user