From c70bc4611aa5485b6cae656447f5cc9a8c78b6ac Mon Sep 17 00:00:00 2001 From: holistis Date: Fri, 11 Sep 2026 07:51:45 +0200 Subject: [PATCH] fix(edge-functions): decode percent-encoded paths before route matching matchURLPath() compares the raw, undecoded req.url pathname against route patterns, which are written against decoded paths (e.g. /admin/*). A request to /%61dmin/x therefore skips a route meant to match /admin/x, since URL.prototype.pathname does not decode percent-encoding. This came up while comparing netlify dev's local edge function routing against production for a routing-normalization audit; Netlify Hosting's own redirect matching (primitives' rewriter.ts) already decodes before matching, so this brings the two in line. Decoding failures fall back to the raw path instead of throwing. Co-Authored-By: Claude Sonnet 5 --- src/lib/edge-functions/registry.ts | 17 +++++-- .../unit/lib/edge-functions/registry.test.ts | 46 +++++++++++++++++++ 2 files changed, 60 insertions(+), 3 deletions(-) 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() + }) +})