diff --git a/package-lock.json b/package-lock.json index 92d302fd0ce..fdfec9ac897 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9165,6 +9165,12 @@ "@types/node": "*" } }, + "node_modules/@types/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@types/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-S0mIukll6fbF0tvrKic/jj+jI8SHoSvGU+Cs95b/jzZEnBYCbj+7aJtQ9yeABuK3xP1okwA3jEH9qIRayijnvQ==", + "dev": true + }, "node_modules/@types/graceful-fs": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.5.tgz", @@ -37880,12 +37886,16 @@ "name": "@clerk/shared", "version": "0.16.0-staging.1", "license": "ISC", + "dependencies": { + "glob-to-regexp": "^0.4.1" + }, "devDependencies": { "@clerk/types": "^3.37.0-staging.1", "@testing-library/dom": "8.19.0", "@testing-library/jest-dom": "5.16.5", "@testing-library/react": "13.4.0", "@testing-library/user-event": "14.4.3", + "@types/glob-to-regexp": "^0.4.1", "@types/js-cookie": "3.0.2", "jest": "*", "jest-environment-jsdom": "*", @@ -42400,7 +42410,9 @@ "@testing-library/jest-dom": "5.16.5", "@testing-library/react": "13.4.0", "@testing-library/user-event": "14.4.3", + "@types/glob-to-regexp": "^0.4.1", "@types/js-cookie": "3.0.2", + "glob-to-regexp": "^0.4.1", "jest": "*", "jest-environment-jsdom": "*", "js-cookie": "3.0.1", @@ -47638,6 +47650,12 @@ "@types/node": "*" } }, + "@types/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@types/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-S0mIukll6fbF0tvrKic/jj+jI8SHoSvGU+Cs95b/jzZEnBYCbj+7aJtQ9yeABuK3xP1okwA3jEH9qIRayijnvQ==", + "dev": true + }, "@types/graceful-fs": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.5.tgz", diff --git a/packages/clerk-js/src/utils/__tests__/url.test.ts b/packages/clerk-js/src/utils/__tests__/url.test.ts index b64ace4c96f..271cf7e5cb3 100644 --- a/packages/clerk-js/src/utils/__tests__/url.test.ts +++ b/packages/clerk-js/src/utils/__tests__/url.test.ts @@ -4,10 +4,12 @@ import { appendAsQueryParams, buildURL, getAllETLDs, + getETLDPlusOneFromFrontendApi, getSearchParameterFromHash, hasBannedProtocol, hasExternalAccountSignUpError, isAccountsHostedPages, + isAllowedRedirectOrigin, isDataUri, isRedirectForFAPIInitiatedFlow, isValidUrl, @@ -383,3 +385,43 @@ describe('isRedirectForFAPIInitiatedFlow(frontendAp: string, redirectUrl: string }, ); }); + +describe('getETLDPlusOneFromFrontendApi(frontendAp: string)', () => { + const testCases: Array<[string, string]> = [ + ['clerk.foo.bar-53.lcl.dev', 'foo.bar-53.lcl.dev'], + ['clerk.clerk.com', 'clerk.com'], + ['clerk.foo.bar.co.uk', 'foo.bar.co.uk'], + ]; + + test.each(testCases)('frontendApi=(%s), expected value=(%s)', (frontendApi, expectedValue) => { + expect(getETLDPlusOneFromFrontendApi(frontendApi)).toEqual(expectedValue); + }); +}); + +fdescribe('isAllowedRedirectOrigin', () => { + const cases: [string, string[], boolean][] = [ + // base cases + ['https://clerk.com', ['https://www.clerk.com'], false], + ['https://www.clerk.com', ['https://www.clerk.com'], true], + // glob patterns + ['https://clerk.com', ['https://*.clerk.com'], false], + ['https://www.clerk.com', ['https://*.clerk.com'], true], + ['https://www.clerk.com/test', ['https://www.clerk.com/*'], true], + ['https://www.clerk.com/hello/test', ['https://www.clerk.com/*/test'], true], + ['https://www.clerk.com/hello/hello', ['https://www.clerk.com/*/test'], false], + // trailing slashes + ['https://www.clerk.com/', ['https://www.clerk.com'], true], + ['https://www.clerk.com', ['https://www.clerk.com'], true], + ['https://www.clerk.com/test', ['https://www.clerk.com'], false], + // multiple origins + ['https://www.clerk.com', ['https://www.test.dev', 'https://www.clerk.com'], true], + // relative urls + ['/relative', ['https://www.clerk.com'], true], + ['/relative/test', ['https://www.clerk.com'], true], + ['/', ['https://www.clerk.com'], true], + ]; + + test.each(cases)('isAllowedRedirectOrigin("%s","%s") === %s', (url, allowedOrigins, expected) => { + expect(isAllowedRedirectOrigin(url, allowedOrigins)).toEqual(expected); + }); +}); diff --git a/packages/clerk-js/src/utils/authPropHelpers.ts b/packages/clerk-js/src/utils/authPropHelpers.ts index 10883c6dc8a..145275d856d 100644 --- a/packages/clerk-js/src/utils/authPropHelpers.ts +++ b/packages/clerk-js/src/utils/authPropHelpers.ts @@ -3,7 +3,7 @@ import type { ClerkOptions, DisplayConfigResource } from '@clerk/types'; import type { ParsedQs } from 'qs'; import qs from 'qs'; -import { hasBannedProtocol, isValidUrl } from './index'; +import { hasBannedProtocol, isAllowedRedirectOrigin, isValidUrl } from './url'; type PickRedirectionUrlKey = 'afterSignUpUrl' | 'afterSignInUrl' | 'signInUrl' | 'signUpUrl'; @@ -35,11 +35,27 @@ export const pickRedirectionProp = ( const snakeCaseField = camelToSnake(key); const queryParamValue = queryParams?.[snakeCaseField]; + const primaryQueryParamRedirectUrl = typeof queryParamValue === 'string' ? queryParamValue : undefined; + const secondaryQueryParamRedirectUrl = + accessRedirectUrl && typeof queryParams?.redirect_url === 'string' ? queryParams.redirect_url : undefined; + + let queryParamUrl: string | undefined; + if ( + primaryQueryParamRedirectUrl && + isAllowedRedirectOrigin(primaryQueryParamRedirectUrl, options?.allowedRedirectOrigins) + ) { + queryParamUrl = primaryQueryParamRedirectUrl; + } else if ( + secondaryQueryParamRedirectUrl && + isAllowedRedirectOrigin(secondaryQueryParamRedirectUrl, options?.allowedRedirectOrigins) + ) { + queryParamUrl = secondaryQueryParamRedirectUrl; + } + const url = - (typeof queryParamValue === 'string' ? queryParamValue : null) || - (accessRedirectUrl && typeof queryParams?.redirect_url === 'string' ? queryParams?.redirect_url : null) || + queryParamUrl || ctx?.[key] || - (accessRedirectUrl ? ctx?.redirectUrl : null) || + (accessRedirectUrl ? ctx?.redirectUrl : undefined) || options?.[key] || displayConfig?.[key]; diff --git a/packages/clerk-js/src/utils/url.ts b/packages/clerk-js/src/utils/url.ts index 7ea00530d8b..1e081fd3662 100644 --- a/packages/clerk-js/src/utils/url.ts +++ b/packages/clerk-js/src/utils/url.ts @@ -1,7 +1,6 @@ -import { camelToSnake, createDevOrStagingUrlCache, isIPV4Address } from '@clerk/shared'; +import { camelToSnake, createDevOrStagingUrlCache, globs } from '@clerk/shared'; import type { SignUpResource } from '@clerk/types'; -import { loadScript } from '../utils'; import { joinPaths } from './path'; import { getQueryParams } from './querystring'; @@ -54,33 +53,8 @@ export function isAccountsHostedPages(url: string | URL = window.location.hostna return res; } -export async function getETLDPlusOne(hostname: string = window.location.hostname): Promise { - if (isIPV4Address(hostname)) { - return hostname; - } - - const parts = hostname.split('.'); - - // Reuse dynamic loading of TLDParse library as in useTLDParser - if (parts.length >= 3) { - try { - await loadScript('https://cdn.jsdelivr.net/npm/tldts@5/dist/index.umd.min.js', { - globalObject: window.tldts, - }); - - return window.tldts.getDomain(hostname, { - allowPrivateDomains: true, - }); - } catch (err) { - console.error('Failed to load tldts: ', err); - } - - // Poor mans domain splitting if dynamic loading of tldts fails - const [, ...domain] = parts; - return domain.join('.'); - } - - return hostname; +export function getETLDPlusOneFromFrontendApi(frontendApi: string): string { + return frontendApi.replace('clerk.', ''); } export function getAllETLDs(hostname: string = window.location.hostname): string[] { @@ -375,3 +349,23 @@ export function isRedirectForFAPIInitiatedFlow(frontendApi: string, redirectUrl: return frontendApi === url.host && frontendApiRedirectPaths.includes(path); } + +export const isAllowedRedirectOrigin = (_url: string, allowedRedirectOrigins: string[] | undefined) => { + if (!allowedRedirectOrigins) { + return true; + } + + const url = new URL(_url, DUMMY_URL_BASE); + const isRelativeUrl = url.origin === DUMMY_URL_BASE; + if (isRelativeUrl) { + return true; + } + + const isAllowed = allowedRedirectOrigins.some(origin => globs.toRegexp(origin).test(trimTrailingSlash(url.href))); + if (!isAllowed) { + console.warn( + `Clerk: Redirect URL ${url} is not on one of the allowedRedirectOrigins, falling back to the default redirect URL.`, + ); + } + return isAllowed; +}; diff --git a/packages/shared/package.json b/packages/shared/package.json index 89f31ede73d..40f3803a3db 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -24,6 +24,7 @@ "@testing-library/jest-dom": "5.16.5", "@testing-library/react": "13.4.0", "@testing-library/user-event": "14.4.3", + "@types/glob-to-regexp": "0.4.1", "@types/js-cookie": "3.0.2", "jest": "*", "jest-environment-jsdom": "*", @@ -38,8 +39,10 @@ }, "author": "", "license": "ISC", - "gitHead": "1b19a43b61f712756ab4d8c9ccbee6e8bddbe4ce", "publishConfig": { "access": "public" + }, + "dependencies": { + "glob-to-regexp": "0.4.1" } } diff --git a/packages/shared/src/utils/globs.ts b/packages/shared/src/utils/globs.ts new file mode 100644 index 00000000000..674daf026a2 --- /dev/null +++ b/packages/shared/src/utils/globs.ts @@ -0,0 +1,13 @@ +import globToRegexp from 'glob-to-regexp'; + +export const globs = { + toRegexp: (pattern: string) => { + try { + return globToRegexp(pattern) as RegExp; + } catch (e: any) { + throw new Error( + `Invalid pattern: ${pattern}.\nConsult the documentation of glob-to-regexp here: https://www.npmjs.com/package/glob-to-regexp.\n${e.message}`, + ); + } + }, +}; diff --git a/packages/shared/src/utils/index.ts b/packages/shared/src/utils/index.ts index a6701783591..becca91c8fa 100644 --- a/packages/shared/src/utils/index.ts +++ b/packages/shared/src/utils/index.ts @@ -22,3 +22,4 @@ export * from './url'; export * from './workerTimers'; export * from './runWithExponentialBackOff'; export * from './isomorphicAtob'; +export * from './globs'; diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index 212c30d8b7a..484c6c8bbe8 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -486,6 +486,7 @@ export interface ClerkOptions { signUpUrl?: string; afterSignInUrl?: string; afterSignUpUrl?: string; + allowedRedirectOrigins?: string[]; /** * @experimental */