Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 80 additions & 7 deletions packages/auth0-express/src/utils.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
);
});

Expand All @@ -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,<h1>x</h1>', 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"
);
});
});
Expand All @@ -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();
});

Expand All @@ -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', () => {
Expand Down
56 changes: 35 additions & 21 deletions packages/auth0-express/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);
Expand All @@ -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) {
Expand Down
Loading