diff --git a/CHANGELOG.md b/CHANGELOG.md index 46531a5..9bc56f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed ### Added +## [2.0.0] - 2026-05-03 +### Changed +- **BREAKING**: Deployment route patterns now use [URLPattern](https://developer.mozilla.org/en-US/docs/Web/API/URLPattern) syntax instead of glob-style patterns. Before: `*example.com/*`, After: `https://*.example.com/*`. URLPattern instances are pre-compiled at router creation time for better performance and ReDoS mitigation. +- `deployments` is now optional in `Config` — users who only need routing no longer need to specify `deployments: []`. +- Removed unused `config` parameter from internal `withAuth` function signature. + +### Fixed +- `normalizeRequest` now preserves HTTP method, headers, and request body when rewriting URLs (previously created a bare `new Request(url)` losing all properties). +- Route matching loop now uses `continue` instead of `break` on partial substring mismatches, allowing later routes to match correctly. +- Basic Auth password parsing now supports colons in passwords per RFC 7617 (uses `indexOf(':')` + `slice()` instead of `split(':')`). + +### Added +- Comprehensive test coverage for IP-based auth, mixed auth (IP + Basic), and edge cases (missing headers, colons in passwords). +- ESLint with `typescript-eslint` flat config. +- `.nvmrc` pinning Node 22. +- Updated `wrangler.toml` compatibility_date to `2026-04-01`. + ## [1.1.0] - 2026-04-26 ### Added - Add webmanifest, map and xml as media formats diff --git a/package.json b/package.json index e2cd0b0..c34bfbc 100644 --- a/package.json +++ b/package.json @@ -40,8 +40,6 @@ "@cloudflare/workers-types": "^4.20250109.0", "@eslint/js": "^10.0.1", "@types/node": "^25.5.2", - "@typescript-eslint/eslint-plugin": "^8.59.1", - "@typescript-eslint/parser": "^8.59.1", "eslint": "^10.3.0", "release-it": "^19.2.4", "typescript": "^6.0.2", diff --git a/src/cloudflare-router.ts b/src/cloudflare-router.ts index 8b715f7..8abe7af 100644 --- a/src/cloudflare-router.ts +++ b/src/cloudflare-router.ts @@ -11,7 +11,7 @@ export const createRouter = (config: Config) => { return { async fetch(request: Request, _env: Record, _ctx: ExecutionContext): Promise { - return withAuth(request, config, compiledDeployments, async () => { + return withAuth(request, 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/with-auth.ts b/src/utils/with-auth.ts index da8a751..5c13471 100644 --- a/src/utils/with-auth.ts +++ b/src/utils/with-auth.ts @@ -1,4 +1,3 @@ -import { Config } from '../config' import { CompiledDeployment, deploymentForRequest } from './deployment-for-request' const getCredentialsFromAuthorizationHeader = (authorizationHeader: string | undefined | null) => { @@ -40,14 +39,14 @@ async function timingSafeEqual(a: string, b: string): Promise { } // Ensures requests are authenticated before executing the callback -export const withAuth = async (request: Request, config: Config, compiledDeployments: CompiledDeployment[], callback: (request: Request) => Promise | Response): Promise => { +export const withAuth = async (request: Request, 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 (compiledDeployments.length === 0 || request.method === 'OPTIONS') { return callback(request) } - // Look for a deployment to ensure we have a valid config + // Look for a deployment matching the incoming request URL 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 1bcf7c7..0ab8dd1 100644 --- a/test/utils/deployment-for-request.test.ts +++ b/test/utils/deployment-for-request.test.ts @@ -1,5 +1,5 @@ import { test, expect } from 'vitest' -import { Config, Deployment } from '../../src/config' +import { Deployment } from '../../src/config' import { compileDeployments, deploymentForRequest } from '../../src/utils/deployment-for-request' const MOCK_DEPLOYMENT_1: Deployment = { diff --git a/test/utils/with-auth.test.ts b/test/utils/with-auth.test.ts index be96c60..3b45066 100644 --- a/test/utils/with-auth.test.ts +++ b/test/utils/with-auth.test.ts @@ -1,6 +1,6 @@ import { test, expect, vi } from 'vitest' import { withAuth } from '../../src/utils/with-auth' -import { Config, Deployment } from '../../src/config' +import { Deployment } from '../../src/config' import { compileDeployments } from '../../src/utils/deployment-for-request' const MOCK_DEPLOYMENT_WITH_AUTH: Deployment = { @@ -29,12 +29,7 @@ test('it calls the callback when no deployments are defined', async () => { const callback = vi.fn().mockReturnValue(new Response('ok')) const request = new Request('https://app.example.com/secrets') - const config: Config = { - deployments: [], - routes: {}, - edgeCacheTtl: 360 - } - await withAuth(request, config, compileDeployments([]), callback) + await withAuth(request, compileDeployments([]), callback) expect(callback).toHaveBeenCalled() }) @@ -43,42 +38,21 @@ test('it calls the callback when request method is options', async () => { const request = new Request('https://app.example.com/secrets', { method: 'OPTIONS', }) - const config: Config = { - deployments: [ - MOCK_DEPLOYMENT_WITH_AUTH, - ], - routes: {}, - edgeCacheTtl: 360 - } - await withAuth(request, config, compileDeployments([MOCK_DEPLOYMENT_WITH_AUTH]), callback) + await withAuth(request, 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://app.example.com/secrets') - const config: Config = { - deployments: [ - MOCK_DEPLOYMENT_WITHOUT_AUTH, - ], - routes: {}, - edgeCacheTtl: 360 - } - await withAuth(request, config, compileDeployments([MOCK_DEPLOYMENT_WITHOUT_AUTH]), callback) + await withAuth(request, 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://app.example.co.uk/secrets') - const config: Config = { - deployments: [ - MOCK_DEPLOYMENT_WITH_AUTH, - ], - routes: {}, - edgeCacheTtl: 360 - } - const response = await withAuth(request, config, compileDeployments([MOCK_DEPLOYMENT_WITH_AUTH]), callback) + const response = await withAuth(request, compileDeployments([MOCK_DEPLOYMENT_WITH_AUTH]), callback) expect(response.status).toBe(404) expect(callback).not.toHaveBeenCalled() }) @@ -86,14 +60,7 @@ test('it does not call callback when there is no matching deployment', async () 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://app.example.com/secrets') - const config: Config = { - deployments: [ - MOCK_DEPLOYMENT_WITH_AUTH, - ], - routes: {}, - edgeCacheTtl: 360 - } - const response = await withAuth(request, config, compileDeployments([MOCK_DEPLOYMENT_WITH_AUTH]), callback) + const response = await withAuth(request, 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() @@ -106,14 +73,7 @@ test('it does not call the callback when auth is required but username is incorr 'Authorization': 'Basic dGVzdGVyOmxldG1laW4=', // tester:letmein } }) - const config: Config = { - deployments: [ - MOCK_DEPLOYMENT_WITH_AUTH, - ], - routes: {}, - edgeCacheTtl: 360 - } - const response = await withAuth(request, config, compileDeployments([MOCK_DEPLOYMENT_WITH_AUTH]), callback) + const response = await withAuth(request, 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() @@ -126,14 +86,7 @@ test('it does not call the callback when auth is required but password is incorr 'Authorization': 'Basic dGVzdDpsZXRtZW91dA==', // test:letmeout } }) - const config: Config = { - deployments: [ - MOCK_DEPLOYMENT_WITH_AUTH, - ], - routes: {}, - edgeCacheTtl: 360 - } - const response = await withAuth(request, config, compileDeployments([MOCK_DEPLOYMENT_WITH_AUTH]), callback) + const response = await withAuth(request, 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() @@ -146,14 +99,7 @@ test('it calls callback when auth is required and valid', async () => { 'Authorization': 'Basic dGVzdDpsZXRtZWlu', // test:letmein } }) - const config: Config = { - deployments: [ - MOCK_DEPLOYMENT_WITH_AUTH, - ], - routes: {}, - edgeCacheTtl: 360 - } - await withAuth(request, config, compileDeployments([MOCK_DEPLOYMENT_WITH_AUTH]), callback) + await withAuth(request, compileDeployments([MOCK_DEPLOYMENT_WITH_AUTH]), callback) expect(callback).toHaveBeenCalled() }) @@ -170,12 +116,7 @@ test('it authenticates when password contains colons', async () => { 'Authorization': 'Basic dGVzdDpwYXNzOndvcmQ=', // test:pass:word } }) - const config: Config = { - deployments: [deployment], - routes: {}, - edgeCacheTtl: 360 - } - await withAuth(request, config, compileDeployments([deployment]), callback) + await withAuth(request, compileDeployments([deployment]), callback) expect(callback).toHaveBeenCalled() }) @@ -190,12 +131,7 @@ test('it rejects when password with colons does not match', async () => { 'Authorization': 'Basic dGVzdDpsZXRtZWlu', // test:letmein (wrong password) } }) - const config: Config = { - deployments: [deployment], - routes: {}, - edgeCacheTtl: 360 - } - const response = await withAuth(request, config, compileDeployments([deployment]), callback) + const response = await withAuth(request, compileDeployments([deployment]), callback) expect(response.status).toBe(401) expect(callback).not.toHaveBeenCalled() }) @@ -221,12 +157,7 @@ test('it calls callback when IP matches allow list', async () => { const request = new Request('https://app.example.com/secrets', { headers: { 'CF-Connecting-IP': '192.168.1.1' }, }) - const config: Config = { - deployments: [MOCK_DEPLOYMENT_WITH_IP_AUTH], - routes: {}, - edgeCacheTtl: 360, - } - await withAuth(request, config, compileDeployments([MOCK_DEPLOYMENT_WITH_IP_AUTH]), callback) + await withAuth(request, compileDeployments([MOCK_DEPLOYMENT_WITH_IP_AUTH]), callback) expect(callback).toHaveBeenCalled() }) @@ -235,12 +166,7 @@ test('it rejects when IP does not match allow list', async () => { const request = new Request('https://app.example.com/secrets', { headers: { 'CF-Connecting-IP': '172.16.0.1' }, }) - const config: Config = { - deployments: [MOCK_DEPLOYMENT_WITH_IP_AUTH], - routes: {}, - edgeCacheTtl: 360, - } - const response = await withAuth(request, config, compileDeployments([MOCK_DEPLOYMENT_WITH_IP_AUTH]), callback) + const response = await withAuth(request, compileDeployments([MOCK_DEPLOYMENT_WITH_IP_AUTH]), callback) expect(response.status).toBe(401) expect(callback).not.toHaveBeenCalled() }) @@ -248,12 +174,7 @@ test('it rejects when IP does not match allow list', async () => { test('it rejects when CF-Connecting-IP header is missing', async () => { const callback = vi.fn().mockReturnValue(new Response('ok')) const request = new Request('https://app.example.com/secrets') - const config: Config = { - deployments: [MOCK_DEPLOYMENT_WITH_IP_AUTH], - routes: {}, - edgeCacheTtl: 360, - } - const response = await withAuth(request, config, compileDeployments([MOCK_DEPLOYMENT_WITH_IP_AUTH]), callback) + const response = await withAuth(request, compileDeployments([MOCK_DEPLOYMENT_WITH_IP_AUTH]), callback) expect(response.status).toBe(401) expect(callback).not.toHaveBeenCalled() }) @@ -275,12 +196,7 @@ test('it authorizes via IP when mixed auth config and IP matches', async () => { const request = new Request('https://app.example.com/secrets', { headers: { 'CF-Connecting-IP': '192.168.1.1' }, }) - const config: Config = { - deployments: [MOCK_DEPLOYMENT_WITH_MIXED_AUTH], - routes: {}, - edgeCacheTtl: 360, - } - await withAuth(request, config, compileDeployments([MOCK_DEPLOYMENT_WITH_MIXED_AUTH]), callback) + await withAuth(request, compileDeployments([MOCK_DEPLOYMENT_WITH_MIXED_AUTH]), callback) expect(callback).toHaveBeenCalled() }) @@ -292,12 +208,6 @@ test('it authorizes via basic auth when mixed auth config and IP does not match' 'Authorization': 'Basic dGVzdDpsZXRtZWlu', // test:letmein }, }) - const config: Config = { - deployments: [MOCK_DEPLOYMENT_WITH_MIXED_AUTH], - routes: {}, - edgeCacheTtl: 360, - } - await withAuth(request, config, compileDeployments([MOCK_DEPLOYMENT_WITH_MIXED_AUTH]), callback) + await withAuth(request, compileDeployments([MOCK_DEPLOYMENT_WITH_MIXED_AUTH]), callback) expect(callback).toHaveBeenCalled() }) -