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
10 changes: 10 additions & 0 deletions .changeset/windownavigate-protocol-allowlist.md
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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": "115KB" },
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "116KB" },
{ "path": "./dist/clerk.no-rhc.js", "maxSize": "316KB" },
{ "path": "./dist/clerk.native.js", "maxSize": "73KB" },
{ "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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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
90 changes: 90 additions & 0 deletions packages/react/src/__tests__/isomorphicClerk.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -434,4 +434,94 @@ describe('isomorphicClerk', () => {
);
});
});

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

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)[prop] = value;
return true;
},
},
),
});
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
});

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

it('delegates to clerk-js when the navigation chokepoint is available', () => {
const navigate = vi.fn();
const isomorphicClerk = new IsomorphicClerk({ publishableKey: 'pk_test_XXX' });
(isomorphicClerk as any).clerkjs = { __internal_windowNavigate: navigate };

isomorphicClerk.__internal_windowNavigate('/sign-in', { useStaticAllowlistOnly: true });

expect(navigate).toHaveBeenCalledWith('/sign-in', { useStaticAllowlistOnly: true });
expect(hrefSetter).not.toHaveBeenCalled();
});

it('falls back to navigation when clerk-js is older and lacks the chokepoint', () => {
const isomorphicClerk = new IsomorphicClerk({ publishableKey: 'pk_test_XXX' });
// An older clerk-js that does not expose __internal_windowNavigate.
(isomorphicClerk as any).clerkjs = {};

isomorphicClerk.__internal_windowNavigate('/sign-in');

expect(hrefSetter).toHaveBeenCalledWith('https://example.com/sign-in');
expect(warnSpy).not.toHaveBeenCalled();
});

it('fallback honors the customer-supplied allowedRedirectProtocols option', () => {
const isomorphicClerk = new IsomorphicClerk({
publishableKey: 'pk_test_XXX',
allowedRedirectProtocols: ['slack:'],
});
(isomorphicClerk as any).clerkjs = {};

isomorphicClerk.__internal_windowNavigate('slack://channel/123');

expect(hrefSetter).toHaveBeenCalledWith('slack://channel/123');
expect(warnSpy).not.toHaveBeenCalled();
});

it('fallback fails closed for disallowed protocols', () => {
const isomorphicClerk = new IsomorphicClerk({ publishableKey: 'pk_test_XXX' });
(isomorphicClerk as any).clerkjs = {};

isomorphicClerk.__internal_windowNavigate('javascript:alert(1)');

expect(hrefSetter).not.toHaveBeenCalled();
expect(warnSpy).toHaveBeenCalledTimes(1);
});

it('fallback ignores customer protocols when useStaticAllowlistOnly is set', () => {
const isomorphicClerk = new IsomorphicClerk({
publishableKey: 'pk_test_XXX',
allowedRedirectProtocols: ['slack:'],
});
(isomorphicClerk as any).clerkjs = {};

isomorphicClerk.__internal_windowNavigate('slack://channel/123', { useStaticAllowlistOnly: true });

expect(hrefSetter).not.toHaveBeenCalled();
expect(warnSpy).toHaveBeenCalledTimes(1);
});
});
});
20 changes: 20 additions & 0 deletions packages/react/src/isomorphicClerk.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
import { inBrowser } from '@clerk/shared/browser';
import { clerkEvents, createClerkEventBus } from '@clerk/shared/clerkEventBus';
import { ALLOWED_PROTOCOLS, windowNavigate } from '@clerk/shared/internal/clerk-js/windowNavigate';
import { loadClerkJSScript, loadClerkUIScript } from '@clerk/shared/loadClerkJsScript';
import type {
__internal_AttemptToEnableEnvironmentSettingParams,
Expand DownExpand Up@@ -265,6 +266,25 @@ 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) => {
const clerkjs = this.clerkjs;
if (typeof clerkjs?.__internal_windowNavigate === 'function') {
clerkjs.__internal_windowNavigate(to, opts);
return;
}

// Older clerk-js lacks this navigation chokepoint, so delegating would silently no-op. Fall back to
// the shared helper (browser-only, it touches window.location) so newer @clerk/ui still navigates and
// rejects disallowed protocols on an older clerk-js, honoring allowedRedirectProtocols unless opted out.
if (!inBrowser()) {
return;
}
const allowedProtocols = opts?.useStaticAllowlistOnly
? ALLOWED_PROTOCOLS
: [...ALLOWED_PROTOCOLS, ...(this.options.allowedRedirectProtocols ?? [])];
windowNavigate(to, { allowedProtocols });
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

constructor(options: IsomorphicClerkOptions) {
this.#publishableKey = options?.publishableKey;
this.#proxyUrl = options?.proxyUrl;
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
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'],
// Control characters the URL parser strips but `trim()` does not: each still resolves
// scheme-relative against the base origin, so the guard must normalize before testing.
['interior-tab scheme-relative', '/\t/evil.example/path'],
['interior-newline scheme-relative', '/\n/evil.example/path'],
['interior-cr scheme-relative', '/\r/evil.example/path'],
['interior-tab backslash', '\\\t\\evil.example/path'],
['leading-null scheme-relative', '\x00//evil.example/path'],
['leading-c0-controls scheme-relative', '\x01\x02//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);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
fix(clerk-js): validate window navigation protocols and honor allowedRedirectProtocols by jacekradko · Pull Request #8961 · clerk/javascript · GitHub
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
10 changes: 10 additions & 0 deletions .changeset/windownavigate-protocol-allowlist.md
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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": "115KB" },
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "116KB" },
{ "path": "./dist/clerk.no-rhc.js", "maxSize": "316KB" },
{ "path": "./dist/clerk.native.js", "maxSize": "73KB" },
{ "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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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
90 changes: 90 additions & 0 deletions packages/react/src/__tests__/isomorphicClerk.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -434,4 +434,94 @@ describe('isomorphicClerk', () => {
);
});
});

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

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)[prop] = value;
return true;
},
},
),
});
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
});

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

it('delegates to clerk-js when the navigation chokepoint is available', () => {
const navigate = vi.fn();
const isomorphicClerk = new IsomorphicClerk({ publishableKey: 'pk_test_XXX' });
(isomorphicClerk as any).clerkjs = { __internal_windowNavigate: navigate };

isomorphicClerk.__internal_windowNavigate('/sign-in', { useStaticAllowlistOnly: true });

expect(navigate).toHaveBeenCalledWith('/sign-in', { useStaticAllowlistOnly: true });
expect(hrefSetter).not.toHaveBeenCalled();
});

it('falls back to navigation when clerk-js is older and lacks the chokepoint', () => {
const isomorphicClerk = new IsomorphicClerk({ publishableKey: 'pk_test_XXX' });
// An older clerk-js that does not expose __internal_windowNavigate.
(isomorphicClerk as any).clerkjs = {};

isomorphicClerk.__internal_windowNavigate('/sign-in');

expect(hrefSetter).toHaveBeenCalledWith('https://example.com/sign-in');
expect(warnSpy).not.toHaveBeenCalled();
});

it('fallback honors the customer-supplied allowedRedirectProtocols option', () => {
const isomorphicClerk = new IsomorphicClerk({
publishableKey: 'pk_test_XXX',
allowedRedirectProtocols: ['slack:'],
});
(isomorphicClerk as any).clerkjs = {};

isomorphicClerk.__internal_windowNavigate('slack://channel/123');

expect(hrefSetter).toHaveBeenCalledWith('slack://channel/123');
expect(warnSpy).not.toHaveBeenCalled();
});

it('fallback fails closed for disallowed protocols', () => {
const isomorphicClerk = new IsomorphicClerk({ publishableKey: 'pk_test_XXX' });
(isomorphicClerk as any).clerkjs = {};

isomorphicClerk.__internal_windowNavigate('javascript:alert(1)');

expect(hrefSetter).not.toHaveBeenCalled();
expect(warnSpy).toHaveBeenCalledTimes(1);
});

it('fallback ignores customer protocols when useStaticAllowlistOnly is set', () => {
const isomorphicClerk = new IsomorphicClerk({
publishableKey: 'pk_test_XXX',
allowedRedirectProtocols: ['slack:'],
});
(isomorphicClerk as any).clerkjs = {};

isomorphicClerk.__internal_windowNavigate('slack://channel/123', { useStaticAllowlistOnly: true });

expect(hrefSetter).not.toHaveBeenCalled();
expect(warnSpy).toHaveBeenCalledTimes(1);
});
});
});
20 changes: 20 additions & 0 deletions packages/react/src/isomorphicClerk.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
import { inBrowser } from '@clerk/shared/browser';
import { clerkEvents, createClerkEventBus } from '@clerk/shared/clerkEventBus';
import { ALLOWED_PROTOCOLS, windowNavigate } from '@clerk/shared/internal/clerk-js/windowNavigate';
import { loadClerkJSScript, loadClerkUIScript } from '@clerk/shared/loadClerkJsScript';
import type {
__internal_AttemptToEnableEnvironmentSettingParams,
Expand DownExpand Up@@ -265,6 +266,25 @@ 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) => {
const clerkjs = this.clerkjs;
if (typeof clerkjs?.__internal_windowNavigate === 'function') {
clerkjs.__internal_windowNavigate(to, opts);
return;
}

// Older clerk-js lacks this navigation chokepoint, so delegating would silently no-op. Fall back to
// the shared helper (browser-only, it touches window.location) so newer @clerk/ui still navigates and
// rejects disallowed protocols on an older clerk-js, honoring allowedRedirectProtocols unless opted out.
if (!inBrowser()) {
return;
}
const allowedProtocols = opts?.useStaticAllowlistOnly
? ALLOWED_PROTOCOLS
: [...ALLOWED_PROTOCOLS, ...(this.options.allowedRedirectProtocols ?? [])];
windowNavigate(to, { allowedProtocols });
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

constructor(options: IsomorphicClerkOptions) {
this.#publishableKey = options?.publishableKey;
this.#proxyUrl = options?.proxyUrl;
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
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'],
// Control characters the URL parser strips but `trim()` does not: each still resolves
// scheme-relative against the base origin, so the guard must normalize before testing.
['interior-tab scheme-relative', '/\t/evil.example/path'],
['interior-newline scheme-relative', '/\n/evil.example/path'],
['interior-cr scheme-relative', '/\r/evil.example/path'],
['interior-tab backslash', '\\\t\\evil.example/path'],
['leading-null scheme-relative', '\x00//evil.example/path'],
['leading-c0-controls scheme-relative', '\x01\x02//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);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(clerk-js): validate window navigation protocols and honor allowedRedirectProtocols by jacekradko · Pull Request #8961 · clerk/javascript · GitHub
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
10 changes: 10 additions & 0 deletions .changeset/windownavigate-protocol-allowlist.md
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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": "115KB" },
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "116KB" },
{ "path": "./dist/clerk.no-rhc.js", "maxSize": "316KB" },
{ "path": "./dist/clerk.native.js", "maxSize": "73KB" },
{ "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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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
90 changes: 90 additions & 0 deletions packages/react/src/__tests__/isomorphicClerk.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -434,4 +434,94 @@ describe('isomorphicClerk', () => {
);
});
});

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

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)[prop] = value;
return true;
},
},
),
});
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
});

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

it('delegates to clerk-js when the navigation chokepoint is available', () => {
const navigate = vi.fn();
const isomorphicClerk = new IsomorphicClerk({ publishableKey: 'pk_test_XXX' });
(isomorphicClerk as any).clerkjs = { __internal_windowNavigate: navigate };

isomorphicClerk.__internal_windowNavigate('/sign-in', { useStaticAllowlistOnly: true });

expect(navigate).toHaveBeenCalledWith('/sign-in', { useStaticAllowlistOnly: true });
expect(hrefSetter).not.toHaveBeenCalled();
});

it('falls back to navigation when clerk-js is older and lacks the chokepoint', () => {
const isomorphicClerk = new IsomorphicClerk({ publishableKey: 'pk_test_XXX' });
// An older clerk-js that does not expose __internal_windowNavigate.
(isomorphicClerk as any).clerkjs = {};

isomorphicClerk.__internal_windowNavigate('/sign-in');

expect(hrefSetter).toHaveBeenCalledWith('https://example.com/sign-in');
expect(warnSpy).not.toHaveBeenCalled();
});

it('fallback honors the customer-supplied allowedRedirectProtocols option', () => {
const isomorphicClerk = new IsomorphicClerk({
publishableKey: 'pk_test_XXX',
allowedRedirectProtocols: ['slack:'],
});
(isomorphicClerk as any).clerkjs = {};

isomorphicClerk.__internal_windowNavigate('slack://channel/123');

expect(hrefSetter).toHaveBeenCalledWith('slack://channel/123');
expect(warnSpy).not.toHaveBeenCalled();
});

it('fallback fails closed for disallowed protocols', () => {
const isomorphicClerk = new IsomorphicClerk({ publishableKey: 'pk_test_XXX' });
(isomorphicClerk as any).clerkjs = {};

isomorphicClerk.__internal_windowNavigate('javascript:alert(1)');

expect(hrefSetter).not.toHaveBeenCalled();
expect(warnSpy).toHaveBeenCalledTimes(1);
});

it('fallback ignores customer protocols when useStaticAllowlistOnly is set', () => {
const isomorphicClerk = new IsomorphicClerk({
publishableKey: 'pk_test_XXX',
allowedRedirectProtocols: ['slack:'],
});
(isomorphicClerk as any).clerkjs = {};

isomorphicClerk.__internal_windowNavigate('slack://channel/123', { useStaticAllowlistOnly: true });

expect(hrefSetter).not.toHaveBeenCalled();
expect(warnSpy).toHaveBeenCalledTimes(1);
});
});
});
20 changes: 20 additions & 0 deletions packages/react/src/isomorphicClerk.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
import { inBrowser } from '@clerk/shared/browser';
import { clerkEvents, createClerkEventBus } from '@clerk/shared/clerkEventBus';
import { ALLOWED_PROTOCOLS, windowNavigate } from '@clerk/shared/internal/clerk-js/windowNavigate';
import { loadClerkJSScript, loadClerkUIScript } from '@clerk/shared/loadClerkJsScript';
import type {
__internal_AttemptToEnableEnvironmentSettingParams,
Expand DownExpand Up@@ -265,6 +266,25 @@ 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) => {
const clerkjs = this.clerkjs;
if (typeof clerkjs?.__internal_windowNavigate === 'function') {
clerkjs.__internal_windowNavigate(to, opts);
return;
}

// Older clerk-js lacks this navigation chokepoint, so delegating would silently no-op. Fall back to
// the shared helper (browser-only, it touches window.location) so newer @clerk/ui still navigates and
// rejects disallowed protocols on an older clerk-js, honoring allowedRedirectProtocols unless opted out.
if (!inBrowser()) {
return;
}
const allowedProtocols = opts?.useStaticAllowlistOnly
? ALLOWED_PROTOCOLS
: [...ALLOWED_PROTOCOLS, ...(this.options.allowedRedirectProtocols ?? [])];
windowNavigate(to, { allowedProtocols });
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

constructor(options: IsomorphicClerkOptions) {
this.#publishableKey = options?.publishableKey;
this.#proxyUrl = options?.proxyUrl;
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
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'],
// Control characters the URL parser strips but `trim()` does not: each still resolves
// scheme-relative against the base origin, so the guard must normalize before testing.
['interior-tab scheme-relative', '/\t/evil.example/path'],
['interior-newline scheme-relative', '/\n/evil.example/path'],
['interior-cr scheme-relative', '/\r/evil.example/path'],
['interior-tab backslash', '\\\t\\evil.example/path'],
['leading-null scheme-relative', '\x00//evil.example/path'],
['leading-c0-controls scheme-relative', '\x01\x02//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);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(clerk-js): validate window navigation protocols and honor allowedRedirectProtocols by jacekradko · Pull Request #8961 · clerk/javascript · GitHub
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
10 changes: 10 additions & 0 deletions .changeset/windownavigate-protocol-allowlist.md
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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": "115KB" },
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "116KB" },
{ "path": "./dist/clerk.no-rhc.js", "maxSize": "316KB" },
{ "path": "./dist/clerk.native.js", "maxSize": "73KB" },
{ "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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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
90 changes: 90 additions & 0 deletions packages/react/src/__tests__/isomorphicClerk.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -434,4 +434,94 @@ describe('isomorphicClerk', () => {
);
});
});

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

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)[prop] = value;
return true;
},
},
),
});
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
});

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

it('delegates to clerk-js when the navigation chokepoint is available', () => {
const navigate = vi.fn();
const isomorphicClerk = new IsomorphicClerk({ publishableKey: 'pk_test_XXX' });
(isomorphicClerk as any).clerkjs = { __internal_windowNavigate: navigate };

isomorphicClerk.__internal_windowNavigate('/sign-in', { useStaticAllowlistOnly: true });

expect(navigate).toHaveBeenCalledWith('/sign-in', { useStaticAllowlistOnly: true });
expect(hrefSetter).not.toHaveBeenCalled();
});

it('falls back to navigation when clerk-js is older and lacks the chokepoint', () => {
const isomorphicClerk = new IsomorphicClerk({ publishableKey: 'pk_test_XXX' });
// An older clerk-js that does not expose __internal_windowNavigate.
(isomorphicClerk as any).clerkjs = {};

isomorphicClerk.__internal_windowNavigate('/sign-in');

expect(hrefSetter).toHaveBeenCalledWith('https://example.com/sign-in');
expect(warnSpy).not.toHaveBeenCalled();
});

it('fallback honors the customer-supplied allowedRedirectProtocols option', () => {
const isomorphicClerk = new IsomorphicClerk({
publishableKey: 'pk_test_XXX',
allowedRedirectProtocols: ['slack:'],
});
(isomorphicClerk as any).clerkjs = {};

isomorphicClerk.__internal_windowNavigate('slack://channel/123');

expect(hrefSetter).toHaveBeenCalledWith('slack://channel/123');
expect(warnSpy).not.toHaveBeenCalled();
});

it('fallback fails closed for disallowed protocols', () => {
const isomorphicClerk = new IsomorphicClerk({ publishableKey: 'pk_test_XXX' });
(isomorphicClerk as any).clerkjs = {};

isomorphicClerk.__internal_windowNavigate('javascript:alert(1)');

expect(hrefSetter).not.toHaveBeenCalled();
expect(warnSpy).toHaveBeenCalledTimes(1);
});

it('fallback ignores customer protocols when useStaticAllowlistOnly is set', () => {
const isomorphicClerk = new IsomorphicClerk({
publishableKey: 'pk_test_XXX',
allowedRedirectProtocols: ['slack:'],
});
(isomorphicClerk as any).clerkjs = {};

isomorphicClerk.__internal_windowNavigate('slack://channel/123', { useStaticAllowlistOnly: true });

expect(hrefSetter).not.toHaveBeenCalled();
expect(warnSpy).toHaveBeenCalledTimes(1);
});
});
});
20 changes: 20 additions & 0 deletions packages/react/src/isomorphicClerk.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
import { inBrowser } from '@clerk/shared/browser';
import { clerkEvents, createClerkEventBus } from '@clerk/shared/clerkEventBus';
import { ALLOWED_PROTOCOLS, windowNavigate } from '@clerk/shared/internal/clerk-js/windowNavigate';
import { loadClerkJSScript, loadClerkUIScript } from '@clerk/shared/loadClerkJsScript';
import type {
__internal_AttemptToEnableEnvironmentSettingParams,
Expand DownExpand Up@@ -265,6 +266,25 @@ 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) => {
const clerkjs = this.clerkjs;
if (typeof clerkjs?.__internal_windowNavigate === 'function') {
clerkjs.__internal_windowNavigate(to, opts);
return;
}

// Older clerk-js lacks this navigation chokepoint, so delegating would silently no-op. Fall back to
// the shared helper (browser-only, it touches window.location) so newer @clerk/ui still navigates and
// rejects disallowed protocols on an older clerk-js, honoring allowedRedirectProtocols unless opted out.
if (!inBrowser()) {
return;
}
const allowedProtocols = opts?.useStaticAllowlistOnly
? ALLOWED_PROTOCOLS
: [...ALLOWED_PROTOCOLS, ...(this.options.allowedRedirectProtocols ?? [])];
windowNavigate(to, { allowedProtocols });
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

constructor(options: IsomorphicClerkOptions) {
this.#publishableKey = options?.publishableKey;
this.#proxyUrl = options?.proxyUrl;
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
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'],
// Control characters the URL parser strips but `trim()` does not: each still resolves
// scheme-relative against the base origin, so the guard must normalize before testing.
['interior-tab scheme-relative', '/\t/evil.example/path'],
['interior-newline scheme-relative', '/\n/evil.example/path'],
['interior-cr scheme-relative', '/\r/evil.example/path'],
['interior-tab backslash', '\\\t\\evil.example/path'],
['leading-null scheme-relative', '\x00//evil.example/path'],
['leading-c0-controls scheme-relative', '\x01\x02//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);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' fix(clerk-js): validate window navigation protocols and honor allowedRedirectProtocols by jacekradko · Pull Request #8961 · clerk/javascript · GitHub
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
10 changes: 10 additions & 0 deletions .changeset/windownavigate-protocol-allowlist.md
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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": "115KB" },
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "116KB" },
{ "path": "./dist/clerk.no-rhc.js", "maxSize": "316KB" },
{ "path": "./dist/clerk.native.js", "maxSize": "73KB" },
{ "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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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
90 changes: 90 additions & 0 deletions packages/react/src/__tests__/isomorphicClerk.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -434,4 +434,94 @@ describe('isomorphicClerk', () => {
);
});
});

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

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)[prop] = value;
return true;
},
},
),
});
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
});

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

it('delegates to clerk-js when the navigation chokepoint is available', () => {
const navigate = vi.fn();
const isomorphicClerk = new IsomorphicClerk({ publishableKey: 'pk_test_XXX' });
(isomorphicClerk as any).clerkjs = { __internal_windowNavigate: navigate };

isomorphicClerk.__internal_windowNavigate('/sign-in', { useStaticAllowlistOnly: true });

expect(navigate).toHaveBeenCalledWith('/sign-in', { useStaticAllowlistOnly: true });
expect(hrefSetter).not.toHaveBeenCalled();
});

it('falls back to navigation when clerk-js is older and lacks the chokepoint', () => {
const isomorphicClerk = new IsomorphicClerk({ publishableKey: 'pk_test_XXX' });
// An older clerk-js that does not expose __internal_windowNavigate.
(isomorphicClerk as any).clerkjs = {};

isomorphicClerk.__internal_windowNavigate('/sign-in');

expect(hrefSetter).toHaveBeenCalledWith('https://example.com/sign-in');
expect(warnSpy).not.toHaveBeenCalled();
});

it('fallback honors the customer-supplied allowedRedirectProtocols option', () => {
const isomorphicClerk = new IsomorphicClerk({
publishableKey: 'pk_test_XXX',
allowedRedirectProtocols: ['slack:'],
});
(isomorphicClerk as any).clerkjs = {};

isomorphicClerk.__internal_windowNavigate('slack://channel/123');

expect(hrefSetter).toHaveBeenCalledWith('slack://channel/123');
expect(warnSpy).not.toHaveBeenCalled();
});

it('fallback fails closed for disallowed protocols', () => {
const isomorphicClerk = new IsomorphicClerk({ publishableKey: 'pk_test_XXX' });
(isomorphicClerk as any).clerkjs = {};

isomorphicClerk.__internal_windowNavigate('javascript:alert(1)');

expect(hrefSetter).not.toHaveBeenCalled();
expect(warnSpy).toHaveBeenCalledTimes(1);
});

it('fallback ignores customer protocols when useStaticAllowlistOnly is set', () => {
const isomorphicClerk = new IsomorphicClerk({
publishableKey: 'pk_test_XXX',
allowedRedirectProtocols: ['slack:'],
});
(isomorphicClerk as any).clerkjs = {};

isomorphicClerk.__internal_windowNavigate('slack://channel/123', { useStaticAllowlistOnly: true });

expect(hrefSetter).not.toHaveBeenCalled();
expect(warnSpy).toHaveBeenCalledTimes(1);
});
});
});
20 changes: 20 additions & 0 deletions packages/react/src/isomorphicClerk.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
import { inBrowser } from '@clerk/shared/browser';
import { clerkEvents, createClerkEventBus } from '@clerk/shared/clerkEventBus';
import { ALLOWED_PROTOCOLS, windowNavigate } from '@clerk/shared/internal/clerk-js/windowNavigate';
import { loadClerkJSScript, loadClerkUIScript } from '@clerk/shared/loadClerkJsScript';
import type {
__internal_AttemptToEnableEnvironmentSettingParams,
Expand DownExpand Up@@ -265,6 +266,25 @@ 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) => {
const clerkjs = this.clerkjs;
if (typeof clerkjs?.__internal_windowNavigate === 'function') {
clerkjs.__internal_windowNavigate(to, opts);
return;
}

// Older clerk-js lacks this navigation chokepoint, so delegating would silently no-op. Fall back to
// the shared helper (browser-only, it touches window.location) so newer @clerk/ui still navigates and
// rejects disallowed protocols on an older clerk-js, honoring allowedRedirectProtocols unless opted out.
if (!inBrowser()) {
return;
}
const allowedProtocols = opts?.useStaticAllowlistOnly
? ALLOWED_PROTOCOLS
: [...ALLOWED_PROTOCOLS, ...(this.options.allowedRedirectProtocols ?? [])];
windowNavigate(to, { allowedProtocols });
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

constructor(options: IsomorphicClerkOptions) {
this.#publishableKey = options?.publishableKey;
this.#proxyUrl = options?.proxyUrl;
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
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'],
// Control characters the URL parser strips but `trim()` does not: each still resolves
// scheme-relative against the base origin, so the guard must normalize before testing.
['interior-tab scheme-relative', '/\t/evil.example/path'],
['interior-newline scheme-relative', '/\n/evil.example/path'],
['interior-cr scheme-relative', '/\r/evil.example/path'],
['interior-tab backslash', '\\\t\\evil.example/path'],
['leading-null scheme-relative', '\x00//evil.example/path'],
['leading-c0-controls scheme-relative', '\x01\x02//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);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(clerk-js): validate window navigation protocols and honor allowedRedirectProtocols by jacekradko · Pull Request #8961 · clerk/javascript · GitHub
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
10 changes: 10 additions & 0 deletions .changeset/windownavigate-protocol-allowlist.md
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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": "115KB" },
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "116KB" },
{ "path": "./dist/clerk.no-rhc.js", "maxSize": "316KB" },
{ "path": "./dist/clerk.native.js", "maxSize": "73KB" },
{ "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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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
90 changes: 90 additions & 0 deletions packages/react/src/__tests__/isomorphicClerk.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -434,4 +434,94 @@ describe('isomorphicClerk', () => {
);
});
});

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

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)[prop] = value;
return true;
},
},
),
});
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
});

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

it('delegates to clerk-js when the navigation chokepoint is available', () => {
const navigate = vi.fn();
const isomorphicClerk = new IsomorphicClerk({ publishableKey: 'pk_test_XXX' });
(isomorphicClerk as any).clerkjs = { __internal_windowNavigate: navigate };

isomorphicClerk.__internal_windowNavigate('/sign-in', { useStaticAllowlistOnly: true });

expect(navigate).toHaveBeenCalledWith('/sign-in', { useStaticAllowlistOnly: true });
expect(hrefSetter).not.toHaveBeenCalled();
});

it('falls back to navigation when clerk-js is older and lacks the chokepoint', () => {
const isomorphicClerk = new IsomorphicClerk({ publishableKey: 'pk_test_XXX' });
// An older clerk-js that does not expose __internal_windowNavigate.
(isomorphicClerk as any).clerkjs = {};

isomorphicClerk.__internal_windowNavigate('/sign-in');

expect(hrefSetter).toHaveBeenCalledWith('https://example.com/sign-in');
expect(warnSpy).not.toHaveBeenCalled();
});

it('fallback honors the customer-supplied allowedRedirectProtocols option', () => {
const isomorphicClerk = new IsomorphicClerk({
publishableKey: 'pk_test_XXX',
allowedRedirectProtocols: ['slack:'],
});
(isomorphicClerk as any).clerkjs = {};

isomorphicClerk.__internal_windowNavigate('slack://channel/123');

expect(hrefSetter).toHaveBeenCalledWith('slack://channel/123');
expect(warnSpy).not.toHaveBeenCalled();
});

it('fallback fails closed for disallowed protocols', () => {
const isomorphicClerk = new IsomorphicClerk({ publishableKey: 'pk_test_XXX' });
(isomorphicClerk as any).clerkjs = {};

isomorphicClerk.__internal_windowNavigate('javascript:alert(1)');

expect(hrefSetter).not.toHaveBeenCalled();
expect(warnSpy).toHaveBeenCalledTimes(1);
});

it('fallback ignores customer protocols when useStaticAllowlistOnly is set', () => {
const isomorphicClerk = new IsomorphicClerk({
publishableKey: 'pk_test_XXX',
allowedRedirectProtocols: ['slack:'],
});
(isomorphicClerk as any).clerkjs = {};

isomorphicClerk.__internal_windowNavigate('slack://channel/123', { useStaticAllowlistOnly: true });

expect(hrefSetter).not.toHaveBeenCalled();
expect(warnSpy).toHaveBeenCalledTimes(1);
});
});
});
20 changes: 20 additions & 0 deletions packages/react/src/isomorphicClerk.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
import { inBrowser } from '@clerk/shared/browser';
import { clerkEvents, createClerkEventBus } from '@clerk/shared/clerkEventBus';
import { ALLOWED_PROTOCOLS, windowNavigate } from '@clerk/shared/internal/clerk-js/windowNavigate';
import { loadClerkJSScript, loadClerkUIScript } from '@clerk/shared/loadClerkJsScript';
import type {
__internal_AttemptToEnableEnvironmentSettingParams,
Expand DownExpand Up@@ -265,6 +266,25 @@ 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) => {
const clerkjs = this.clerkjs;
if (typeof clerkjs?.__internal_windowNavigate === 'function') {
clerkjs.__internal_windowNavigate(to, opts);
return;
}

// Older clerk-js lacks this navigation chokepoint, so delegating would silently no-op. Fall back to
// the shared helper (browser-only, it touches window.location) so newer @clerk/ui still navigates and
// rejects disallowed protocols on an older clerk-js, honoring allowedRedirectProtocols unless opted out.
if (!inBrowser()) {
return;
}
const allowedProtocols = opts?.useStaticAllowlistOnly
? ALLOWED_PROTOCOLS
: [...ALLOWED_PROTOCOLS, ...(this.options.allowedRedirectProtocols ?? [])];
windowNavigate(to, { allowedProtocols });
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

constructor(options: IsomorphicClerkOptions) {
this.#publishableKey = options?.publishableKey;
this.#proxyUrl = options?.proxyUrl;
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
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'],
// Control characters the URL parser strips but `trim()` does not: each still resolves
// scheme-relative against the base origin, so the guard must normalize before testing.
['interior-tab scheme-relative', '/\t/evil.example/path'],
['interior-newline scheme-relative', '/\n/evil.example/path'],
['interior-cr scheme-relative', '/\r/evil.example/path'],
['interior-tab backslash', '\\\t\\evil.example/path'],
['leading-null scheme-relative', '\x00//evil.example/path'],
['leading-c0-controls scheme-relative', '\x01\x02//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);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(clerk-js): validate window navigation protocols and honor allowedRedirectProtocols by jacekradko · Pull Request #8961 · clerk/javascript · GitHub
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
10 changes: 10 additions & 0 deletions .changeset/windownavigate-protocol-allowlist.md
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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": "115KB" },
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "116KB" },
{ "path": "./dist/clerk.no-rhc.js", "maxSize": "316KB" },
{ "path": "./dist/clerk.native.js", "maxSize": "73KB" },
{ "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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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
90 changes: 90 additions & 0 deletions packages/react/src/__tests__/isomorphicClerk.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -434,4 +434,94 @@ describe('isomorphicClerk', () => {
);
});
});

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

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)[prop] = value;
return true;
},
},
),
});
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
});

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

it('delegates to clerk-js when the navigation chokepoint is available', () => {
const navigate = vi.fn();
const isomorphicClerk = new IsomorphicClerk({ publishableKey: 'pk_test_XXX' });
(isomorphicClerk as any).clerkjs = { __internal_windowNavigate: navigate };

isomorphicClerk.__internal_windowNavigate('/sign-in', { useStaticAllowlistOnly: true });

expect(navigate).toHaveBeenCalledWith('/sign-in', { useStaticAllowlistOnly: true });
expect(hrefSetter).not.toHaveBeenCalled();
});

it('falls back to navigation when clerk-js is older and lacks the chokepoint', () => {
const isomorphicClerk = new IsomorphicClerk({ publishableKey: 'pk_test_XXX' });
// An older clerk-js that does not expose __internal_windowNavigate.
(isomorphicClerk as any).clerkjs = {};

isomorphicClerk.__internal_windowNavigate('/sign-in');

expect(hrefSetter).toHaveBeenCalledWith('https://example.com/sign-in');
expect(warnSpy).not.toHaveBeenCalled();
});

it('fallback honors the customer-supplied allowedRedirectProtocols option', () => {
const isomorphicClerk = new IsomorphicClerk({
publishableKey: 'pk_test_XXX',
allowedRedirectProtocols: ['slack:'],
});
(isomorphicClerk as any).clerkjs = {};

isomorphicClerk.__internal_windowNavigate('slack://channel/123');

expect(hrefSetter).toHaveBeenCalledWith('slack://channel/123');
expect(warnSpy).not.toHaveBeenCalled();
});

it('fallback fails closed for disallowed protocols', () => {
const isomorphicClerk = new IsomorphicClerk({ publishableKey: 'pk_test_XXX' });
(isomorphicClerk as any).clerkjs = {};

isomorphicClerk.__internal_windowNavigate('javascript:alert(1)');

expect(hrefSetter).not.toHaveBeenCalled();
expect(warnSpy).toHaveBeenCalledTimes(1);
});

it('fallback ignores customer protocols when useStaticAllowlistOnly is set', () => {
const isomorphicClerk = new IsomorphicClerk({
publishableKey: 'pk_test_XXX',
allowedRedirectProtocols: ['slack:'],
});
(isomorphicClerk as any).clerkjs = {};

isomorphicClerk.__internal_windowNavigate('slack://channel/123', { useStaticAllowlistOnly: true });

expect(hrefSetter).not.toHaveBeenCalled();
expect(warnSpy).toHaveBeenCalledTimes(1);
});
});
});
20 changes: 20 additions & 0 deletions packages/react/src/isomorphicClerk.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
import { inBrowser } from '@clerk/shared/browser';
import { clerkEvents, createClerkEventBus } from '@clerk/shared/clerkEventBus';
import { ALLOWED_PROTOCOLS, windowNavigate } from '@clerk/shared/internal/clerk-js/windowNavigate';
import { loadClerkJSScript, loadClerkUIScript } from '@clerk/shared/loadClerkJsScript';
import type {
__internal_AttemptToEnableEnvironmentSettingParams,
Expand DownExpand Up@@ -265,6 +266,25 @@ 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) => {
const clerkjs = this.clerkjs;
if (typeof clerkjs?.__internal_windowNavigate === 'function') {
clerkjs.__internal_windowNavigate(to, opts);
return;
}

// Older clerk-js lacks this navigation chokepoint, so delegating would silently no-op. Fall back to
// the shared helper (browser-only, it touches window.location) so newer @clerk/ui still navigates and
// rejects disallowed protocols on an older clerk-js, honoring allowedRedirectProtocols unless opted out.
if (!inBrowser()) {
return;
}
const allowedProtocols = opts?.useStaticAllowlistOnly
? ALLOWED_PROTOCOLS
: [...ALLOWED_PROTOCOLS, ...(this.options.allowedRedirectProtocols ?? [])];
windowNavigate(to, { allowedProtocols });
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

constructor(options: IsomorphicClerkOptions) {
this.#publishableKey = options?.publishableKey;
this.#proxyUrl = options?.proxyUrl;
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
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'],
// Control characters the URL parser strips but `trim()` does not: each still resolves
// scheme-relative against the base origin, so the guard must normalize before testing.
['interior-tab scheme-relative', '/\t/evil.example/path'],
['interior-newline scheme-relative', '/\n/evil.example/path'],
['interior-cr scheme-relative', '/\r/evil.example/path'],
['interior-tab backslash', '\\\t\\evil.example/path'],
['leading-null scheme-relative', '\x00//evil.example/path'],
['leading-c0-controls scheme-relative', '\x01\x02//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);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); fix(clerk-js): validate window navigation protocols and honor allowedRedirectProtocols by jacekradko · Pull Request #8961 · clerk/javascript · GitHub
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
10 changes: 10 additions & 0 deletions .changeset/windownavigate-protocol-allowlist.md
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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": "115KB" },
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "116KB" },
{ "path": "./dist/clerk.no-rhc.js", "maxSize": "316KB" },
{ "path": "./dist/clerk.native.js", "maxSize": "73KB" },
{ "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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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
90 changes: 90 additions & 0 deletions packages/react/src/__tests__/isomorphicClerk.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -434,4 +434,94 @@ describe('isomorphicClerk', () => {
);
});
});

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

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)[prop] = value;
return true;
},
},
),
});
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
});

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

it('delegates to clerk-js when the navigation chokepoint is available', () => {
const navigate = vi.fn();
const isomorphicClerk = new IsomorphicClerk({ publishableKey: 'pk_test_XXX' });
(isomorphicClerk as any).clerkjs = { __internal_windowNavigate: navigate };

isomorphicClerk.__internal_windowNavigate('/sign-in', { useStaticAllowlistOnly: true });

expect(navigate).toHaveBeenCalledWith('/sign-in', { useStaticAllowlistOnly: true });
expect(hrefSetter).not.toHaveBeenCalled();
});

it('falls back to navigation when clerk-js is older and lacks the chokepoint', () => {
const isomorphicClerk = new IsomorphicClerk({ publishableKey: 'pk_test_XXX' });
// An older clerk-js that does not expose __internal_windowNavigate.
(isomorphicClerk as any).clerkjs = {};

isomorphicClerk.__internal_windowNavigate('/sign-in');

expect(hrefSetter).toHaveBeenCalledWith('https://example.com/sign-in');
expect(warnSpy).not.toHaveBeenCalled();
});

it('fallback honors the customer-supplied allowedRedirectProtocols option', () => {
const isomorphicClerk = new IsomorphicClerk({
publishableKey: 'pk_test_XXX',
allowedRedirectProtocols: ['slack:'],
});
(isomorphicClerk as any).clerkjs = {};

isomorphicClerk.__internal_windowNavigate('slack://channel/123');

expect(hrefSetter).toHaveBeenCalledWith('slack://channel/123');
expect(warnSpy).not.toHaveBeenCalled();
});

it('fallback fails closed for disallowed protocols', () => {
const isomorphicClerk = new IsomorphicClerk({ publishableKey: 'pk_test_XXX' });
(isomorphicClerk as any).clerkjs = {};

isomorphicClerk.__internal_windowNavigate('javascript:alert(1)');

expect(hrefSetter).not.toHaveBeenCalled();
expect(warnSpy).toHaveBeenCalledTimes(1);
});

it('fallback ignores customer protocols when useStaticAllowlistOnly is set', () => {
const isomorphicClerk = new IsomorphicClerk({
publishableKey: 'pk_test_XXX',
allowedRedirectProtocols: ['slack:'],
});
(isomorphicClerk as any).clerkjs = {};

isomorphicClerk.__internal_windowNavigate('slack://channel/123', { useStaticAllowlistOnly: true });

expect(hrefSetter).not.toHaveBeenCalled();
expect(warnSpy).toHaveBeenCalledTimes(1);
});
});
});
20 changes: 20 additions & 0 deletions packages/react/src/isomorphicClerk.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
import { inBrowser } from '@clerk/shared/browser';
import { clerkEvents, createClerkEventBus } from '@clerk/shared/clerkEventBus';
import { ALLOWED_PROTOCOLS, windowNavigate } from '@clerk/shared/internal/clerk-js/windowNavigate';
import { loadClerkJSScript, loadClerkUIScript } from '@clerk/shared/loadClerkJsScript';
import type {
__internal_AttemptToEnableEnvironmentSettingParams,
Expand DownExpand Up@@ -265,6 +266,25 @@ 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) => {
const clerkjs = this.clerkjs;
if (typeof clerkjs?.__internal_windowNavigate === 'function') {
clerkjs.__internal_windowNavigate(to, opts);
return;
}

// Older clerk-js lacks this navigation chokepoint, so delegating would silently no-op. Fall back to
// the shared helper (browser-only, it touches window.location) so newer @clerk/ui still navigates and
// rejects disallowed protocols on an older clerk-js, honoring allowedRedirectProtocols unless opted out.
if (!inBrowser()) {
return;
}
const allowedProtocols = opts?.useStaticAllowlistOnly
? ALLOWED_PROTOCOLS
: [...ALLOWED_PROTOCOLS, ...(this.options.allowedRedirectProtocols ?? [])];
windowNavigate(to, { allowedProtocols });
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

constructor(options: IsomorphicClerkOptions) {
this.#publishableKey = options?.publishableKey;
this.#proxyUrl = options?.proxyUrl;
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
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'],
// Control characters the URL parser strips but `trim()` does not: each still resolves
// scheme-relative against the base origin, so the guard must normalize before testing.
['interior-tab scheme-relative', '/\t/evil.example/path'],
['interior-newline scheme-relative', '/\n/evil.example/path'],
['interior-cr scheme-relative', '/\r/evil.example/path'],
['interior-tab backslash', '\\\t\\evil.example/path'],
['leading-null scheme-relative', '\x00//evil.example/path'],
['leading-c0-controls scheme-relative', '\x01\x02//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);
});
});
Loading
Loading