Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/chilled-crabs-stare.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/clerk-js': patch
---

Fix an issue where protocol relative URLs were not properly detected as non-relative.
2 changes: 1 addition & 1 deletion packages/clerk-js/src/utils/__tests__/redirectUrls.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ describe('redirectUrls', () => {
expect(redirectUrls.getAfterSignUpUrl()).toBe(`${mockWindowLocation.href}sign-up-force-redirect-url`);
});

it('prioritizes redirect_url from searchParamsover fallback urls', () => {
it('prioritizes redirect_url from searchParams over fallback urls', () => {
const redirectUrls = new RedirectUrls(
{
signInFallbackRedirectUrl: 'sign-in-fallback-redirect-url',
Expand Down
55 changes: 37 additions & 18 deletions packages/clerk-js/src/utils/__tests__/url.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@ import {
getSearchParameterFromHash,
hasBannedProtocol,
hasExternalAccountSignUpError,
isAllowedRedirectOrigin,
isAllowedRedirect,
isDataUri,
isDevAccountPortalOrigin,
isProblematicUrl,
isRedirectForFAPIInitiatedFlow,
isRelativeUrl,
isValidUrl,
mergeFragmentIntoUrl,
relativeToAbsoluteUrl,
Expand Down Expand Up @@ -84,18 +84,38 @@ describe('isValidUrl(url,base)', () => {
});
});

describe('isRelativeUrl(url,base)', () => {
describe('isProblematicUrl(url)', () => {
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could potentially do with more test cases here, but with the shift to checking against full URLs we get more guarantees from the existing origin check.

const cases: Array<[string, boolean]> = [
['', true],
['/', true],
['/test', true],
['/test?clerk=true', true],
['/?clerk=true', true],
['https://www.clerk.com/', false],
// 1. URLs with backslashes instead of forward slashes
['\\evil.com', false],
['/\\evil.com', false],
['\\\\evil.com', false],
['/..\\evil.com', false],
['/\\@evil.com', false],

// 2. Path traversal attempts
['..//evil.com', true],
['/../evil.com', false],
['../../', false],
['/../../', false],

// 3. URLs with different schemes
['javascript:alert(1)', true],

// 4. URLs with control characters and whitespace
['/test ', false],
[' /test', false],
['/test\n', false],

// 5. Fragment identifiers and query parameters
['/#/evil.com', false],
['/path#//evil.com', false],
['/evil.com?redirect=evil.com', false],
['/evil.com?redirect=https://evil.com', false],
];

test.each(cases)('.isRelativeUrl(%s,%s)', (a, expected) => {
expect(isRelativeUrl(a)).toBe(expected);
test.each(cases)('.isProblematicUrl(%s,%s)', (a, expected) => {
expect(isProblematicUrl(new URL(a, 'https://clerk.dummy'))).toBe(expected);
});
});

Expand Down Expand Up @@ -441,7 +461,7 @@ describe('getETLDPlusOneFromFrontendApi(frontendAp: string)', () => {
});
});

describe('isAllowedRedirectOrigin', () => {
describe('isAllowedRedirect', () => {
const cases: [string, Array<string | RegExp> | undefined, boolean][] = [
// base cases
['https://clerk.com', ['https://www.clerk.com'], false],
Expand All @@ -463,7 +483,7 @@ describe('isAllowedRedirectOrigin', () => {
// empty origins list for relative routes
['/', [], true],
// empty origins list for absolute routes
['https://www.clerk.com/', [], false],
['https://www.example.com/', [], false],
//undefined origins
['https://www.clerk.com/', undefined, true],
// query params
Expand All @@ -475,18 +495,17 @@ describe('isAllowedRedirectOrigin', () => {
// malformed or protocol-relative URLs
['http:evil.com', [/https:\/\/www\.clerk\.com/], false],
['https:evil.com', [/https:\/\/www\.clerk\.com/], false],
['http//evil.com', [/https:\/\/www\.clerk\.com/], false],
['https//evil.com', [/https:\/\/www\.clerk\.com/], false],
['//evil.com', [/https:\/\/www\.clerk\.com/], false],
['..//evil.com', ['https://www.clerk.com'], false],
];

const warnMock = jest.spyOn(logger, 'warnOnce');

beforeEach(() => warnMock.mockClear());
afterAll(() => warnMock.mockRestore());

test.each(cases)('isAllowedRedirectOrigin("%s","%s") === %s', (url, allowedOrigins, expected) => {
expect(isAllowedRedirectOrigin(allowedOrigins)(url)).toEqual(expected);
test.each(cases)('isAllowedRedirect("%s","%s") === %s', (url, allowedOrigins, expected) => {
expect(isAllowedRedirect(allowedOrigins, 'https://www.clerk.com')(url)).toEqual(expected);
expect(warnMock).toHaveBeenCalledTimes(Number(!expected)); // Number(boolean) evaluates to 0 or 1
});
});
Expand Down Expand Up @@ -532,6 +551,6 @@ describe('relativeToAbsoluteUrl', () => {
];

test.each(cases)('relativeToAbsoluteUrl(%s, %s) === %s', (origin, relative, expected) => {
expect(relativeToAbsoluteUrl(relative, origin)).toEqual(expected);
expect(relativeToAbsoluteUrl(relative, origin)).toEqual(new URL(expected));
});
});
14 changes: 9 additions & 5 deletions packages/clerk-js/src/utils/redirectUrls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { camelToSnake } from '@clerk/shared/underscore';
import type { ClerkOptions, RedirectOptions } from '@clerk/types';

import { assertNoLegacyProp, warnForNewPropShadowingLegacyProp } from './assertNoLegacyProp';
import { isAllowedRedirectOrigin, relativeToAbsoluteUrl } from './url';
import { isAllowedRedirect, relativeToAbsoluteUrl } from './url';

export class RedirectUrls {
private static keys: (keyof RedirectOptions)[] = [
Expand Down Expand Up @@ -146,7 +146,9 @@ export class RedirectUrls {
// @ts-expect-error
res[key] = obj[key];
});
return this.#toAbsoluteUrls(this.#filterOrigins(res));
return applyFunctionToObj(this.#filterRedirects(this.#toAbsoluteUrls(filterProps(res, Boolean))), val =>
val.toString(),
);
}

#parseSearchParams(obj: any) {
Expand All @@ -156,14 +158,16 @@ export class RedirectUrls {
res[key] = obj[camelToSnake(key)];
});
res['redirectUrl'] = obj.redirect_url;
return this.#toAbsoluteUrls(this.#filterOrigins(res));
return applyFunctionToObj(this.#filterRedirects(this.#toAbsoluteUrls(filterProps(res, Boolean))), val =>
val.toString(),
);
}

#toAbsoluteUrls(obj: RedirectOptions) {
return applyFunctionToObj(obj, (url: string) => relativeToAbsoluteUrl(url, window.location.origin));
}

#filterOrigins = (obj: RedirectOptions) => {
return filterProps(obj, isAllowedRedirectOrigin(this.options?.allowedRedirectOrigins));
#filterRedirects = (obj: RedirectOptions) => {
return filterProps(obj, isAllowedRedirect(this.options?.allowedRedirectOrigins, window.location.origin));
};
}
70 changes: 38 additions & 32 deletions packages/clerk-js/src/utils/url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,37 +234,38 @@ export function isValidUrl(val: unknown): val is string {
}
}

export function relativeToAbsoluteUrl(url: string, origin: string | URL): string {
if (isValidUrl(url)) {
return url;
export function relativeToAbsoluteUrl(url: string, origin: string | URL): URL {
try {
return new URL(url);
} catch (e) {
return new URL(url, origin);
}
return new URL(url, origin).href;
}

export function isRelativeUrl(val: string): boolean {
if (val !== val && !val) {
return false;
}
// Regular expression to detect disallowed patterns
const disallowedPatterns = [
/\0/, // Null bytes
/^\/\//, // Protocol-relative
// eslint-disable-next-line no-control-regex
/[\x00-\x1F]/, // Control characters
];

if (val.startsWith('//') || val.startsWith('http/') || val.startsWith('https/')) {
// Protocol-relative URL; consider it absolute.
return false;
/**
* Check for potentially problematic URLs that could have been crafted to intentionally bypass the origin check. Note that the URLs passed to this
* function are assumed to be from an "allowed origin", so we are not executing origin-specific checks here.
*/
export function isProblematicUrl(url: URL): boolean {
if (hasBannedProtocol(url)) {
return true;
}

try {
// If this does not throw, it's a valid absolute URL
new URL(val);
return false;
} catch (e) {
try {
// If this does not throw, it's a valid relative URL
new URL(val, DUMMY_URL_BASE);
// Check against disallowed patterns
for (const pattern of disallowedPatterns) {
if (pattern.test(url.pathname)) {
return true;
} catch (e) {
// Invalid URL case
return false;
}
}

return false;
}

export function isDataUri(val?: string): val is string {
Expand Down Expand Up @@ -352,20 +353,25 @@ export function requiresUserInput(redirectUrl: string): boolean {
return frontendApiRedirectPathsWithUserInput.includes(url.pathname);
}

export const isAllowedRedirectOrigin =
(allowedRedirectOrigins: Array<string | RegExp> | undefined) => (_url: string) => {
if (!allowedRedirectOrigins) {
return true;
export const isAllowedRedirect =
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function now deals with full URLs, instead of potentially relative URLs as well. This makes our checks more consistent, and removes situations where we treat a relative URL as okay, make it absolute, and then it ends up being problematic.

(allowedRedirectOrigins: Array<string | RegExp> | undefined, currentOrigin: string) => (_url: URL | string) => {
let url = _url;
if (typeof url === 'string') {
url = relativeToAbsoluteUrl(url, currentOrigin);
}

if (isRelativeUrl(_url)) {
if (!allowedRedirectOrigins) {
return true;
}

const url = new URL(_url, DUMMY_URL_BASE);
const isAllowed = allowedRedirectOrigins
.map(origin => (typeof origin === 'string' ? globs.toRegexp(trimTrailingSlash(origin)) : origin))
.some(origin => origin.test(trimTrailingSlash(url.origin)));
const isSameOrigin = currentOrigin === url.origin;

const isAllowed =
!isProblematicUrl(url) &&
(isSameOrigin ||
allowedRedirectOrigins
.map(origin => (typeof origin === 'string' ? globs.toRegexp(trimTrailingSlash(origin)) : origin))
.some(origin => origin.test(trimTrailingSlash(url.origin))));

if (!isAllowed) {
logger.warnOnce(
Expand Down