diff --git a/integration/hello-world/e2e/middleware-strict-routing.spec.ts b/integration/hello-world/e2e/middleware-strict-routing.spec.ts new file mode 100644 index 0000000000..b2d9e71281 --- /dev/null +++ b/integration/hello-world/e2e/middleware-strict-routing.spec.ts @@ -0,0 +1,100 @@ +import { + Controller, + Get, + INestApplication, + MiddlewareConsumer, + Module, + NestModule, + RequestMethod, +} from '@nestjs/common'; +import { NestFactory } from '@nestjs/core'; +import { ExpressAdapter } from '@nestjs/platform-express'; +import express from 'express'; +import request from 'supertest'; +import { listenOnLoopback } from '../../_support/listen-on-loopback.js'; + +let middlewareCalls = 0; + +@Controller('health') +class HealthController { + @Get() + root() { + return 'root'; + } + + @Get('deep/') + deep() { + return 'deep'; + } +} + +@Controller('open') +class OpenController { + @Get() + root() { + return 'open'; + } +} + +@Module({ controllers: [HealthController, OpenController] }) +class AppModule implements NestModule { + configure(consumer: MiddlewareConsumer) { + consumer + .apply((req, res, next) => { + middlewareCalls++; + res.statusCode = 403; + res.end('blocked'); + }) + .exclude({ path: 'open', method: RequestMethod.GET }) + .forRoutes(HealthController, OpenController); + } +} + +describe.each([[''], ['api']])( + 'Middleware with strict routing (global prefix "%s")', + prefix => { + let app: INestApplication; + const base = prefix ? `/${prefix}` : ''; + + beforeAll(async () => { + const server = express(); + server.set('strict routing', true); + app = await NestFactory.create(AppModule, new ExpressAdapter(server), { + logger: false, + }); + if (prefix) { + app.setGlobalPrefix(prefix); + } + await listenOnLoopback(app); + }); + + beforeEach(() => { + middlewareCalls = 0; + }); + + afterAll(async () => { + await app.close(); + }); + + it('should run the middleware for the root route of a controller', async () => { + await request(app.getHttpServer()) + .get(`${base}/health`) + .expect(403, 'blocked'); + expect(middlewareCalls).toBe(1); + }); + + it('should run the middleware for a route declared with a trailing slash', async () => { + await request(app.getHttpServer()) + .get(`${base}/health/deep`) + .expect(403, 'blocked'); + expect(middlewareCalls).toBe(1); + }); + + it('should still skip the middleware for an excluded root route', async () => { + await request(app.getHttpServer()) + .get(`${base}/open`) + .expect(200, 'open'); + expect(middlewareCalls).toBe(0); + }); + }, +); diff --git a/packages/platform-express/adapters/express-adapter.ts b/packages/platform-express/adapters/express-adapter.ts index 359b0607ba..ec5dc416f8 100644 --- a/packages/platform-express/adapters/express-adapter.ts +++ b/packages/platform-express/adapters/express-adapter.ts @@ -425,10 +425,20 @@ export class ExpressAdapter extends AbstractHttpAdapter< if (isExactPath && router === this.instance.use) { router = this.instance.all; } - // Express routes are not strict, so "/api" also matches "/api/", which - // the wildcard path registered next to it may match too ("/api/{*path}" - // does, "/api/*path" does not). The core runs the middleware for the - // first of its paths that matches a request only. + // Express routes are not strict by default, so "/api" also matches + // "/api/", which the wildcard path registered next to it may match too + // ("/api/{*path}" does, "/api/*path" does not). The core runs the + // middleware for the first of its paths that matches a request only. + // With "strict routing", the router serves a route the core passes + // with a trailing slash (the root route of a controller, "/users/") + // without it ("/users"), so the path is registered without it too. + if ( + this.instance.router?.strict && + convertedPath.length > 1 && + convertedPath.endsWith('/') + ) { + router.call(this.instance, stripEndSlash(convertedPath), callback); + } return router.call(this.instance, convertedPath, callback); } catch (e) { if (e instanceof TypeError) {