diff --git a/src/lib/edge-functions/registry.ts b/src/lib/edge-functions/registry.ts index 1c06e31cd0f..3230470b165 100644 --- a/src/lib/edge-functions/registry.ts +++ b/src/lib/edge-functions/registry.ts @@ -499,13 +499,24 @@ export class EdgeFunctionsRegistryImpl implements EdgeFunctionsRegistry { matchURLPath(urlPath: string, method: string, headers: Record) { const functionNames: string[] = [] const routeIndexes: number[] = [] + // `urlPath` comes from `URL.prototype.pathname`, which does not decode + // percent-encoding. Route patterns are written against decoded paths + // (e.g. `/admin/*`), so a request to `/%61dmin/x` would otherwise skip a + // route meant to match `/admin/x`. Malformed sequences fall back to the + // raw path rather than throwing. + let decodedUrlPath = urlPath + try { + decodedUrlPath = decodeURIComponent(urlPath) + } catch { + // Leave decodedUrlPath as the raw urlPath. + } this.routes.forEach((route, index) => { if (route.methods && route.methods.length !== 0 && !route.methods.includes(method)) { return } - if (!route.pattern.test(urlPath)) { + if (!route.pattern.test(decodedUrlPath)) { return } @@ -539,13 +550,13 @@ export class EdgeFunctionsRegistryImpl implements EdgeFunctionsRegistry { } const isExcludedForFunction = this.manifest?.function_config[route.function]?.excluded_patterns?.some((pattern) => - new RegExp(pattern).test(urlPath), + new RegExp(pattern).test(decodedUrlPath), ) if (isExcludedForFunction) { return } - const isExcludedForRoute = route.excluded_patterns.some((pattern) => new RegExp(pattern).test(urlPath)) + const isExcludedForRoute = route.excluded_patterns.some((pattern) => new RegExp(pattern).test(decodedUrlPath)) if (isExcludedForRoute) { return } diff --git a/tests/unit/lib/edge-functions/registry.test.ts b/tests/unit/lib/edge-functions/registry.test.ts index 104d1a34fa1..dd9e7fcca30 100644 --- a/tests/unit/lib/edge-functions/registry.test.ts +++ b/tests/unit/lib/edge-functions/registry.test.ts @@ -61,3 +61,49 @@ describe('EdgeFunctionsRegistryImpl.build() coalescing', () => { expect(state.buildCount).toBe(2) }) }) + +describe('EdgeFunctionsRegistryImpl.matchURLPath() percent-encoded paths', () => { + const createRegistryWithRoute = () => { + const registry = Object.create(EdgeFunctionsRegistryImpl.prototype) as EdgeFunctionsRegistryImpl + // `routes` and `manifest` are private; matchURLPath() only reads them, so + // setting them directly is enough to exercise it in isolation. + const registryInternals = registry as unknown as { routes: unknown[]; manifest: unknown } + + // A route pattern as it would be compiled for `/admin/*`: written against + // the decoded path, same as it's authored in a project's routing config. + registryInternals.routes = [ + { + function: 'admin-guard', + pattern: /^\/admin\/.*$/, + excluded_patterns: [], + }, + ] + registryInternals.manifest = null + + return registry + } + + test('matches a route for the literal, unencoded path', () => { + const registry = createRegistryWithRoute() + + const { functionNames } = registry.matchURLPath('/admin/secretpath', 'GET', {}) + + expect(functionNames).toEqual(['admin-guard']) + }) + + test('matches a route when the request path is percent-encoded', () => { + const registry = createRegistryWithRoute() + + // "%61" is a percent-encoded "a": this requests the same logical path as + // /admin/secretpath, just spelled differently on the wire. + const { functionNames } = registry.matchURLPath('/%61dmin/secretpath', 'GET', {}) + + expect(functionNames).toEqual(['admin-guard']) + }) + + test('falls back to the raw path for malformed percent-encoding instead of throwing', () => { + const registry = createRegistryWithRoute() + + expect(() => registry.matchURLPath('/admin/%', 'GET', {})).not.toThrow() + }) +})