diff --git a/integration/hello-world/e2e/middleware-fastify.spec.ts b/integration/hello-world/e2e/middleware-fastify.spec.ts index 98ee4676078..8f61374fe97 100644 --- a/integration/hello-world/e2e/middleware-fastify.spec.ts +++ b/integration/hello-world/e2e/middleware-fastify.spec.ts @@ -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( + 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( + 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( + 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( + 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(); + }); + }); + }); }); diff --git a/packages/platform-fastify/adapters/fastify-adapter.ts b/packages/platform-fastify/adapters/fastify-adapter.ts index 0831aa5f7d3..842aa5374ac 100644 --- a/packages/platform-fastify/adapters/fastify-adapter.ts +++ b/packages/platform-fastify/adapters/fastify-adapter.ts @@ -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; + + 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; + } } diff --git a/packages/platform-fastify/adapters/middie/fastify-middie.ts b/packages/platform-fastify/adapters/middie/fastify-middie.ts index ca98a27ea7c..95c8245976d 100644 --- a/packages/platform-fastify/adapters/middie/fastify-middie.ts +++ b/packages/platform-fastify/adapters/middie/fastify-middie.ts @@ -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; } +function bindLast any>( + fn: F, + last: Last>, +): (...args: DropLast>) => ReturnType { + return (...args: any[]) => fn(...args, last); +} + +// Helper types +type Last = T extends [...any[], infer L] ? L : never; +type DropLast = 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[] = []; 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>; 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) {