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
10 changes: 10 additions & 0 deletions .changeset/windownavigate-protocol-allowlist.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@clerk/clerk-js': patch
'@clerk/react': patch
'@clerk/shared': patch
'@clerk/ui': patch
---

Fix missing redirect URL protocol validation for Clerk UI browser navigations, including the multi-session add-account flow.

Internal browser navigations now consistently honor configured redirect protocols and fail closed across mixed ClerkJS/UI bundle versions.
4 changes: 2 additions & 2 deletions packages/clerk-js/bundlewatch.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@
"files": [
{ "path": "./dist/clerk.js", "maxSize": "549KB" },
{ "path": "./dist/clerk.browser.js", "maxSize": "74KB" },
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "114KB" },
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "116KB" },
{ "path": "./dist/clerk.no-rhc.js", "maxSize": "316KB" },
{ "path": "./dist/clerk.native.js", "maxSize": "72KB" },
{ "path": "./dist/clerk.native.js", "maxSize": "74KB" },
{ "path": "./dist/vendors*.js", "maxSize": "7KB" },
{ "path": "./dist/coinbase*.js", "maxSize": "36KB" },
{ "path": "./dist/base-account-sdk*.js", "maxSize": "207KB" },
Expand Down
21 changes: 18 additions & 3 deletions packages/clerk-js/src/core/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1916,7 +1916,7 @@ export class Clerk implements ClerkInterface {

if (customNavigate) {
debugLogger.info(`Clerk is navigating to: ${to}`);
return await customNavigate(to, { windowNavigate });
return await customNavigate(to, { windowNavigate: this.__internal_windowNavigate });
}

// No window.location and no custom router - can't navigate
Expand Down Expand Up @@ -1955,13 +1955,13 @@ export class Clerk implements ClerkInterface {

// Custom protocol URLs have an origin value of 'null'. In many cases, this indicates deep-linking and we want to ensure the customNavigate function is used if available.
if ((toURL.origin !== 'null' && toURL.origin !== window.location.origin) || !customNavigate) {
windowNavigate(toURL);
this.__internal_windowNavigate(toURL);
return;
}

const metadata = {
...(options?.metadata ? { __internal_metadata: options?.metadata } : {}),
windowNavigate,
windowNavigate: this.__internal_windowNavigate,
};
// React router only wants the path, search or hash portion.
return await customNavigate(stripOrigin(toURL), metadata);
Expand Down Expand Up @@ -3578,6 +3578,21 @@ export class Clerk implements ClerkInterface {
return allowedProtocols;
}

/**
* Primary `window.location.href` navigation chokepoint for `@clerk/clerk-js` and `@clerk/ui`.
* By default the resolved URL is validated against the customer-supplied
* `allowedRedirectProtocols` option (the static `ALLOWED_PROTOCOLS` ∪ the customer extension),
* so internal callers honor customer protocols automatically.
*
* Pass `useStaticAllowlistOnly: true` to opt out of the customer extension when a call site
* must reject any protocol the customer added. There is no current internal consumer of the
* opt-out; it exists for future security-critical paths.
*/
public __internal_windowNavigate = (to: URL | string, opts?: { useStaticAllowlistOnly?: boolean }): void => {
const allowedProtocols = opts?.useStaticAllowlistOnly ? ALLOWED_PROTOCOLS : this.#allowedRedirectProtocols;
windowNavigate(to, { allowedProtocols });
};

#isLoaded(): this is LoadedClerk {
return this.client !== undefined;
}
Expand Down
5 changes: 2 additions & 3 deletions packages/clerk-js/src/core/resources/SignIn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import {
} from '@clerk/shared/internal/clerk-js/passkeys';
import { createValidatePassword } from '@clerk/shared/internal/clerk-js/passwords/password';
import { getClerkQueryParam } from '@clerk/shared/internal/clerk-js/queryParams';
import { windowNavigate } from '@clerk/shared/internal/clerk-js/windowNavigate';
import { Poller } from '@clerk/shared/poller';
import type {
AttemptFirstFactorParams,
Expand Down Expand Up @@ -392,7 +391,7 @@ export class SignIn extends BaseResource implements SignInResource {
});
}

return this.authenticateWithRedirectOrPopup(params, windowNavigate);
return this.authenticateWithRedirectOrPopup(params, SignIn.clerk.__internal_windowNavigate);
};

public authenticateWithPopup = async (params: AuthenticateWithPopupParams): Promise<void> => {
Expand Down Expand Up @@ -1199,7 +1198,7 @@ class SignInFuture implements SignInFutureResource {
// Pick up the modified SignIn resource
await this.#resource.reload();
} else {
windowNavigate(externalVerificationRedirectURL);
SignIn.clerk.__internal_windowNavigate(externalVerificationRedirectURL);
}
}
});
Expand Down
5 changes: 2 additions & 3 deletions packages/clerk-js/src/core/resources/SignUp.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { inBrowser } from '@clerk/shared/browser';
import { type ClerkError, ClerkRuntimeError, isCaptchaError, isClerkAPIResponseError } from '@clerk/shared/error';
import { createValidatePassword } from '@clerk/shared/internal/clerk-js/passwords/password';
import { windowNavigate } from '@clerk/shared/internal/clerk-js/windowNavigate';
import { Poller } from '@clerk/shared/poller';
import type {
AttemptEmailAddressVerificationParams,
Expand Down Expand Up @@ -462,7 +461,7 @@ export class SignUp extends BaseResource implements SignUpResource {
});
}

return this.authenticateWithRedirectOrPopup(params, windowNavigate);
return this.authenticateWithRedirectOrPopup(params, SignUp.clerk.__internal_windowNavigate);
};

public authenticateWithPopup = async (
Expand Down Expand Up @@ -1082,7 +1081,7 @@ class SignUpFuture implements SignUpFutureResource {
// Pick up the modified SignUp resource
await this.#resource.reload();
} else {
windowNavigate(externalVerificationRedirectURL);
SignUp.clerk.__internal_windowNavigate(externalVerificationRedirectURL);
}
}
});
Expand Down
4 changes: 4 additions & 0 deletions packages/react/src/isomorphicClerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,10 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk {
return this.clerkjs?.__internal_getOption ? this.clerkjs?.__internal_getOption(key) : this.options[key];
}

public __internal_windowNavigate: Clerk['__internal_windowNavigate'] = (to, opts) => {
this.clerkjs?.__internal_windowNavigate?.(to, opts);
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

constructor(options: IsomorphicClerkOptions) {
this.#publishableKey = options?.publishableKey;
this.#proxyUrl = options?.proxyUrl;
Expand Down
124 changes: 124 additions & 0 deletions packages/shared/src/internal/clerk-js/__tests__/windowNavigate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { ALLOWED_PROTOCOLS, CLERK_BEFORE_UNLOAD_EVENT, windowNavigate } from '../windowNavigate';

describe('windowNavigate', () => {
let originalLocation: Location;
let hrefSetter: ReturnType<typeof vi.fn>;
let warnSpy: ReturnType<typeof vi.spyOn>;
let eventSpy: ReturnType<typeof vi.fn>;

beforeEach(() => {
originalLocation = window.location;
hrefSetter = vi.fn();
Object.defineProperty(window, 'location', {
configurable: true,
value: new Proxy(
{ href: 'https://example.com/' },
{
set: (target, prop, value) => {
if (prop === 'href') {
hrefSetter(value);
(target as any).href = value;
return true;
}
(target as any)[prop] = value;
return true;
},
},
),
});
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
eventSpy = vi.fn();
window.addEventListener(CLERK_BEFORE_UNLOAD_EVENT, eventSpy);
});

afterEach(() => {
window.removeEventListener(CLERK_BEFORE_UNLOAD_EVENT, eventSpy);
Object.defineProperty(window, 'location', {
configurable: true,
value: originalLocation,
});
warnSpy.mockRestore();
});

it.each([
['absolute https URL', 'https://example.com/dashboard'],
['absolute http URL', 'http://example.com/dashboard'],
['relative path', '/sign-in'],
['wails protocol', 'wails://app/route'],
['chrome-extension protocol', 'chrome-extension://abc/route'],
])('navigates to %s', (_label, to) => {
windowNavigate(to);
expect(hrefSetter).toHaveBeenCalledTimes(1);
expect(eventSpy).toHaveBeenCalledTimes(1);
expect(warnSpy).not.toHaveBeenCalled();
});

it.each([
['javascript', 'javascript:alert(1)'],
['data', 'data:text/html,<script>alert(1)</script>'],
['file', 'file:///etc/passwd'],
['vbscript', 'vbscript:msgbox(1)'],
['mixed-case JavaScript', 'JavaScript:alert(1)'],
['upper-case JAVASCRIPT', 'JAVASCRIPT:alert(1)'],
['leading-whitespace javascript', ' javascript:alert(1)'],
['leading-tab javascript', '\tjavascript:alert(1)'],
['leading-newline javascript', '\njavascript:alert(1)'],
])('blocks %s: protocol and does not navigate', (_label, to) => {
windowNavigate(to);
expect(hrefSetter).not.toHaveBeenCalled();
expect(eventSpy).not.toHaveBeenCalled();
expect(warnSpy).toHaveBeenCalledTimes(1);
});

it('blocks javascript: URLs that the URL parser normalizes via the base URL', () => {
windowNavigate('javascript:alert(location.origin)//');
expect(hrefSetter).not.toHaveBeenCalled();
expect(eventSpy).not.toHaveBeenCalled();
expect(warnSpy).toHaveBeenCalledTimes(1);
});

it.each([
['scheme-relative //host', '//evil.example/path'],
['scheme-relative ///host', '///evil.example/path'],
['backslash \\\\host', '\\\\evil.example\\path'],
['mixed /\\host', '/\\evil.example/path'],
['mixed \\/host', '\\/evil.example/path'],
['leading-whitespace scheme-relative', ' //evil.example/path'],
['leading-tab scheme-relative', '\t//evil.example/path'],
])('blocks %s and does not navigate', (_label, to) => {
windowNavigate(to);
expect(hrefSetter).not.toHaveBeenCalled();
expect(eventSpy).not.toHaveBeenCalled();
expect(warnSpy).toHaveBeenCalledTimes(1);
});

it('still rejects scheme-relative URLs when an extended allowlist is supplied', () => {
windowNavigate('//evil.example/path', {
allowedProtocols: [...ALLOWED_PROTOCOLS, 'slack:'],
});
expect(hrefSetter).not.toHaveBeenCalled();
expect(eventSpy).not.toHaveBeenCalled();
expect(warnSpy).toHaveBeenCalledTimes(1);
});

it('honors a caller-supplied extended allowlist for custom protocols', () => {
windowNavigate('slack://channel/123', {
allowedProtocols: [...ALLOWED_PROTOCOLS, 'slack:'],
});
expect(hrefSetter).toHaveBeenCalledTimes(1);
expect(hrefSetter).toHaveBeenCalledWith('slack://channel/123');
expect(eventSpy).toHaveBeenCalledTimes(1);
expect(warnSpy).not.toHaveBeenCalled();
});

it('still rejects disallowed protocols when an extended allowlist is supplied', () => {
windowNavigate('javascript:alert(1)', {
allowedProtocols: [...ALLOWED_PROTOCOLS, 'slack:'],
});
expect(hrefSetter).not.toHaveBeenCalled();
expect(eventSpy).not.toHaveBeenCalled();
expect(warnSpy).toHaveBeenCalledTimes(1);
});
});
39 changes: 37 additions & 2 deletions packages/shared/src/internal/clerk-js/windowNavigate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,48 @@ export const ALLOWED_PROTOCOLS = [
'chrome-extension:',
];

export type WindowNavigateOptions = {
/**
* Protocol allowlist applied to the resolved URL. Defaults to `ALLOWED_PROTOCOLS`. Pass an
* extended list (e.g. `Clerk`'s `#allowedRedirectProtocols`) to honor the customer-supplied
* `allowedRedirectProtocols` option.
*/
allowedProtocols?: ReadonlyArray<string>;
};

const SCHEME_RELATIVE_PREFIX = /^[/\\][/\\]/;

/**
* Helper utility to navigate via window.location.href. Also dispatches a clerk:beforeunload custom event.
*
* Note that this utility should **never** be called with a user-provided URL. We make no specific checks against the contents of the URL here and assume it is safe. Use `Clerk.navigate()` instead for user-provided URLs.
* Navigations whose protocol is not in the allowlist (e.g. `javascript:`, `data:`) are aborted.
* Scheme-relative inputs (`//host`, `\\host`) are also rejected: they adopt the base URL's scheme,
* which is always in the allowlist, so they would otherwise pass the protocol check while
* redirecting cross-origin.
*
* Callers that have already validated against an extended allowlist should pass it via
* `options.allowedProtocols` so legitimate custom protocols (Wails, Tauri, etc.) are honored.
*
* @deprecated Use `clerk.__internal_windowNavigate` instead. It honors the customer-supplied
* `allowedRedirectProtocols` option by default, so internal call sites can't accidentally
* bypass it by forgetting to pass `options.allowedProtocols`. The bare export will be removed
* in the next major version.
*/
export function windowNavigate(to: URL | string): void {
export function windowNavigate(to: URL | string, options?: WindowNavigateOptions): void {
if (typeof to === 'string' && SCHEME_RELATIVE_PREFIX.test(to.trim())) {
console.warn(
`Clerk: scheme-relative navigation to "${to}" is not allowed. Provide a same-origin path or an absolute URL.`,
);
return;
}
const toURL = new URL(to, window.location.href);
const allowedProtocols = options?.allowedProtocols ?? ALLOWED_PROTOCOLS;
if (!allowedProtocols.includes(toURL.protocol)) {
console.warn(
`Clerk: "${toURL.protocol}" is not a valid navigation protocol. Aborting navigation. If you think this is a mistake, please open an issue.`,
);
return;
}
window.dispatchEvent(new CustomEvent(CLERK_BEFORE_UNLOAD_EVENT));
window.location.href = toURL.href;
}
10 changes: 10 additions & 0 deletions packages/shared/src/types/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,16 @@ export interface Clerk {
*/
__internal_getOption<K extends keyof ClerkOptions>(key: K): ClerkOptions[K];

/**
* @internal
* Primary `window.location.href` navigation chokepoint for `@clerk/clerk-js` and `@clerk/ui`.
* By default the resolved URL is validated against the customer-supplied
* `allowedRedirectProtocols` option (static defaults ∪ the customer extension).
* Disallowed protocols and scheme-relative inputs (`//host`) are rejected with a console warning.
* Pass `useStaticAllowlistOnly: true` to opt out of the customer extension.
*/
__internal_windowNavigate: (to: URL | string, opts?: { useStaticAllowlistOnly?: boolean }) => void;

frontendApi: string;

/** Your Clerk [Publishable Key](!publishable-key). */
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { navigateIfTaskExists } from '@clerk/shared/internal/clerk-js/sessionTasks';
import { windowNavigate } from '@clerk/shared/internal/clerk-js/windowNavigate';
import { useClerk, usePortalRoot } from '@clerk/shared/react';
import type { SignedInSessionResource, UserButtonProps, UserResource } from '@clerk/shared/types';

import { useEnvironment } from '@/ui/contexts';
import { useCardState } from '@/ui/elements/contexts';
import { sleep } from '@/ui/utils/sleep';
import { clerkWindowNavigate } from '@/ui/utils/windowNavigate';

import { useMultipleSessions } from '../../hooks/useMultipleSessions';
import { useRouter } from '../../router';
Expand All @@ -22,7 +22,8 @@ type UseMultisessionActionsParams = {
} & Pick<UserButtonProps, 'userProfileMode' | 'appearance' | 'userProfileProps'>;

export const useMultisessionActions = (opts: UseMultisessionActionsParams) => {
const { setActive, signOut, openUserProfile } = useClerk();
const clerk = useClerk();
const { setActive, signOut, openUserProfile } = clerk;
const card = useCardState();
const { signedInSessions, otherSessions } = useMultipleSessions({ user: opts.user });
const { navigate } = useRouter();
Expand Down Expand Up @@ -101,7 +102,7 @@ export const useMultisessionActions = (opts: UseMultisessionActionsParams) => {
};

const handleAddAccountClicked = () => {
windowNavigate(opts.signInUrl || window.location.href);
clerkWindowNavigate(clerk, opts.signInUrl || window.location.href);
return sleep(2000);
};

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { appendModalState } from '@clerk/shared/internal/clerk-js/queryStateParams';
import { windowNavigate } from '@clerk/shared/internal/clerk-js/windowNavigate';
import { __internal_useUserEnterpriseConnections, useReverification, useUser } from '@clerk/shared/react';
import { __internal_useUserEnterpriseConnections, useClerk, useReverification, useUser } from '@clerk/shared/react';
import type { EnterpriseAccountResource, EnterpriseConnectionResource, OAuthProvider } from '@clerk/shared/types';
import { Fragment, useState } from 'react';

Expand All @@ -9,6 +8,7 @@ import { useCardState, withCardStateProvider } from '@/ui/elements/contexts';
import { ProfileSection } from '@/ui/elements/Section';
import { handleError } from '@/ui/utils/errorHandler';
import { sleep } from '@/ui/utils/sleep';
import { clerkWindowNavigate } from '@/ui/utils/windowNavigate';

import { ProviderIcon } from '../../common';
import { useUserProfileContext } from '../../contexts';
Expand All @@ -17,6 +17,7 @@ import { Action } from '../../elements/Action';
const EnterpriseConnectMenuButton = (props: { connection: EnterpriseConnectionResource }) => {
const { connection } = props;
const card = useCardState();
const clerk = useClerk();
const { user } = useUser();
const { componentName, mode } = useUserProfileContext();
const isModal = mode === 'modal';
Expand All @@ -42,7 +43,7 @@ const EnterpriseConnectMenuButton = (props: { connection: EnterpriseConnectionRe
.then(res => {
if (res?.verification?.externalVerificationRedirectURL) {
void sleep(2000).then(() => card.setIdle(loadingKey));
windowNavigate(res.verification.externalVerificationRedirectURL);
clerkWindowNavigate(clerk, res.verification.externalVerificationRedirectURL);
}
})
.catch(err => {
Expand Down
Loading
Loading