Skip to content
6 changes: 5 additions & 1 deletion packages/event-handler/src/http/Router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ import {
isBinaryResult,
isExtendedAPIGatewayProxyResult,
resolvePrefixedPath,
stripTrailingSlashes,
} from './utils.js';

class Router<TEnv extends Env = Env> {
Expand Down Expand Up @@ -342,7 +343,10 @@ class Router<TEnv extends Env = Env> {

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 : stripTrailingSlashes(rawPath)
) as Path;

const route = this.routeRegistry.resolve(method, path);

Expand Down
47 changes: 35 additions & 12 deletions packages/event-handler/src/http/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,27 +322,50 @@ export const composeMiddleware = (middleware: Middleware[]): Middleware => {
};
};

/**
* Strips trailing forward slashes from a path string in linear time.
*
* Implemented as a manual scan from the right rather than `replace(/\/+$/, '')`
* to keep the cost linear on attacker-controlled request paths — a regex
* approach can be coerced into pathological backtracking on inputs with long
* runs of slashes.
*
* @param input - The path string to normalize
* @returns The input with any trailing `/` characters removed; the original string is returned unchanged when no trailing slashes are present
*/
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.
*
* 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 = stripTrailingSlashes(getPathString(prefix));
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;
const joined = `${prefixStr}${path}`;
return (joined.endsWith('/') ? joined.slice(0, -1) : joined) as Path;
};

export const HttpResponseStream =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
2 changes: 2 additions & 0 deletions packages/event-handler/tests/unit/http/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/ },
Expand Down
Loading