From 01fc8b89a7fa1c21134042e81f60568c52ac02d8 Mon Sep 17 00:00:00 2001 From: Lud Date: Sun, 3 May 2026 21:14:49 +0200 Subject: [PATCH] feat!: replace regex route matching with URLPattern BREAKING CHANGE: Deployment route patterns now use URLPattern syntax instead of glob-style patterns. Before: '*example.com/*' After: 'https://*.example.com/*' This eliminates the ReDoS vulnerability from regex-based matching and pre-compiles URLPattern instances at router creation time for better per-request performance. Key changes: - deployment-for-request.ts: replaced regex with URLPattern, added compileDeployments() for pre-compilation - cloudflare-router.ts: pre-compiles deployments at createRouter() time - with-auth.ts: accepts CompiledDeployment[] instead of reading from config - Updated all tests and README for new pattern syntax --- README.md | 2 +- index.ts | 1 + src/cloudflare-router.ts | 6 ++- src/utils/deployment-for-request.ts | 36 +++++++++---- src/utils/with-auth.ts | 8 +-- test/utils/deployment-for-request.test.ts | 66 +++++++++++++---------- test/utils/with-auth.test.ts | 37 ++++++------- 7 files changed, 95 insertions(+), 61 deletions(-) diff --git a/README.md b/README.md index 83f33b7..23fe089 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ export default createRouter({ accountId: '12345', zoneId: 'abcdef', routes: [ - '*example.com/*', + 'https://*.example.com/*', ], auth: [ { diff --git a/index.ts b/index.ts index f3452a2..a9980b6 100644 --- a/index.ts +++ b/index.ts @@ -1,2 +1,3 @@ export { createRouter } from './src/cloudflare-router' export { BasicAuthMethod, IPAuthMethod, Deployment, Config, Routes, DEFAULT_CONFIG } from './src/config' +export { CompiledDeployment } from './src/utils/deployment-for-request' diff --git a/src/cloudflare-router.ts b/src/cloudflare-router.ts index 9654d3a..1f7c1dc 100644 --- a/src/cloudflare-router.ts +++ b/src/cloudflare-router.ts @@ -3,11 +3,15 @@ import { Config } from './config' import handleRequest from './utils/handle-request' import normalizeRequest from './utils/normalize-request' import { withAuth } from './utils/with-auth' +import { compileDeployments } from './utils/deployment-for-request' export const createRouter = (config: Config) => { + // Pre-compile URLPattern instances once at creation time + const compiledDeployments = compileDeployments(config.deployments) + return { async fetch(request: Request, env: Record, ctx: ExecutionContext): Promise { - return withAuth(request, config, async () => { + return withAuth(request, config, compiledDeployments, async () => { const { request: normalizedReq, cache } = normalizeRequest(request, config.routes, config.isS3Site) const edgeCacheTtl = cache && config.edgeCacheTtl ? config.edgeCacheTtl : 0 return handleRequest(normalizedReq, edgeCacheTtl) diff --git a/src/utils/deployment-for-request.ts b/src/utils/deployment-for-request.ts index d2d634c..3bc43f0 100644 --- a/src/utils/deployment-for-request.ts +++ b/src/utils/deployment-for-request.ts @@ -1,12 +1,28 @@ -import { Config, Deployment } from '../config' +import { Deployment } from '../config' -export const deploymentForRequest = (request: Request, config: Config): Deployment | undefined => { - return config.deployments.find(deployment => { - return deployment.routes.find(route => { - const sanitizedRoute = route.replace(/[^a-zA-Z0-9*\.\-\/]/g, '') // We only really want to allow the pattern *.example.com/* - const regexedRoute = sanitizedRoute.replace(/\./g, '\\.').replace(/\*/g, '(.*)') - const normalizedUrl = request.url.replace(/https?:\/\//, '') - return normalizedUrl.match(new RegExp(`^${regexedRoute}$`)) - }) - }) +export interface CompiledDeployment { + deployment: Deployment + patterns: URLPattern[] +} + +/** + * Pre-compiles deployment route patterns into URLPattern instances. + * Called once at router creation time to avoid per-request compilation. + */ +export const compileDeployments = (deployments: Deployment[]): CompiledDeployment[] => { + return deployments.map(deployment => ({ + deployment, + patterns: deployment.routes.map(route => new URLPattern(route)), + })) +} + +/** + * Finds the deployment matching the incoming request URL. + * Uses pre-compiled URLPattern instances for safe, fast matching. + */ +export const deploymentForRequest = (request: Request, compiledDeployments: CompiledDeployment[]): Deployment | undefined => { + const match = compiledDeployments.find(({ patterns }) => + patterns.some(pattern => pattern.test(request.url)) + ) + return match?.deployment } diff --git a/src/utils/with-auth.ts b/src/utils/with-auth.ts index dde70b1..99e16a0 100644 --- a/src/utils/with-auth.ts +++ b/src/utils/with-auth.ts @@ -1,5 +1,5 @@ import { Config } from '../config' -import { deploymentForRequest } from './deployment-for-request' +import { CompiledDeployment, deploymentForRequest } from './deployment-for-request' const getCredentialsFromAuthorizationHeader = (authorizationHeader: string | undefined | null) => { const encoded = (authorizationHeader || '').replace('Basic ', '') @@ -35,15 +35,15 @@ async function timingSafeEqual(a: string, b: string): Promise { } // Ensures requests are authenticated before executing the callback -export const withAuth = async (request: Request, config: Config, callback: (request: Request) => Promise | Response): Promise => { +export const withAuth = async (request: Request, config: Config, compiledDeployments: CompiledDeployment[], callback: (request: Request) => Promise | Response): Promise => { // If no deployments are defined, then just allow all requests to passthrough // We also allow options requests here to get cors headers from origin - if (config.deployments.length === 0 || request.method === 'OPTIONS') { + if (compiledDeployments.length === 0 || request.method === 'OPTIONS') { return callback(request) } // Look for a deployment to ensure we have a valid config - const deployment = deploymentForRequest(request, config) + const deployment = deploymentForRequest(request, compiledDeployments) if (deployment === undefined) { return new Response('Unknown deployment', { status: 404 }) } diff --git a/test/utils/deployment-for-request.test.ts b/test/utils/deployment-for-request.test.ts index 82a713c..1bcf7c7 100644 --- a/test/utils/deployment-for-request.test.ts +++ b/test/utils/deployment-for-request.test.ts @@ -1,12 +1,12 @@ import { test, expect } from 'vitest' import { Config, Deployment } from '../../src/config' -import { deploymentForRequest } from '../../src/utils/deployment-for-request' +import { compileDeployments, deploymentForRequest } from '../../src/utils/deployment-for-request' const MOCK_DEPLOYMENT_1: Deployment = { accountId: '12345', zoneId: '12345', routes: [ - '*example.com/*', + 'https://*.example.com/*', ], auth: [ { @@ -18,43 +18,55 @@ const MOCK_DEPLOYMENT_1: Deployment = { } test('it finds a deployment for matching request', () => { - const request = new Request('https://example.com/explore') - const config: Config = { - deployments: [ - MOCK_DEPLOYMENT_1, - ], - routes: {}, - edgeCacheTtl: 360 - } + const request = new Request('https://app.example.com/explore') + const compiled = compileDeployments([MOCK_DEPLOYMENT_1]) - const deployment = deploymentForRequest(request, config) + const deployment = deploymentForRequest(request, compiled) expect(deployment).toEqual(MOCK_DEPLOYMENT_1) }) test('it finds a deployment for matching subdomain', () => { const request = new Request('https://api.example.com/explore') - const config: Config = { - deployments: [ - MOCK_DEPLOYMENT_1, - ], - routes: {}, - edgeCacheTtl: 360 - } + const compiled = compileDeployments([MOCK_DEPLOYMENT_1]) - const deployment = deploymentForRequest(request, config) + const deployment = deploymentForRequest(request, compiled) expect(deployment).toEqual(MOCK_DEPLOYMENT_1) }) test('it returns undefined when there is no matching request', () => { const request = new Request('https://example.co.uk/explore') - const config: Config = { - deployments: [ - MOCK_DEPLOYMENT_1, - ], - routes: {}, - edgeCacheTtl: 360 - } + const compiled = compileDeployments([MOCK_DEPLOYMENT_1]) - const deployment = deploymentForRequest(request, config) + const deployment = deploymentForRequest(request, compiled) expect(deployment).toEqual(undefined) }) + +test('it matches exact hostname pattern', () => { + const deployment: Deployment = { + accountId: '1', + zoneId: '1', + routes: ['https://exact.example.com/*'], + } + const compiled = compileDeployments([deployment]) + + expect(deploymentForRequest(new Request('https://exact.example.com/page'), compiled)).toEqual(deployment) + expect(deploymentForRequest(new Request('https://other.example.com/page'), compiled)).toBeUndefined() +}) + +test('it selects the correct deployment from multiple deployments', () => { + const deploymentA: Deployment = { + accountId: '1', + zoneId: '1', + routes: ['https://*.alpha.com/*'], + } + const deploymentB: Deployment = { + accountId: '2', + zoneId: '2', + routes: ['https://*.beta.com/*'], + } + const compiled = compileDeployments([deploymentA, deploymentB]) + + expect(deploymentForRequest(new Request('https://app.alpha.com/dashboard'), compiled)).toEqual(deploymentA) + expect(deploymentForRequest(new Request('https://app.beta.com/dashboard'), compiled)).toEqual(deploymentB) + expect(deploymentForRequest(new Request('https://app.gamma.com/dashboard'), compiled)).toBeUndefined() +}) diff --git a/test/utils/with-auth.test.ts b/test/utils/with-auth.test.ts index 4179ace..e852da0 100644 --- a/test/utils/with-auth.test.ts +++ b/test/utils/with-auth.test.ts @@ -1,12 +1,13 @@ import { test, expect, vi } from 'vitest' import { withAuth } from '../../src/utils/with-auth' import { Config, Deployment } from '../../src/config' +import { compileDeployments } from '../../src/utils/deployment-for-request' const MOCK_DEPLOYMENT_WITH_AUTH: Deployment = { accountId: '12345', zoneId: '12345', routes: [ - '*example.com/*', + 'https://*.example.com/*', ], auth: [ { @@ -20,26 +21,26 @@ const MOCK_DEPLOYMENT_WITHOUT_AUTH: Deployment = { accountId: '12345', zoneId: '12345', routes: [ - '*example.com/*', + 'https://*.example.com/*', ], } test('it calls the callback when no deployments are defined', async () => { const callback = vi.fn().mockReturnValue(new Response('ok')) - const request = new Request('https://example.com/secrets') + const request = new Request('https://app.example.com/secrets') const config: Config = { deployments: [], routes: {}, edgeCacheTtl: 360 } - await withAuth(request, config, callback) + await withAuth(request, config, compileDeployments([]), callback) expect(callback).toHaveBeenCalled() }) test('it calls the callback when request method is options', async () => { const callback = vi.fn().mockReturnValue(new Response('ok')) - const request = new Request('https://example.com/secrets', { + const request = new Request('https://app.example.com/secrets', { method: 'OPTIONS', }) const config: Config = { @@ -49,13 +50,13 @@ test('it calls the callback when request method is options', async () => { routes: {}, edgeCacheTtl: 360 } - await withAuth(request, config, callback) + await withAuth(request, config, compileDeployments([MOCK_DEPLOYMENT_WITH_AUTH]), callback) expect(callback).toHaveBeenCalled() }) test('it calls the callback when a deployment is matched without auth', async () => { const callback = vi.fn().mockReturnValue(new Response('ok')) - const request = new Request('https://example.com/secrets') + const request = new Request('https://app.example.com/secrets') const config: Config = { deployments: [ MOCK_DEPLOYMENT_WITHOUT_AUTH, @@ -63,13 +64,13 @@ test('it calls the callback when a deployment is matched without auth', async () routes: {}, edgeCacheTtl: 360 } - await withAuth(request, config, callback) + await withAuth(request, config, compileDeployments([MOCK_DEPLOYMENT_WITHOUT_AUTH]), callback) expect(callback).toHaveBeenCalled() }) test('it does not call callback when there is no matching deployment', async () => { const callback = vi.fn().mockReturnValue(new Response('ok')) - const request = new Request('https://example.co.uk/secrets') + const request = new Request('https://app.example.co.uk/secrets') const config: Config = { deployments: [ MOCK_DEPLOYMENT_WITH_AUTH, @@ -77,14 +78,14 @@ test('it does not call callback when there is no matching deployment', async () routes: {}, edgeCacheTtl: 360 } - const response = await withAuth(request, config, callback) + const response = await withAuth(request, config, compileDeployments([MOCK_DEPLOYMENT_WITH_AUTH]), callback) expect(response.status).toBe(404) expect(callback).not.toHaveBeenCalled() }) test('it does not call the callback when auth is required but missing', async () => { const callback = vi.fn().mockReturnValue(new Response('ok')) - const request = new Request('https://example.com/secrets') + const request = new Request('https://app.example.com/secrets') const config: Config = { deployments: [ MOCK_DEPLOYMENT_WITH_AUTH, @@ -92,7 +93,7 @@ test('it does not call the callback when auth is required but missing', async () routes: {}, edgeCacheTtl: 360 } - const response = await withAuth(request, config, callback) + const response = await withAuth(request, config, compileDeployments([MOCK_DEPLOYMENT_WITH_AUTH]), callback) expect(response.status).toBe(401) expect(response.headers.get('WWW-Authenticate')).toBe('Basic realm="Cloudflare Router", charset="UTF-8"') expect(callback).not.toHaveBeenCalled() @@ -100,7 +101,7 @@ test('it does not call the callback when auth is required but missing', async () test('it does not call the callback when auth is required but username is incorrect', async () => { const callback = vi.fn().mockReturnValue(new Response('ok')) - const request = new Request('https://example.com/secrets', { + const request = new Request('https://app.example.com/secrets', { headers: { 'Authorization': 'Basic dGVzdGVyOmxldG1laW4=', // tester:letmein } @@ -112,7 +113,7 @@ test('it does not call the callback when auth is required but username is incorr routes: {}, edgeCacheTtl: 360 } - const response = await withAuth(request, config, callback) + const response = await withAuth(request, config, compileDeployments([MOCK_DEPLOYMENT_WITH_AUTH]), callback) expect(response.status).toBe(401) expect(response.headers.get('WWW-Authenticate')).toBe('Basic realm="Cloudflare Router", charset="UTF-8"') expect(callback).not.toHaveBeenCalled() @@ -120,7 +121,7 @@ test('it does not call the callback when auth is required but username is incorr test('it does not call the callback when auth is required but password is incorrect', async () => { const callback = vi.fn().mockReturnValue(new Response('ok')) - const request = new Request('https://example.com/secrets', { + const request = new Request('https://app.example.com/secrets', { headers: { 'Authorization': 'Basic dGVzdDpsZXRtZW91dA==', // test:letmeout } @@ -132,7 +133,7 @@ test('it does not call the callback when auth is required but password is incorr routes: {}, edgeCacheTtl: 360 } - const response = await withAuth(request, config, callback) + const response = await withAuth(request, config, compileDeployments([MOCK_DEPLOYMENT_WITH_AUTH]), callback) expect(response.status).toBe(401) expect(response.headers.get('WWW-Authenticate')).toBe('Basic realm="Cloudflare Router", charset="UTF-8"') expect(callback).not.toHaveBeenCalled() @@ -140,7 +141,7 @@ test('it does not call the callback when auth is required but password is incorr test('it calls callback when auth is required and valid', async () => { const callback = vi.fn().mockReturnValue(new Response('ok')) - const request = new Request('https://example.com/secrets', { + const request = new Request('https://app.example.com/secrets', { headers: { 'Authorization': 'Basic dGVzdDpsZXRtZWlu', // test:letmein } @@ -152,6 +153,6 @@ test('it calls callback when auth is required and valid', async () => { routes: {}, edgeCacheTtl: 360 } - await withAuth(request, config, callback) + await withAuth(request, config, compileDeployments([MOCK_DEPLOYMENT_WITH_AUTH]), callback) expect(callback).toHaveBeenCalled() })