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) {