diff --git a/packages/auth0-express/src/utils.spec.ts b/packages/auth0-express/src/utils.spec.ts index 100a794..084cb5e 100644 --- a/packages/auth0-express/src/utils.spec.ts +++ b/packages/auth0-express/src/utils.spec.ts @@ -30,10 +30,25 @@ describe('createRouteUrl', () => { test('throws when path has multiple leading slashes', () => { expect(() => createRouteUrl('///evil.com/auth/callback', BASE)).toThrow( - "Invalid route configuration: '///evil.com/auth/callback' contains multiple leading slashes" + "Invalid route configuration: '///evil.com/auth/callback' contains multiple leading slashes or backslashes" ); expect(() => createRouteUrl('///custom-auth/callback', BASE)).toThrow( - "Invalid route configuration: '///custom-auth/callback' contains multiple leading slashes" + "Invalid route configuration: '///custom-auth/callback' contains multiple leading slashes or backslashes" + ); + }); + + test('throws when path has multiple leading backslashes', () => { + expect(() => createRouteUrl('\\\\evil.com/path', BASE)).toThrow( + "Invalid route configuration: '\\\\evil.com/path' contains multiple leading slashes or backslashes" + ); + }); + + test('throws when path has mixed leading slash and backslash', () => { + expect(() => createRouteUrl('/\\evil.com/path', BASE)).toThrow( + "Invalid route configuration: '/\\evil.com/path' contains multiple leading slashes or backslashes" + ); + expect(() => createRouteUrl('\\/evil.com/path', BASE)).toThrow( + "Invalid route configuration: '\\/evil.com/path' contains multiple leading slashes or backslashes" ); }); @@ -45,17 +60,66 @@ describe('createRouteUrl', () => { expect(createRouteUrl('auth/callback', BASE).href).toBe('https://myapp.com/auth/callback'); }); - test('throws when path is an absolute URL with a scheme', () => { - expect(() => createRouteUrl('https://evil.com/auth/callback', BASE)).toThrow(); + test('single leading backslash is stripped and resolves as a same-origin relative path', () => { + // A single \ is stripped to a plain relative path — not a protocol-relative bypass. + // The WHATWG URL parser also normalises \ to / within the path. + expect(createRouteUrl('\\auth\\callback', BASE).href).toBe('https://myapp.com/auth/callback'); + }); + + test('allows paths with a colon in non-first segment', () => { + expect(createRouteUrl('/a/b:c', BASE).href).toBe('https://myapp.com/a/b:c'); + }); + + test('allows paths whose first segment starts with a digit followed by a colon', () => { + // "2024-report:summary" starts with a digit — not a valid URI scheme per RFC 3986. + expect(createRouteUrl('/2024-report:summary', BASE).href).toBe('https://myapp.com/2024-report:summary'); + }); + + test('accepts an absolute same-origin URL (e.g. from a reverse proxy)', () => { + expect(createRouteUrl('https://myapp.com/auth/callback?code=abc', BASE).href).toBe( + 'https://myapp.com/auth/callback?code=abc' + ); + }); + + test('accepts an absolute same-origin URL with subpath base', () => { + expect(createRouteUrl('https://myapp.com/subapp/auth/callback?code=abc', BASE_WITH_SUBPATH).href).toBe( + 'https://myapp.com/subapp/auth/callback?code=abc' + ); + }); + + test('throws when absolute URL has a different origin', () => { + expect(() => createRouteUrl('https://evil.com/auth/callback', BASE)).toThrow( + 'URL is not allowed: origin does not match base URL' + ); }); test('throws when path uses http scheme to override the base URL origin', () => { - expect(() => createRouteUrl('http://evil.com/auth/callback', BASE)).toThrow(); + expect(() => createRouteUrl('http://evil.com/auth/callback', BASE)).toThrow( + 'URL is not allowed: origin does not match base URL' + ); + }); + + test('throws when path uses javascript scheme', () => { + expect(() => createRouteUrl('javascript:alert(1)', BASE)).toThrow( + 'URL is not allowed: origin does not match base URL' + ); + }); + + test('throws when path uses data scheme', () => { + expect(() => createRouteUrl('data:text/html,

x

', BASE)).toThrow( + 'URL is not allowed: origin does not match base URL' + ); + }); + + test('throws when path uses file scheme', () => { + expect(() => createRouteUrl('file:///etc/passwd', BASE)).toThrow( + 'URL is not allowed: origin does not match base URL' + ); }); test('throws for protocol-relative-looking paths with multiple leading slashes', () => { expect(() => createRouteUrl('////evil.com/path', BASE)).toThrow( - "Invalid route configuration: '////evil.com/path' contains multiple leading slashes" + "Invalid route configuration: '////evil.com/path' contains multiple leading slashes or backslashes" ); }); }); @@ -68,7 +132,6 @@ describe('toSafeRedirect', () => { }); test('returns undefined for triple-slash path', () => { - // ///evil.com → throws due to multiple leading slashes → caught → returns undefined expect(toSafeRedirect('///evil.com/path', BASE)).toBeUndefined(); }); @@ -89,6 +152,16 @@ describe('toSafeRedirect', () => { test('returns undefined for a URL with a different port (different origin)', () => { expect(toSafeRedirect('http://localhost:4000/path', BASE)).toBeUndefined(); }); + + test('returns undefined for backslash-based protocol-relative bypass attempts', () => { + expect(toSafeRedirect('\\\\evil.com', BASE)).toBeUndefined(); + expect(toSafeRedirect('/\\evil.com', BASE)).toBeUndefined(); + expect(toSafeRedirect('\\/evil.com', BASE)).toBeUndefined(); + }); + + test('returns undefined when safeBaseUrl is not a valid URL', () => { + expect(toSafeRedirect('/dashboard', 'not-a-url')).toBeUndefined(); + }); }); describe('wrapDomainResolver', () => { diff --git a/packages/auth0-express/src/utils.ts b/packages/auth0-express/src/utils.ts index 9aa220f..a50a929 100644 --- a/packages/auth0-express/src/utils.ts +++ b/packages/auth0-express/src/utils.ts @@ -15,15 +15,17 @@ function ensureTrailingSlash(value: string) { } /** - * Ensures the value does not have any leading slashes. + * Ensures the value does not have any leading slashes or backslashes. * If it does, it will trim all of them. - * @param value The value to ensure has no leading slashes. - * @returns The value without leading slashes. + * @param value The value to ensure has no leading slashes or backslashes. + * @returns The value without leading slashes or backslashes. */ function ensureNoLeadingSlash(value: string) { - return value.replace(/^\/+/, ''); + return value.replace(/^[/\\]+/, ''); } +const SCHEME_REGEX = /^[a-zA-Z][a-zA-Z0-9.+-]*:/; + /** * Wraps a user-provided domain resolver so it always receives the Express * request context. `@auth0/auth0-server-js` invokes the resolver with the @@ -46,18 +48,28 @@ export function wrapDomainResolver( /** * Utility function to ensure Route URLs are created correctly when using both the root and subpath as base URL. + * Accepts a relative path or an absolute same-origin URL, such as one forwarded by a reverse proxy. * Validates that the constructed URL has the same origin as the base URL to prevent host override attacks. - * @param url The URL to use. - * @param base The base URL to use. + * @param url Relative path (e.g. `/auth/callback`) or absolute same-origin URL. + * @param base The base URL to use, including any subpath. * @returns A URL object, combining the base and url. - * @throws {Error} If the constructed URL origin does not match the base URL origin. + * @throws {Error} If the input contains multiple leading slashes/backslashes or the origin does not match the base URL. */ export function createRouteUrl(url: string, base: string) { const baseUrl = new URL(ensureTrailingSlash(base)); + + if (SCHEME_REGEX.test(url)) { + const absolute = new URL(url); + if (absolute.origin !== baseUrl.origin) { + throw new Error('URL is not allowed: origin does not match base URL'); + } + return absolute; + } + const normalized = ensureNoLeadingSlash(url); - if (normalized !== url.replace(/^\//, '')) { - throw new Error(`Invalid route configuration: '${url}' contains multiple leading slashes`); + if (normalized !== url.replace(/^[/\\]/, '')) { + throw new Error(`Invalid route configuration: '${url}' contains multiple leading slashes or backslashes`); } const result = new URL(normalized, baseUrl); @@ -68,25 +80,27 @@ export function createRouteUrl(url: string, base: string) { } /** - * Function to ensure a redirect URL is safe to use, as in, it has the same origin as the safeBaseUrl. - * @param dangerousRedirect The redirect URL to check. + * Validates a user-supplied redirect URL (e.g. a `returnTo` query parameter) against the application base URL. + * Accepts relative paths and absolute same-origin URLs. Rejects different-origin URLs and injection attempts. + * Never throws — returns undefined for any invalid or unsafe input. + * @param dangerousRedirect Untrusted redirect URL from user input. * @param safeBaseUrl The base URL to check against. - * @returns A safe redirect URL or undefined if the redirect URL is not safe. + * @returns A safe redirect URL, or undefined if the input is not safe. */ export function toSafeRedirect(dangerousRedirect: string, safeBaseUrl: string): string | undefined { - let url: URL; - try { - url = createRouteUrl(dangerousRedirect, safeBaseUrl); + const safeOrigin = new URL(safeBaseUrl).origin; + // Absolute URLs are validated by origin check directly. + // Relative paths go through createRouteUrl rather than new URL(input, base) because + // new URL() treats a leading slash as origin-absolute, dropping the subpath in + // subpath deployments. + const url = SCHEME_REGEX.test(dangerousRedirect) + ? new URL(dangerousRedirect) + : createRouteUrl(dangerousRedirect, safeBaseUrl); + return url.origin === safeOrigin ? url.toString() : undefined; } catch { return undefined; } - - if (url.origin === new URL(safeBaseUrl).origin) { - return url.toString(); - } - - return undefined; } export function createServerClientInstance(options: Auth0Options) {