From d37065adb261ac4e58e0add49080a7a17cb6291b Mon Sep 17 00:00:00 2001 From: svozza Date: Thu, 14 May 2026 17:52:05 +0100 Subject: [PATCH 1/3] fix(event-handler): normalize prefix and request trailing slashes in HTTP Router resolvePrefixedPath now strips trailing slashes off the prefix before joining, preventing double slashes when prefix already ends with `/`. Router.resolve normalizes incoming request paths the same way (preserving `/` itself) so a route registered with path `/` under prefix `/api` matches both `/api` and `/api/`. Closes #5252 --- packages/event-handler/src/http/Router.ts | 5 ++- packages/event-handler/src/http/utils.ts | 29 +++++++++------- .../unit/http/Router/basic-routing.test.ts | 33 +++++++++++++++++++ .../tests/unit/http/utils.test.ts | 2 ++ 4 files changed, 56 insertions(+), 13 deletions(-) diff --git a/packages/event-handler/src/http/Router.ts b/packages/event-handler/src/http/Router.ts index 9ef1f1e685..59edb8ad33 100644 --- a/packages/event-handler/src/http/Router.ts +++ b/packages/event-handler/src/http/Router.ts @@ -342,7 +342,10 @@ class Router { try { const method = req.method as HttpMethod; - const path = new URL(req.url).pathname as Path; + const rawPath = new URL(req.url).pathname; + const path = ( + rawPath === '/' ? rawPath : rawPath.replace(/\/+$/, '') + ) as Path; const route = this.routeRegistry.resolve(method, path); diff --git a/packages/event-handler/src/http/utils.ts b/packages/event-handler/src/http/utils.ts index 7935df3c33..40306c8249 100644 --- a/packages/event-handler/src/http/utils.ts +++ b/packages/event-handler/src/http/utils.ts @@ -325,24 +325,29 @@ export const composeMiddleware = (middleware: Middleware[]): Middleware => { /** * Resolves a prefixed path by combining the provided path and prefix. * - * The function returns a RegExp if any of the path or prefix is a RegExp. - * Otherwise, it returns a `/${string}` type value. + * Trailing slashes on the prefix are stripped before joining, so a prefix of + * `/api` and `/api/` produce the same result. When the resulting path would + * end with a redundant `/` (e.g. path `/` under any prefix), the trailing + * slash is collapsed so the route id is canonical (`/api`, not `/api/`). + * Incoming request paths are normalized the same way at lookup time, so a + * route registered with path `/` under prefix `/api` matches both `/api` and + * `/api/`. + * + * Returns a `RegExp` if either argument is a `RegExp`; otherwise a + * `/${string}` typed value. * * @param path - The path to resolve - * @param prefix - The prefix to prepend to the path + * @param prefix - The prefix to prepend to the path; trailing slashes are ignored */ export const resolvePrefixedPath = (path: Path, prefix?: Path): Path => { if (!prefix) return path; - if (isRegExp(prefix)) { - if (isRegExp(path)) { - return new RegExp(`${getPathString(prefix)}/${getPathString(path)}`); - } - return new RegExp(`${getPathString(prefix)}${path}`); - } - if (isRegExp(path)) { - return new RegExp(`${prefix}/${getPathString(path)}`); + const prefixStr = getPathString(prefix).replace(/\/+$/, ''); + if (isRegExp(prefix) || isRegExp(path)) { + const pathStr = getPathString(path); + const sep = pathStr.startsWith('/') ? '' : '/'; + return new RegExp(`${prefixStr}${sep}${pathStr}`); } - return `${prefix}${path}`.replace(/\/$/, '') as Path; + return `${prefixStr}${path}`.replace(/\/$/, '') as Path; }; export const HttpResponseStream = diff --git a/packages/event-handler/tests/unit/http/Router/basic-routing.test.ts b/packages/event-handler/tests/unit/http/Router/basic-routing.test.ts index eda21a26ea..199e64b5cd 100644 --- a/packages/event-handler/tests/unit/http/Router/basic-routing.test.ts +++ b/packages/event-handler/tests/unit/http/Router/basic-routing.test.ts @@ -218,6 +218,39 @@ describe.each([ expect(JSON.parse(getResult.body ?? '{}').actualPath).toBe('/todos/1'); }); + it('matches a root route registered under a prefix for both /prefix and /prefix/', async () => { + // Prepare + const app = new Router({ prefix: '/api' }); + app.get('/', () => ({ root: true })); + + // Act + const noSlash = await app.resolve(createEvent('/api', 'GET'), context); + const trailingSlash = await app.resolve( + createEvent('/api/', 'GET'), + context + ); + + // Assess + expect(noSlash.statusCode).toBe(200); + expect(JSON.parse(noSlash.body ?? '{}')).toEqual({ root: true }); + expect(trailingSlash.statusCode).toBe(200); + expect(JSON.parse(trailingSlash.body ?? '{}')).toEqual({ root: true }); + }); + + it('routes correctly when prefix accidentally ends with a slash', async () => { + // Prepare: prefix ends with `/` — the registered route id should not + // contain `//` and incoming requests should still match. + const app = new Router({ prefix: '/api/' }); + app.get('/users', () => ({ users: [] })); + + // Act + const result = await app.resolve(createEvent('/api/users', 'GET'), context); + + // Assess + expect(result.statusCode).toBe(200); + expect(JSON.parse(result.body ?? '{}')).toEqual({ users: [] }); + }); + it('routes to the included router when using split routers', async () => { // Prepare const todoRouter = new Router({ logger: console }); diff --git a/packages/event-handler/tests/unit/http/utils.test.ts b/packages/event-handler/tests/unit/http/utils.test.ts index b4d3cd9ed7..0f2f551806 100644 --- a/packages/event-handler/tests/unit/http/utils.test.ts +++ b/packages/event-handler/tests/unit/http/utils.test.ts @@ -855,6 +855,8 @@ describe('Path Utilities', () => { it.each([ { path: '/test', prefix: '/prefix', expected: '/prefix/test' }, { path: '/', prefix: '/prefix', expected: '/prefix' }, + { path: '/users', prefix: '/api/', expected: '/api/users' }, + { path: '/', prefix: '/api/', expected: '/api' }, { path: '/test', expected: '/test' }, { path: /.+/, prefix: '/prefix', expected: /\/prefix\/.+/ }, { path: '/test', prefix: /\/prefix/, expected: /\/prefix\/test/ }, From b18981ec3631fd563ed67c32f77af50fa7a94ec2 Mon Sep 17 00:00:00 2001 From: svozza Date: Thu, 14 May 2026 18:08:42 +0100 Subject: [PATCH 2/3] refactor(event-handler): use linear trailing-slash strip helper Replace `replace(/\/+$/, '')` with a small `stripTrailingSlashes` helper that walks the string from the end, keeping path normalization linear in input length. --- packages/event-handler/src/http/Router.ts | 3 ++- packages/event-handler/src/http/utils.ts | 13 +++++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/event-handler/src/http/Router.ts b/packages/event-handler/src/http/Router.ts index 59edb8ad33..3e4acd2c92 100644 --- a/packages/event-handler/src/http/Router.ts +++ b/packages/event-handler/src/http/Router.ts @@ -81,6 +81,7 @@ import { isBinaryResult, isExtendedAPIGatewayProxyResult, resolvePrefixedPath, + stripTrailingSlashes, } from './utils.js'; class Router { @@ -344,7 +345,7 @@ class Router { const method = req.method as HttpMethod; const rawPath = new URL(req.url).pathname; const path = ( - rawPath === '/' ? rawPath : rawPath.replace(/\/+$/, '') + rawPath === '/' ? rawPath : stripTrailingSlashes(rawPath) ) as Path; const route = this.routeRegistry.resolve(method, path); diff --git a/packages/event-handler/src/http/utils.ts b/packages/event-handler/src/http/utils.ts index 40306c8249..f2049cd39d 100644 --- a/packages/event-handler/src/http/utils.ts +++ b/packages/event-handler/src/http/utils.ts @@ -339,15 +339,24 @@ export const composeMiddleware = (middleware: Middleware[]): Middleware => { * @param path - The path to resolve * @param prefix - The prefix to prepend to the path; trailing slashes are ignored */ +// Linear-time trailing-slash strip. Avoids `replace(/\/+$/, '')` to keep +// behavior linear on attacker-controlled request paths. +export const stripTrailingSlashes = (input: string): string => { + let end = input.length; + while (end > 0 && input[end - 1] === '/') end--; + return end === input.length ? input : input.slice(0, end); +}; + export const resolvePrefixedPath = (path: Path, prefix?: Path): Path => { if (!prefix) return path; - const prefixStr = getPathString(prefix).replace(/\/+$/, ''); + const prefixStr = stripTrailingSlashes(getPathString(prefix)); if (isRegExp(prefix) || isRegExp(path)) { const pathStr = getPathString(path); const sep = pathStr.startsWith('/') ? '' : '/'; return new RegExp(`${prefixStr}${sep}${pathStr}`); } - return `${prefixStr}${path}`.replace(/\/$/, '') as Path; + const joined = `${prefixStr}${path}`; + return (joined.endsWith('/') ? joined.slice(0, -1) : joined) as Path; }; export const HttpResponseStream = From 3973bba64354ba5aab04ca8dbaba32f549042b57 Mon Sep 17 00:00:00 2001 From: svozza Date: Fri, 15 May 2026 10:37:44 +0100 Subject: [PATCH 3/3] docs(event-handler): pair JSDoc/comment with their correct functions --- packages/event-handler/src/http/utils.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/event-handler/src/http/utils.ts b/packages/event-handler/src/http/utils.ts index f2049cd39d..550fa345c9 100644 --- a/packages/event-handler/src/http/utils.ts +++ b/packages/event-handler/src/http/utils.ts @@ -322,6 +322,14 @@ export const composeMiddleware = (middleware: Middleware[]): Middleware => { }; }; +// Linear-time trailing-slash strip. Avoids `replace(/\/+$/, '')` to keep +// behavior linear on attacker-controlled request paths. +export const stripTrailingSlashes = (input: string): string => { + let end = input.length; + while (end > 0 && input[end - 1] === '/') end--; + return end === input.length ? input : input.slice(0, end); +}; + /** * Resolves a prefixed path by combining the provided path and prefix. * @@ -339,14 +347,6 @@ export const composeMiddleware = (middleware: Middleware[]): Middleware => { * @param path - The path to resolve * @param prefix - The prefix to prepend to the path; trailing slashes are ignored */ -// Linear-time trailing-slash strip. Avoids `replace(/\/+$/, '')` to keep -// behavior linear on attacker-controlled request paths. -export const stripTrailingSlashes = (input: string): string => { - let end = input.length; - while (end > 0 && input[end - 1] === '/') end--; - return end === input.length ? input : input.slice(0, end); -}; - export const resolvePrefixedPath = (path: Path, prefix?: Path): Path => { if (!prefix) return path; const prefixStr = stripTrailingSlashes(getPathString(prefix));