diff --git a/.changeset/fix-allowed-redirect-origins-port.md b/.changeset/fix-allowed-redirect-origins-port.md new file mode 100644 index 00000000000..f798c3583c8 --- /dev/null +++ b/.changeset/fix-allowed-redirect-origins-port.md @@ -0,0 +1,5 @@ +--- +"@clerk/shared": patch +--- + +Fix satellite domain redirect validation incorrectly rejecting URLs with non-default ports. Redirect URLs like `https://app.example.net:5173` (common in local development with Vite or similar dev servers) are now correctly matched against `allowedRedirectOrigins` patterns such as `https://*.example.net`. diff --git a/packages/shared/src/internal/clerk-js/__tests__/url.test.ts b/packages/shared/src/internal/clerk-js/__tests__/url.test.ts index 63927fae344..84f7abe09df 100644 --- a/packages/shared/src/internal/clerk-js/__tests__/url.test.ts +++ b/packages/shared/src/internal/clerk-js/__tests__/url.test.ts @@ -579,6 +579,17 @@ describe('isAllowedRedirect', () => { ['https:evil.com', [/https:\/\/www\.clerk\.com/], false], ['//evil.com', [/https:\/\/www\.clerk\.com/], false], ['..//evil.com', ['https://www.clerk.com'], false], + // non-default ports — satellite apps in local dev (e.g. Vite on :5173) + // wildcard glob entries accept any port on the matching host + ['https://app.example.net:5173', ['https://*.example.net'], true], + ['https://app.example.net:5176', ['https://*.example.net'], true], + ['https://www.clerk.com:3000', ['https://*.clerk.com'], true], + // exact string entries remain port-sensitive (port is part of the origin) + ['https://www.clerk.com:3000', ['https://www.clerk.com'], false], + ['https://www.clerk.com:3000/path?q=1', ['https://www.clerk.com'], false], + // non-default port must not bypass domain validation + ['https://evil.com:5173', ['https://*.clerk.com'], false], + ['https://evil.clerk.com.evil.com:5173', ['https://*.clerk.com'], false], ]; const warnMock = vi.spyOn(logger, 'warnOnce'); diff --git a/packages/shared/src/internal/clerk-js/url.ts b/packages/shared/src/internal/clerk-js/url.ts index 6c524febf4a..805eb2c4d11 100644 --- a/packages/shared/src/internal/clerk-js/url.ts +++ b/packages/shared/src/internal/clerk-js/url.ts @@ -424,7 +424,11 @@ export function requiresUserInput(redirectUrl: string): boolean { } export const isAllowedRedirect = - (allowedRedirectOrigins: Array | undefined, currentOrigin: string) => (_url: URL | string) => { + ( + allowedRedirectOrigins: Array | undefined, + currentOrigin: string, + ): ((_url: URL | string) => boolean) => + (_url: URL | string): boolean => { let url = _url; if (typeof url === 'string') { url = relativeToAbsoluteUrl(url, currentOrigin); @@ -436,12 +440,22 @@ export const isAllowedRedirect = const isSameOrigin = currentOrigin === url.origin; + // When the redirect URL includes a non-default port (common in local dev, e.g. :5173), + // url.origin includes the port (https://app.example.net:5173) while glob entries like + // https://*.example.net have none. For wildcard/glob entries only, also test the + // port-stripped origin so satellite apps on custom ports are not rejected. + // Exact string entries (no wildcard) remain port-sensitive to preserve the security boundary. + const portlessOrigin = url.port !== '' ? `${url.protocol}//${url.hostname}` : null; + const isAllowed = !isProblematicUrl(url) && (isSameOrigin || - allowedRedirectOrigins - .map(origin => (typeof origin === 'string' ? globs.toRegexp(trimTrailingSlash(origin)) : origin)) - .some(origin => origin.test(trimTrailingSlash(url.origin)))); + allowedRedirectOrigins.some(origin => { + const pattern = typeof origin === 'string' ? globs.toRegexp(trimTrailingSlash(origin)) : origin; + if (pattern.test(trimTrailingSlash(url.origin))) return true; + const isGlobEntry = typeof origin === 'string' && origin.includes('*'); + return isGlobEntry && portlessOrigin !== null && pattern.test(portlessOrigin); + })); if (!isAllowed) { logger.warnOnce(