diff --git a/.changeset/windownavigate-protocol-allowlist.md b/.changeset/windownavigate-protocol-allowlist.md new file mode 100644 index 00000000000..c28ad2f3007 --- /dev/null +++ b/.changeset/windownavigate-protocol-allowlist.md @@ -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. diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index df17a62a863..5ce8b7f6d8b 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -2,9 +2,9 @@ "files": [ { "path": "./dist/clerk.js", "maxSize": "549KB" }, { "path": "./dist/clerk.browser.js", "maxSize": "74KB" }, - { "path": "./dist/clerk.legacy.browser.js", "maxSize": "114KB" }, + { "path": "./dist/clerk.legacy.browser.js", "maxSize": "116KB" }, { "path": "./dist/clerk.no-rhc.js", "maxSize": "316KB" }, - { "path": "./dist/clerk.native.js", "maxSize": "72KB" }, + { "path": "./dist/clerk.native.js", "maxSize": "74KB" }, { "path": "./dist/vendors*.js", "maxSize": "7KB" }, { "path": "./dist/coinbase*.js", "maxSize": "36KB" }, { "path": "./dist/base-account-sdk*.js", "maxSize": "207KB" }, diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 80b6967f256..9450cbd6040 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -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 @@ -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); @@ -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; } diff --git a/packages/clerk-js/src/core/resources/SignIn.ts b/packages/clerk-js/src/core/resources/SignIn.ts index 27397193194..24b6efef9be 100644 --- a/packages/clerk-js/src/core/resources/SignIn.ts +++ b/packages/clerk-js/src/core/resources/SignIn.ts @@ -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, @@ -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 => { @@ -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); } } }); diff --git a/packages/clerk-js/src/core/resources/SignUp.ts b/packages/clerk-js/src/core/resources/SignUp.ts index 99c6b09d35a..428975beb26 100644 --- a/packages/clerk-js/src/core/resources/SignUp.ts +++ b/packages/clerk-js/src/core/resources/SignUp.ts @@ -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, @@ -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 ( @@ -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); } } }); diff --git a/packages/react/src/isomorphicClerk.ts b/packages/react/src/isomorphicClerk.ts index 74b84c106da..ff568517bc2 100644 --- a/packages/react/src/isomorphicClerk.ts +++ b/packages/react/src/isomorphicClerk.ts @@ -265,6 +265,10 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { return this.clerkjs?.__internal_getOption ? this.clerkjs?.__internal_getOption(key) : this.options[key]; } + public __internal_windowNavigate: Clerk['__internal_windowNavigate'] = (to, opts) => { + this.clerkjs?.__internal_windowNavigate?.(to, opts); + }; + constructor(options: IsomorphicClerkOptions) { this.#publishableKey = options?.publishableKey; this.#proxyUrl = options?.proxyUrl; diff --git a/packages/shared/src/internal/clerk-js/__tests__/windowNavigate.test.ts b/packages/shared/src/internal/clerk-js/__tests__/windowNavigate.test.ts new file mode 100644 index 00000000000..91e5ed6bf24 --- /dev/null +++ b/packages/shared/src/internal/clerk-js/__tests__/windowNavigate.test.ts @@ -0,0 +1,124 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ALLOWED_PROTOCOLS, CLERK_BEFORE_UNLOAD_EVENT, windowNavigate } from '../windowNavigate'; + +describe('windowNavigate', () => { + let originalLocation: Location; + let hrefSetter: ReturnType; + let warnSpy: ReturnType; + let eventSpy: ReturnType; + + 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,'], + ['file', 'file:///etc/passwd'], + ['vbscript', 'vbscript:msgbox(1)'], + ['mixed-case JavaScript', 'JavaScript:alert(1)'], + ['upper-case JAVASCRIPT', 'JAVASCRIPT:alert(1)'], + ['leading-whitespace javascript', ' javascript:alert(1)'], + ['leading-tab javascript', '\tjavascript:alert(1)'], + ['leading-newline javascript', '\njavascript:alert(1)'], + ])('blocks %s: protocol and does not navigate', (_label, to) => { + windowNavigate(to); + expect(hrefSetter).not.toHaveBeenCalled(); + expect(eventSpy).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledTimes(1); + }); + + it('blocks javascript: URLs that the URL parser normalizes via the base URL', () => { + windowNavigate('javascript:alert(location.origin)//'); + expect(hrefSetter).not.toHaveBeenCalled(); + expect(eventSpy).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledTimes(1); + }); + + it.each([ + ['scheme-relative //host', '//evil.example/path'], + ['scheme-relative ///host', '///evil.example/path'], + ['backslash \\\\host', '\\\\evil.example\\path'], + ['mixed /\\host', '/\\evil.example/path'], + ['mixed \\/host', '\\/evil.example/path'], + ['leading-whitespace scheme-relative', ' //evil.example/path'], + ['leading-tab scheme-relative', '\t//evil.example/path'], + ])('blocks %s and does not navigate', (_label, to) => { + windowNavigate(to); + expect(hrefSetter).not.toHaveBeenCalled(); + expect(eventSpy).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledTimes(1); + }); + + it('still rejects scheme-relative URLs when an extended allowlist is supplied', () => { + windowNavigate('//evil.example/path', { + allowedProtocols: [...ALLOWED_PROTOCOLS, 'slack:'], + }); + expect(hrefSetter).not.toHaveBeenCalled(); + expect(eventSpy).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledTimes(1); + }); + + it('honors a caller-supplied extended allowlist for custom protocols', () => { + windowNavigate('slack://channel/123', { + allowedProtocols: [...ALLOWED_PROTOCOLS, 'slack:'], + }); + expect(hrefSetter).toHaveBeenCalledTimes(1); + expect(hrefSetter).toHaveBeenCalledWith('slack://channel/123'); + expect(eventSpy).toHaveBeenCalledTimes(1); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('still rejects disallowed protocols when an extended allowlist is supplied', () => { + windowNavigate('javascript:alert(1)', { + allowedProtocols: [...ALLOWED_PROTOCOLS, 'slack:'], + }); + expect(hrefSetter).not.toHaveBeenCalled(); + expect(eventSpy).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/shared/src/internal/clerk-js/windowNavigate.ts b/packages/shared/src/internal/clerk-js/windowNavigate.ts index 051fb9bfb35..e1e51c82729 100644 --- a/packages/shared/src/internal/clerk-js/windowNavigate.ts +++ b/packages/shared/src/internal/clerk-js/windowNavigate.ts @@ -11,13 +11,48 @@ export const ALLOWED_PROTOCOLS = [ 'chrome-extension:', ]; +export type WindowNavigateOptions = { + /** + * Protocol allowlist applied to the resolved URL. Defaults to `ALLOWED_PROTOCOLS`. Pass an + * extended list (e.g. `Clerk`'s `#allowedRedirectProtocols`) to honor the customer-supplied + * `allowedRedirectProtocols` option. + */ + allowedProtocols?: ReadonlyArray; +}; + +const SCHEME_RELATIVE_PREFIX = /^[/\\][/\\]/; + /** * Helper utility to navigate via window.location.href. Also dispatches a clerk:beforeunload custom event. * - * Note that this utility should **never** be called with a user-provided URL. We make no specific checks against the contents of the URL here and assume it is safe. Use `Clerk.navigate()` instead for user-provided URLs. + * Navigations whose protocol is not in the allowlist (e.g. `javascript:`, `data:`) are aborted. + * Scheme-relative inputs (`//host`, `\\host`) are also rejected: they adopt the base URL's scheme, + * which is always in the allowlist, so they would otherwise pass the protocol check while + * redirecting cross-origin. + * + * Callers that have already validated against an extended allowlist should pass it via + * `options.allowedProtocols` so legitimate custom protocols (Wails, Tauri, etc.) are honored. + * + * @deprecated Use `clerk.__internal_windowNavigate` instead. It honors the customer-supplied + * `allowedRedirectProtocols` option by default, so internal call sites can't accidentally + * bypass it by forgetting to pass `options.allowedProtocols`. The bare export will be removed + * in the next major version. */ -export function windowNavigate(to: URL | string): void { +export function windowNavigate(to: URL | string, options?: WindowNavigateOptions): void { + if (typeof to === 'string' && SCHEME_RELATIVE_PREFIX.test(to.trim())) { + console.warn( + `Clerk: scheme-relative navigation to "${to}" is not allowed. Provide a same-origin path or an absolute URL.`, + ); + return; + } const toURL = new URL(to, window.location.href); + const allowedProtocols = options?.allowedProtocols ?? ALLOWED_PROTOCOLS; + if (!allowedProtocols.includes(toURL.protocol)) { + console.warn( + `Clerk: "${toURL.protocol}" is not a valid navigation protocol. Aborting navigation. If you think this is a mistake, please open an issue.`, + ); + return; + } window.dispatchEvent(new CustomEvent(CLERK_BEFORE_UNLOAD_EVENT)); window.location.href = toURL.href; } diff --git a/packages/shared/src/types/clerk.ts b/packages/shared/src/types/clerk.ts index 595aea197c4..3f0ce71e96b 100644 --- a/packages/shared/src/types/clerk.ts +++ b/packages/shared/src/types/clerk.ts @@ -288,6 +288,16 @@ export interface Clerk { */ __internal_getOption(key: K): ClerkOptions[K]; + /** + * @internal + * Primary `window.location.href` navigation chokepoint for `@clerk/clerk-js` and `@clerk/ui`. + * By default the resolved URL is validated against the customer-supplied + * `allowedRedirectProtocols` option (static defaults ∪ the customer extension). + * Disallowed protocols and scheme-relative inputs (`//host`) are rejected with a console warning. + * Pass `useStaticAllowlistOnly: true` to opt out of the customer extension. + */ + __internal_windowNavigate: (to: URL | string, opts?: { useStaticAllowlistOnly?: boolean }) => void; + frontendApi: string; /** Your Clerk [Publishable Key](!publishable-key). */ diff --git a/packages/ui/src/components/UserButton/useMultisessionActions.tsx b/packages/ui/src/components/UserButton/useMultisessionActions.tsx index 8b045f99f7a..bb46235f382 100644 --- a/packages/ui/src/components/UserButton/useMultisessionActions.tsx +++ b/packages/ui/src/components/UserButton/useMultisessionActions.tsx @@ -1,11 +1,11 @@ import { navigateIfTaskExists } from '@clerk/shared/internal/clerk-js/sessionTasks'; -import { windowNavigate } from '@clerk/shared/internal/clerk-js/windowNavigate'; import { useClerk, usePortalRoot } from '@clerk/shared/react'; import type { SignedInSessionResource, UserButtonProps, UserResource } from '@clerk/shared/types'; import { useEnvironment } from '@/ui/contexts'; import { useCardState } from '@/ui/elements/contexts'; import { sleep } from '@/ui/utils/sleep'; +import { clerkWindowNavigate } from '@/ui/utils/windowNavigate'; import { useMultipleSessions } from '../../hooks/useMultipleSessions'; import { useRouter } from '../../router'; @@ -22,7 +22,8 @@ type UseMultisessionActionsParams = { } & Pick; export const useMultisessionActions = (opts: UseMultisessionActionsParams) => { - const { setActive, signOut, openUserProfile } = useClerk(); + const clerk = useClerk(); + const { setActive, signOut, openUserProfile } = clerk; const card = useCardState(); const { signedInSessions, otherSessions } = useMultipleSessions({ user: opts.user }); const { navigate } = useRouter(); @@ -101,7 +102,7 @@ export const useMultisessionActions = (opts: UseMultisessionActionsParams) => { }; const handleAddAccountClicked = () => { - windowNavigate(opts.signInUrl || window.location.href); + clerkWindowNavigate(clerk, opts.signInUrl || window.location.href); return sleep(2000); }; diff --git a/packages/ui/src/components/UserProfile/EnterpriseAccountsSection.tsx b/packages/ui/src/components/UserProfile/EnterpriseAccountsSection.tsx index ff4598bc222..13e787f6f72 100644 --- a/packages/ui/src/components/UserProfile/EnterpriseAccountsSection.tsx +++ b/packages/ui/src/components/UserProfile/EnterpriseAccountsSection.tsx @@ -1,6 +1,5 @@ import { appendModalState } from '@clerk/shared/internal/clerk-js/queryStateParams'; -import { windowNavigate } from '@clerk/shared/internal/clerk-js/windowNavigate'; -import { __internal_useUserEnterpriseConnections, useReverification, useUser } from '@clerk/shared/react'; +import { __internal_useUserEnterpriseConnections, useClerk, useReverification, useUser } from '@clerk/shared/react'; import type { EnterpriseAccountResource, EnterpriseConnectionResource, OAuthProvider } from '@clerk/shared/types'; import { Fragment, useState } from 'react'; @@ -9,6 +8,7 @@ import { useCardState, withCardStateProvider } from '@/ui/elements/contexts'; import { ProfileSection } from '@/ui/elements/Section'; import { handleError } from '@/ui/utils/errorHandler'; import { sleep } from '@/ui/utils/sleep'; +import { clerkWindowNavigate } from '@/ui/utils/windowNavigate'; import { ProviderIcon } from '../../common'; import { useUserProfileContext } from '../../contexts'; @@ -17,6 +17,7 @@ import { Action } from '../../elements/Action'; const EnterpriseConnectMenuButton = (props: { connection: EnterpriseConnectionResource }) => { const { connection } = props; const card = useCardState(); + const clerk = useClerk(); const { user } = useUser(); const { componentName, mode } = useUserProfileContext(); const isModal = mode === 'modal'; @@ -42,7 +43,7 @@ const EnterpriseConnectMenuButton = (props: { connection: EnterpriseConnectionRe .then(res => { if (res?.verification?.externalVerificationRedirectURL) { void sleep(2000).then(() => card.setIdle(loadingKey)); - windowNavigate(res.verification.externalVerificationRedirectURL); + clerkWindowNavigate(clerk, res.verification.externalVerificationRedirectURL); } }) .catch(err => { diff --git a/packages/ui/src/contexts/components/SessionTasks.ts b/packages/ui/src/contexts/components/SessionTasks.ts index 0797956f096..a0ed5e7eddb 100644 --- a/packages/ui/src/contexts/components/SessionTasks.ts +++ b/packages/ui/src/contexts/components/SessionTasks.ts @@ -1,10 +1,10 @@ import { buildTaskUrl, getTaskEndpoint } from '@clerk/shared/internal/clerk-js/sessionTasks'; -import { windowNavigate } from '@clerk/shared/internal/clerk-js/windowNavigate'; import { useClerk } from '@clerk/shared/react'; import type { DecorateUrl, SessionResource } from '@clerk/shared/types'; import { createContext, useContext } from 'react'; import { useRouter, VIRTUAL_ROUTER_BASE_PATH } from '@/ui/router'; +import { clerkWindowNavigate } from '@/ui/utils/windowNavigate'; import type { SessionTasksCtx, TaskChooseOrganizationCtx, TaskResetPasswordCtx, TaskSetupMFACtx } from '../../types'; @@ -44,7 +44,7 @@ export const useSessionTasksContext = (): SessionTasksContextType => { // If decorateUrl modified the URL (Safari ITP fix), do a full page navigation // The touch endpoint URL will be an absolute URL starting with http:// or https:// if (decoratedUrl !== redirectUrlComplete && /^https?:\/\//.test(decoratedUrl)) { - windowNavigate(decoratedUrl); + clerkWindowNavigate(clerk, decoratedUrl); return; } diff --git a/packages/ui/src/contexts/components/SignIn.ts b/packages/ui/src/contexts/components/SignIn.ts index dcf0c08e2db..30780b6e1a0 100644 --- a/packages/ui/src/contexts/components/SignIn.ts +++ b/packages/ui/src/contexts/components/SignIn.ts @@ -2,7 +2,6 @@ import { SIGN_IN_INITIAL_VALUE_KEYS } from '@clerk/shared/internal/clerk-js/cons import { RedirectUrls } from '@clerk/shared/internal/clerk-js/redirectUrls'; import { getTaskEndpoint } from '@clerk/shared/internal/clerk-js/sessionTasks'; import { buildURL } from '@clerk/shared/internal/clerk-js/url'; -import { windowNavigate } from '@clerk/shared/internal/clerk-js/windowNavigate'; import { useClerk } from '@clerk/shared/react'; import type { DecorateUrl, SessionResource } from '@clerk/shared/types'; import { isAbsoluteUrl } from '@clerk/shared/url'; @@ -13,6 +12,7 @@ import { useEnvironment, useOptions } from '../../contexts'; import type { ParsedQueryString } from '../../router'; import { useRouter } from '../../router'; import type { SignInCtx } from '../../types'; +import { clerkWindowNavigate } from '../../utils/windowNavigate'; import { getInitialValuesFromQueryParams } from '../utils'; export type SignInContextType = Omit & { @@ -145,7 +145,7 @@ export const useSignInContext = (): SignInContextType => { // If decorateUrl modified the URL (Safari ITP fix), do a full page navigation // The touch endpoint URL will be an absolute URL starting with http:// or https:// if (decoratedUrl !== redirectUrl && /^https?:\/\//.test(decoratedUrl)) { - windowNavigate(decoratedUrl); + clerkWindowNavigate(clerk, decoratedUrl); return; } diff --git a/packages/ui/src/contexts/components/SignUp.ts b/packages/ui/src/contexts/components/SignUp.ts index 2a4405b0fc1..a3a842141f4 100644 --- a/packages/ui/src/contexts/components/SignUp.ts +++ b/packages/ui/src/contexts/components/SignUp.ts @@ -2,7 +2,6 @@ import { SIGN_UP_INITIAL_VALUE_KEYS } from '@clerk/shared/internal/clerk-js/cons import { RedirectUrls } from '@clerk/shared/internal/clerk-js/redirectUrls'; import { getTaskEndpoint } from '@clerk/shared/internal/clerk-js/sessionTasks'; import { buildURL } from '@clerk/shared/internal/clerk-js/url'; -import { windowNavigate } from '@clerk/shared/internal/clerk-js/windowNavigate'; import { useClerk } from '@clerk/shared/react'; import type { DecorateUrl, SessionResource } from '@clerk/shared/types'; import { isAbsoluteUrl } from '@clerk/shared/url'; @@ -13,6 +12,7 @@ import { useEnvironment, useOptions } from '../../contexts'; import type { ParsedQueryString } from '../../router'; import { useRouter } from '../../router'; import type { SignUpCtx } from '../../types'; +import { clerkWindowNavigate } from '../../utils/windowNavigate'; import { getInitialValuesFromQueryParams } from '../utils'; export type SignUpContextType = Omit & { @@ -136,7 +136,7 @@ export const useSignUpContext = (): SignUpContextType => { // If decorateUrl modified the URL (Safari ITP fix), do a full page navigation // The touch endpoint URL will be an absolute URL starting with http:// or https:// if (decoratedUrl !== redirectUrl && /^https?:\/\//.test(decoratedUrl)) { - windowNavigate(decoratedUrl); + clerkWindowNavigate(clerk, decoratedUrl); return; } diff --git a/packages/ui/src/utils/__tests__/windowNavigate.test.ts b/packages/ui/src/utils/__tests__/windowNavigate.test.ts new file mode 100644 index 00000000000..3473a05c913 --- /dev/null +++ b/packages/ui/src/utils/__tests__/windowNavigate.test.ts @@ -0,0 +1,90 @@ +import { CLERK_BEFORE_UNLOAD_EVENT } from '@clerk/shared/internal/clerk-js/windowNavigate'; +import type { Clerk } from '@clerk/shared/types'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { clerkWindowNavigate } from '../windowNavigate'; + +describe('clerkWindowNavigate', () => { + let originalLocation: Location; + let hrefSetter: ReturnType; + let warnSpy: ReturnType; + let eventSpy: ReturnType; + + 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('delegates to the Clerk navigation chokepoint when available', () => { + const clerk = { + __internal_windowNavigate: vi.fn(), + } as unknown as Clerk; + const opts = { useStaticAllowlistOnly: true }; + + clerkWindowNavigate(clerk, '/sign-in', opts); + + expect(clerk.__internal_windowNavigate).toHaveBeenCalledWith('/sign-in', opts); + expect(hrefSetter).not.toHaveBeenCalled(); + expect(eventSpy).not.toHaveBeenCalled(); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('falls back to static allowlist navigation when ClerkJS is older', () => { + const clerk = {} as unknown as Clerk; + + clerkWindowNavigate(clerk, '/sign-in'); + + expect(hrefSetter).toHaveBeenCalledWith('https://example.com/sign-in'); + expect(eventSpy).toHaveBeenCalledTimes(1); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('fails closed for disallowed protocols when ClerkJS is older', () => { + const clerk = {} as unknown as Clerk; + + clerkWindowNavigate(clerk, 'javascript:alert(1)'); + + expect(hrefSetter).not.toHaveBeenCalled(); + expect(eventSpy).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledTimes(1); + }); + + it('fails closed for customer-extended protocols when ClerkJS is older', () => { + const clerk = {} as unknown as Clerk; + + clerkWindowNavigate(clerk, 'slack://channel/123'); + + expect(hrefSetter).not.toHaveBeenCalled(); + expect(eventSpy).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/ui/src/utils/windowNavigate.ts b/packages/ui/src/utils/windowNavigate.ts new file mode 100644 index 00000000000..fc6acc44dc2 --- /dev/null +++ b/packages/ui/src/utils/windowNavigate.ts @@ -0,0 +1,23 @@ +import { windowNavigate } from '@clerk/shared/internal/clerk-js/windowNavigate'; +import type { Clerk } from '@clerk/shared/types'; + +type ClerkWindowNavigate = Clerk['__internal_windowNavigate']; +type ClerkWithOptionalWindowNavigate = Omit & { + __internal_windowNavigate?: ClerkWindowNavigate; +}; + +export function clerkWindowNavigate( + clerk: Clerk, + to: Parameters[0], + opts?: Parameters[1], +): void { + const clerkWindowNavigate = (clerk as ClerkWithOptionalWindowNavigate).__internal_windowNavigate; + + if (typeof clerkWindowNavigate === 'function') { + return clerkWindowNavigate.call(clerk, to, opts); + } + + // Older ClerkJS instances do not expose the central chokepoint. Fall back to the + // static allowlist so newer UI paired with older ClerkJS still fails closed. + return windowNavigate(to); +}