From 7e24a6ad7f333556ea15f4ffed1c8525b9e8cd53 Mon Sep 17 00:00:00 2001 From: Jacob Foshee Date: Mon, 28 Jul 2025 17:13:45 -0500 Subject: [PATCH 01/15] feat(clerk-js): Add OAuth Application URL and update Avatar component to support linking to external URL --- packages/clerk-js/sandbox/app.ts | 2 ++ .../components/OAuthConsent/OAuthConsent.tsx | 4 +++- packages/clerk-js/src/ui/elements/Avatar.tsx | 18 +++++++++++++++++- packages/types/src/clerk.ts | 4 ++++ 4 files changed, 26 insertions(+), 2 deletions(-) diff --git a/packages/clerk-js/sandbox/app.ts b/packages/clerk-js/sandbox/app.ts index c04981e2b77..23164516b36 100644 --- a/packages/clerk-js/sandbox/app.ts +++ b/packages/clerk-js/sandbox/app.ts @@ -330,6 +330,8 @@ void (async () => { scopes, oAuthApplicationName: searchParams.get('oauth-application-name'), redirectUrl: searchParams.get('redirect_uri'), + oAuthApplicationLogoUrl: 'https://picsum.photos/48/48', + oAuthApplicationUrl: 'https://google.com', }, ); }, diff --git a/packages/clerk-js/src/ui/components/OAuthConsent/OAuthConsent.tsx b/packages/clerk-js/src/ui/components/OAuthConsent/OAuthConsent.tsx index 6841e39dc2a..463a020afb7 100644 --- a/packages/clerk-js/src/ui/components/OAuthConsent/OAuthConsent.tsx +++ b/packages/clerk-js/src/ui/components/OAuthConsent/OAuthConsent.tsx @@ -17,7 +17,7 @@ import { common } from '@/ui/styledSystem'; import { colors } from '@/ui/utils/colors'; export function OAuthConsentInternal() { - const { scopes, oAuthApplicationName, oAuthApplicationLogoUrl, redirectUrl, onDeny, onAllow } = + const { scopes, oAuthApplicationName, oAuthApplicationLogoUrl, oAuthApplicationUrl, redirectUrl, onDeny, onAllow } = useOAuthConsentContext(); const { user } = useUser(); const { applicationName, logoImageUrl } = useEnvironment().displayConfig; @@ -44,6 +44,7 @@ export function OAuthConsentInternal() { t.space.$12} rounded={false} /> @@ -61,6 +62,7 @@ export function OAuthConsentInternal() { > t.space.$12} rounded={false} /> diff --git a/packages/clerk-js/src/ui/elements/Avatar.tsx b/packages/clerk-js/src/ui/elements/Avatar.tsx index 7f691fa032e..57e981d3dba 100644 --- a/packages/clerk-js/src/ui/elements/Avatar.tsx +++ b/packages/clerk-js/src/ui/elements/Avatar.tsx @@ -15,6 +15,11 @@ type AvatarProps = PropsOfComponent & { rounded?: boolean; boxElementDescriptor?: ElementDescriptor; imageElementDescriptor?: ElementDescriptor; + /** + * URL of the external link to navigate to when the avatar is clicked. + * Opens in a new tab. + */ + externalLinkUrl?: string; }; export const Avatar = (props: AvatarProps) => { @@ -28,6 +33,7 @@ export const Avatar = (props: AvatarProps) => { sx, boxElementDescriptor, imageElementDescriptor, + externalLinkUrl, } = props; const [error, setError] = React.useState(false); @@ -65,7 +71,17 @@ export const Avatar = (props: AvatarProps) => { sx, ]} > - {ImgOrFallback} + {externalLinkUrl && externalLinkUrl.trim() ? ( + + {ImgOrFallback} + + ) : ( + ImgOrFallback + )} {/* /** * This Box is the "shimmer" effect for the avatar. diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index 3abdb10a664..5f4cbb47927 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -1874,6 +1874,10 @@ export type __internal_OAuthConsentProps = { * Logo URL of the OAuth application. */ oAuthApplicationLogoUrl?: string; + /** + * URL of the OAuth application. + */ + oAuthApplicationUrl?: string; /** * Scopes requested by the OAuth application. */ From 72e540958cdc0e92fc7507ff06254e0015599cd9 Mon Sep 17 00:00:00 2001 From: Jacob Foshee Date: Wed, 30 Jul 2025 17:29:58 -0500 Subject: [PATCH 02/15] chore: add a task for running dev sandbox --- .vscode/tasks.json | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .vscode/tasks.json diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 00000000000..a5ae6130c3d --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,13 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "type": "npm", + "script": "dev:sandbox", + "path": "packages/clerk-js", + "problemMatcher": [], + "label": "Dev: Sandbox", + "detail": "npm: dev:sandbox - packages/clerk-js" + } + ] +} \ No newline at end of file From 373ded04dff2ddcc1f68083385fdb1f19cd81b5e Mon Sep 17 00:00:00 2001 From: Jacob Foshee Date: Wed, 30 Jul 2025 17:31:48 -0500 Subject: [PATCH 03/15] support query params for new fields in oauth consent e.g. http://localhost:4000/oauth-consent?scopes=email&oauth-application-name=Cursor+Chat&redirect_uri=https://google.com&logo-url=https://picsum.photos/48/48&app-url=https://google.com --- packages/clerk-js/sandbox/app.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/clerk-js/sandbox/app.ts b/packages/clerk-js/sandbox/app.ts index 23164516b36..9829fcc5d1a 100644 --- a/packages/clerk-js/sandbox/app.ts +++ b/packages/clerk-js/sandbox/app.ts @@ -330,8 +330,8 @@ void (async () => { scopes, oAuthApplicationName: searchParams.get('oauth-application-name'), redirectUrl: searchParams.get('redirect_uri'), - oAuthApplicationLogoUrl: 'https://picsum.photos/48/48', - oAuthApplicationUrl: 'https://google.com', + oAuthApplicationLogoUrl: searchParams.get('logo-url'), + oAuthApplicationUrl: searchParams.get('app-url'), }, ); }, From 8c20482ee191df92fbffd3e3b1a4eff7b0e8bbff Mon Sep 17 00:00:00 2001 From: Jacob Foshee Date: Wed, 30 Jul 2025 17:41:14 -0500 Subject: [PATCH 04/15] use spaces in tasks.json --- .vscode/tasks.json | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/.vscode/tasks.json b/.vscode/tasks.json index a5ae6130c3d..71f66cc4083 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -1,13 +1,13 @@ { - "version": "2.0.0", - "tasks": [ - { - "type": "npm", - "script": "dev:sandbox", - "path": "packages/clerk-js", - "problemMatcher": [], - "label": "Dev: Sandbox", - "detail": "npm: dev:sandbox - packages/clerk-js" - } - ] + "version": "2.0.0", + "tasks": [ + { + "type": "npm", + "script": "dev:sandbox", + "path": "packages/clerk-js", + "problemMatcher": [], + "label": "Dev: Sandbox", + "detail": "npm: dev:sandbox - packages/clerk-js" + } + ] } \ No newline at end of file From d66f708612d848232a25002bdece32c3fc6aeacd Mon Sep 17 00:00:00 2001 From: Jacob Foshee Date: Fri, 1 Aug 2025 17:06:47 -0500 Subject: [PATCH 05/15] add changeset --- .changeset/sad-turkeys-rhyme.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .changeset/sad-turkeys-rhyme.md diff --git a/.changeset/sad-turkeys-rhyme.md b/.changeset/sad-turkeys-rhyme.md new file mode 100644 index 00000000000..6b3bd8c8f09 --- /dev/null +++ b/.changeset/sad-turkeys-rhyme.md @@ -0,0 +1,8 @@ +--- +'@clerk/clerk-js': patch +'@clerk/types': patch +--- + +Add optional `externalLinkUrl` to `Avatar` + +Add optional `oAuthApplicationUrl` parameter to OAuth Consent mounting (which is used to provide a link to the OAuth App homepage). From efe0820aec9edb3a94f070d5d6a7528afd1ea0f1 Mon Sep 17 00:00:00 2001 From: Jacob Foshee Date: Fri, 8 Aug 2025 15:28:46 -0500 Subject: [PATCH 06/15] fix: use rather than add noreferrer to Link because it is undesirable to share all the consent query params --- packages/clerk-js/src/ui/elements/Avatar.tsx | 9 ++++----- packages/clerk-js/src/ui/primitives/Link.tsx | 2 +- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/clerk-js/src/ui/elements/Avatar.tsx b/packages/clerk-js/src/ui/elements/Avatar.tsx index 57e981d3dba..c0410ddc972 100644 --- a/packages/clerk-js/src/ui/elements/Avatar.tsx +++ b/packages/clerk-js/src/ui/elements/Avatar.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { Box, descriptors, Flex, Image, Text } from '../customizables'; +import { Box, descriptors, Flex, Image, Link, Text } from '../customizables'; import type { ElementDescriptor } from '../customizables/elementDescriptors'; import type { InternalTheme } from '../foundations'; import type { PropsOfComponent } from '../styledSystem'; @@ -72,13 +72,12 @@ export const Avatar = (props: AvatarProps) => { ]} > {externalLinkUrl && externalLinkUrl.trim() ? ( - {ImgOrFallback} - + ) : ( ImgOrFallback )} diff --git a/packages/clerk-js/src/ui/primitives/Link.tsx b/packages/clerk-js/src/ui/primitives/Link.tsx index 136ef88fa1a..fafa1c6b268 100644 --- a/packages/clerk-js/src/ui/primitives/Link.tsx +++ b/packages/clerk-js/src/ui/primitives/Link.tsx @@ -63,7 +63,7 @@ export const Link = (props: LinkProps): JSX.Element => { onClick={onClickHandler} href={href || ''} target={href && isExternal ? '_blank' : undefined} - rel={href && isExternal ? 'noopener' : undefined} + rel={href && isExternal ? 'noopener noreferrer' : undefined} css={applyVariants(props) as any} > {children} From dcfde0ee0458ef62ec4c2ed8393d0c4a6f3eaa26 Mon Sep 17 00:00:00 2001 From: Jacob Foshee Date: Fri, 8 Aug 2025 15:40:13 -0500 Subject: [PATCH 07/15] feat: add Avatar.linkUrl rather than externalLinkUrl and just apply isExternal if the link is absolute so this is more flexible --- .../ui/components/OAuthConsent/OAuthConsent.tsx | 2 +- packages/clerk-js/src/ui/elements/Avatar.tsx | 15 ++++++++------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/clerk-js/src/ui/components/OAuthConsent/OAuthConsent.tsx b/packages/clerk-js/src/ui/components/OAuthConsent/OAuthConsent.tsx index 463a020afb7..d3a1d5da79e 100644 --- a/packages/clerk-js/src/ui/components/OAuthConsent/OAuthConsent.tsx +++ b/packages/clerk-js/src/ui/components/OAuthConsent/OAuthConsent.tsx @@ -44,7 +44,7 @@ export function OAuthConsentInternal() { t.space.$12} rounded={false} /> diff --git a/packages/clerk-js/src/ui/elements/Avatar.tsx b/packages/clerk-js/src/ui/elements/Avatar.tsx index c0410ddc972..ced6c33b28b 100644 --- a/packages/clerk-js/src/ui/elements/Avatar.tsx +++ b/packages/clerk-js/src/ui/elements/Avatar.tsx @@ -1,3 +1,4 @@ +import { isAbsoluteUrl } from '@clerk/shared/url'; import React from 'react'; import { Box, descriptors, Flex, Image, Link, Text } from '../customizables'; @@ -16,10 +17,10 @@ type AvatarProps = PropsOfComponent & { boxElementDescriptor?: ElementDescriptor; imageElementDescriptor?: ElementDescriptor; /** - * URL of the external link to navigate to when the avatar is clicked. - * Opens in a new tab. + * URL to navigate to when the avatar is clicked. + * If it's an absolute URL, it opens in a new tab. */ - externalLinkUrl?: string; + linkUrl?: string; }; export const Avatar = (props: AvatarProps) => { @@ -33,7 +34,7 @@ export const Avatar = (props: AvatarProps) => { sx, boxElementDescriptor, imageElementDescriptor, - externalLinkUrl, + linkUrl, } = props; const [error, setError] = React.useState(false); @@ -71,10 +72,10 @@ export const Avatar = (props: AvatarProps) => { sx, ]} > - {externalLinkUrl && externalLinkUrl.trim() ? ( + {linkUrl && linkUrl.trim() ? ( {ImgOrFallback} From e87b64063ae15ea6e8102f2c671f585aa7c2eaf2 Mon Sep 17 00:00:00 2001 From: Jacob Foshee Date: Fri, 8 Aug 2025 15:45:18 -0500 Subject: [PATCH 08/15] fix patch notes --- .changeset/sad-turkeys-rhyme.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/sad-turkeys-rhyme.md b/.changeset/sad-turkeys-rhyme.md index 6b3bd8c8f09..77aecfd1393 100644 --- a/.changeset/sad-turkeys-rhyme.md +++ b/.changeset/sad-turkeys-rhyme.md @@ -3,6 +3,6 @@ '@clerk/types': patch --- -Add optional `externalLinkUrl` to `Avatar` +Add optional `linkUrl` to `Avatar` Add optional `oAuthApplicationUrl` parameter to OAuth Consent mounting (which is used to provide a link to the OAuth App homepage). From 92ede1ea5b3557182ac76ccb2da9c8530b44b221 Mon Sep 17 00:00:00 2001 From: Jacob Foshee Date: Fri, 8 Aug 2025 16:08:23 -0500 Subject: [PATCH 09/15] fix: missed one --- .../clerk-js/src/ui/components/OAuthConsent/OAuthConsent.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/clerk-js/src/ui/components/OAuthConsent/OAuthConsent.tsx b/packages/clerk-js/src/ui/components/OAuthConsent/OAuthConsent.tsx index d3a1d5da79e..bc32f054797 100644 --- a/packages/clerk-js/src/ui/components/OAuthConsent/OAuthConsent.tsx +++ b/packages/clerk-js/src/ui/components/OAuthConsent/OAuthConsent.tsx @@ -62,7 +62,7 @@ export function OAuthConsentInternal() { > t.space.$12} rounded={false} /> From 03576fd87887768fe965edfb425db30d6bb66a3f Mon Sep 17 00:00:00 2001 From: Jacob Foshee Date: Mon, 11 Aug 2025 18:04:59 -0500 Subject: [PATCH 10/15] revert changes to avatar --- packages/clerk-js/src/ui/elements/Avatar.tsx | 20 ++------------------ 1 file changed, 2 insertions(+), 18 deletions(-) diff --git a/packages/clerk-js/src/ui/elements/Avatar.tsx b/packages/clerk-js/src/ui/elements/Avatar.tsx index ced6c33b28b..7f691fa032e 100644 --- a/packages/clerk-js/src/ui/elements/Avatar.tsx +++ b/packages/clerk-js/src/ui/elements/Avatar.tsx @@ -1,7 +1,6 @@ -import { isAbsoluteUrl } from '@clerk/shared/url'; import React from 'react'; -import { Box, descriptors, Flex, Image, Link, Text } from '../customizables'; +import { Box, descriptors, Flex, Image, Text } from '../customizables'; import type { ElementDescriptor } from '../customizables/elementDescriptors'; import type { InternalTheme } from '../foundations'; import type { PropsOfComponent } from '../styledSystem'; @@ -16,11 +15,6 @@ type AvatarProps = PropsOfComponent & { rounded?: boolean; boxElementDescriptor?: ElementDescriptor; imageElementDescriptor?: ElementDescriptor; - /** - * URL to navigate to when the avatar is clicked. - * If it's an absolute URL, it opens in a new tab. - */ - linkUrl?: string; }; export const Avatar = (props: AvatarProps) => { @@ -34,7 +28,6 @@ export const Avatar = (props: AvatarProps) => { sx, boxElementDescriptor, imageElementDescriptor, - linkUrl, } = props; const [error, setError] = React.useState(false); @@ -72,16 +65,7 @@ export const Avatar = (props: AvatarProps) => { sx, ]} > - {linkUrl && linkUrl.trim() ? ( - - {ImgOrFallback} - - ) : ( - ImgOrFallback - )} + {ImgOrFallback} {/* /** * This Box is the "shimmer" effect for the avatar. From d10fb15d77718a50fb48a7e6a703d8b9afcc6a68 Mon Sep 17 00:00:00 2001 From: Jacob Foshee Date: Tue, 12 Aug 2025 14:50:34 -0500 Subject: [PATCH 11/15] style: whitespace --- .../clerk-js/src/ui/components/OAuthConsent/OAuthConsent.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/clerk-js/src/ui/components/OAuthConsent/OAuthConsent.tsx b/packages/clerk-js/src/ui/components/OAuthConsent/OAuthConsent.tsx index 33324ae0624..a8efa9ea895 100644 --- a/packages/clerk-js/src/ui/components/OAuthConsent/OAuthConsent.tsx +++ b/packages/clerk-js/src/ui/components/OAuthConsent/OAuthConsent.tsx @@ -46,7 +46,7 @@ export function OAuthConsentInternal() { @@ -66,7 +66,7 @@ export function OAuthConsentInternal() { Date: Tue, 12 Aug 2025 15:46:43 -0500 Subject: [PATCH 12/15] feat(clerk-js): add `isExternal` to `ApplicationLogo` so users can safely open the link in a new window --- .../components/OAuthConsent/OAuthConsent.tsx | 2 ++ .../src/ui/elements/ApplicationLogo.tsx | 31 ++++++++++++++----- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/packages/clerk-js/src/ui/components/OAuthConsent/OAuthConsent.tsx b/packages/clerk-js/src/ui/components/OAuthConsent/OAuthConsent.tsx index a8efa9ea895..d40ed41d158 100644 --- a/packages/clerk-js/src/ui/components/OAuthConsent/OAuthConsent.tsx +++ b/packages/clerk-js/src/ui/components/OAuthConsent/OAuthConsent.tsx @@ -47,6 +47,7 @@ export function OAuthConsentInternal() { src={oAuthApplicationLogoUrl} alt={oAuthApplicationName} href={oAuthApplicationUrl} + isExternal /> @@ -67,6 +68,7 @@ export function OAuthConsentInternal() { src={oAuthApplicationLogoUrl} alt={oAuthApplicationName} href={oAuthApplicationUrl} + isExternal /> & { * The URL to navigate to when the logo is clicked. */ href?: string; + /** + * Whether the href should be treated as an external link. + * When true, uses a Link component with target="_blank" and proper security attributes. + * When false or undefined, uses RouterLink for internal navigation. + */ + isExternal?: boolean; }; export const ApplicationLogo: React.FC = (props: ApplicationLogoProps): JSX.Element | null => { - const { src, alt, href, sx, ...rest } = props; + const { src, alt, href, isExternal, sx, ...rest } = props; const imageRef = React.useRef(null); const [loaded, setLoaded] = React.useState(false); const { logoImageUrl, applicationName, homeUrl } = useEnvironment().displayConfig; @@ -80,12 +87,22 @@ export const ApplicationLogo: React.FC = (props: Applicati ]} > {logoUrl ? ( - - {image} - + isExternal ? ( + + {image} + + ) : ( + + {image} + + ) ) : ( image )} From d437ac9fd6cf168a860cb5583d2f732d88de66d9 Mon Sep 17 00:00:00 2001 From: Jacob Foshee Date: Tue, 12 Aug 2025 16:51:59 -0500 Subject: [PATCH 13/15] feat(clerk-js): implement href sanitization to prevent XSS attacks in Link component There is an existing function `hasBannedProtocol` which explicitly allows data: protocol in a test. So I did not use that function as data: is dangerous for links. --- packages/clerk-js/src/ui/primitives/Link.tsx | 12 ++- .../clerk-js/src/utils/__tests__/url.spec.ts | 76 ++++++++++++++++++- packages/clerk-js/src/utils/url.ts | 65 ++++++++++++++++ 3 files changed, 148 insertions(+), 5 deletions(-) diff --git a/packages/clerk-js/src/ui/primitives/Link.tsx b/packages/clerk-js/src/ui/primitives/Link.tsx index 5e26c7b0f4f..c724e659779 100644 --- a/packages/clerk-js/src/ui/primitives/Link.tsx +++ b/packages/clerk-js/src/ui/primitives/Link.tsx @@ -1,5 +1,6 @@ import React from 'react'; +import { sanitizeHref } from '../../utils/url'; import type { PrimitiveProps, StyleVariants } from '../styledSystem'; import { common, createVariants } from '../styledSystem'; import { applyDataStateProps } from './applyDataStateProps'; @@ -57,9 +58,12 @@ export type LinkProps = PrimitiveProps<'a'> & OwnProps & StyleVariants { const { isExternal, children, href, onClick, ...rest } = props; + // Sanitize href to prevent dangerous protocols + const sanitizedHref = sanitizeHref(href); + const onClickHandler = onClick ? (e: React.MouseEvent) => { - if (!href) { + if (!sanitizedHref) { e.preventDefault(); } onClick(e); @@ -70,9 +74,9 @@ export const Link = (props: LinkProps): JSX.Element => { {children} diff --git a/packages/clerk-js/src/utils/__tests__/url.spec.ts b/packages/clerk-js/src/utils/__tests__/url.spec.ts index 9e37b01cc08..10f2dc8f4c4 100644 --- a/packages/clerk-js/src/utils/__tests__/url.spec.ts +++ b/packages/clerk-js/src/utils/__tests__/url.spec.ts @@ -7,6 +7,7 @@ import { createAllowedRedirectOrigins, getETLDPlusOneFromFrontendApi, getSearchParameterFromHash, + hasBannedHrefProtocol, hasBannedProtocol, hasExternalAccountSignUpError, isAllowedRedirect, @@ -18,6 +19,7 @@ import { mergeFragmentIntoUrl, relativeToAbsoluteUrl, requiresUserInput, + sanitizeHref, trimLeadingSlash, trimTrailingSlash, } from '../url'; @@ -36,7 +38,7 @@ describe('isDevAccountPortalOrigin(url)', () => { ]; test.each(goodUrls)('.isDevAccountPortalOrigin(%s)', (a, expected) => { - // @ts-ignore + // @ts-ignore - Type assertion for test parameter expect(isDevAccountPortalOrigin(a)).toBe(expected); }); }); @@ -147,6 +149,78 @@ describe('hasBannedProtocol(url)', () => { }); }); +describe('hasBannedHrefProtocol(url)', () => { + const cases: Array<[string, boolean]> = [ + ['https://www.clerk.com/', false], + ['http://www.clerk.com/', false], + ['/sign-in', false], + ['/sign-in?test=1', false], + ['/?test', false], + ['javascript:console.log(document.cookies)', true], + ['data:image/png;base64,iVBORw0KGgoAAA5ErkJggg==', true], + ['vbscript:alert("xss")', true], + ['blob:https://example.com/12345678-1234-1234-1234-123456789012', true], + ['ftp://files.example.com/file.txt', false], + ['mailto:user@example.com', false], + ]; + + test.each(cases)('.hasBannedHrefProtocol(%s)', (a, expected) => { + expect(hasBannedHrefProtocol(a)).toBe(expected); + }); +}); + +describe('sanitizeHref(href)', () => { + const cases: Array<[string | undefined | null, string | null]> = [ + // Null/undefined/empty cases + [null, null], + [undefined, null], + ['', null], + + // Safe relative URLs + ['/path/to/page', '/path/to/page'], + ['#anchor', '#anchor'], + ['?query=param', '?query=param'], + ['../relative/path', '../relative/path'], + ['relative/path', 'relative/path'], + ['path/page#anchor', 'path/page#anchor'], + + // Safe absolute URLs + ['https://www.clerk.com/', 'https://www.clerk.com/'], + ['http://localhost:3000/path', 'http://localhost:3000/path'], + ['ftp://files.example.com/file.txt', 'ftp://files.example.com/file.txt'], + ['mailto:user@example.com', 'mailto:user@example.com'], + + // Dangerous protocols - should return null + ['javascript:alert("xss")', null], + ['javascript:console.log(document.cookies)', null], + ['data:text/html,', null], + ['data:text/html;base64,PHNjcmlwdD5hbGVydCgnWFNTIGZyb20gZGF0YSBVUkknKTwvc2NyaXB0Pg==', null], + ['data:image/png;base64,iVBORw0KGgoAAA5ErkJggg==', null], + ['vbscript:alert("xss")', null], + ['blob:https://example.com/12345678-1234-1234-1234-123456789012', null], + + // Sneaky cases with dangerous protocols + ['JAVASCRIPT:alert("xss")', null], // All caps protocol + ['JavaScript:alert("xss")', null], // Mixed case + [' javascript:alert("xss") ', null], // Whitespace + ['javascript: alert("xss") ', null], // Whitespace + + // Malformed URLs that might be relative paths + ['not-a-url', 'not-a-url'], + ['path:with:colons', 'path:with:colons'], + ]; + + test.each(cases)('.sanitizeHref(%s)', (href, expected) => { + expect(sanitizeHref(href)).toBe(expected); + }); + + it('handles malformed URLs gracefully', () => { + // These should not throw errors and should be allowed as potential relative URLs + expect(sanitizeHref(':::invalid:::')).toBe(':::invalid:::'); + expect(sanitizeHref('malformed:url:here')).toBe('malformed:url:here'); + }); +}); + describe('buildURL(options: URLParams, skipOrigin)', () => { it('builds a URL()', () => { expect(buildURL({}, { stringify: true })).toBe('http://localhost:3000/'); diff --git a/packages/clerk-js/src/utils/url.ts b/packages/clerk-js/src/utils/url.ts index d5b69503538..c4af6761c48 100644 --- a/packages/clerk-js/src/utils/url.ts +++ b/packages/clerk-js/src/utils/url.ts @@ -21,6 +21,9 @@ const DUMMY_URL_BASE = 'http://clerk-dummy'; const BANNED_URI_PROTOCOLS = ['javascript:'] as const; +// Protocols that are dangerous specifically for href attributes in links +const BANNED_HREF_PROTOCOLS = ['javascript:', 'data:', 'vbscript:', 'blob:'] as const; + const { isDevOrStagingUrl } = createDevOrStagingUrlCache(); export { isDevOrStagingUrl }; const accountPortalCache = new Map(); @@ -276,6 +279,16 @@ export function isDataUri(val?: string): val is string { return new URL(val).protocol === 'data:'; } +/** + * Checks if a URL uses javascript: protocol. + * This prevents some XSS attacks through javascript: URLs. + * + * IMPORTANT: This does not check for `data:` or other protocols which + * are dangerous if used for links or setting the window location. + * + * @param val - The URL to check + * @returns True if the URL contains a banned protocol, false otherwise + */ export function hasBannedProtocol(val: string | URL) { if (!isValidUrl(val)) { return false; @@ -284,6 +297,58 @@ export function hasBannedProtocol(val: string | URL) { return BANNED_URI_PROTOCOLS.some(bp => bp === protocol); } +/** + * Checks if a URL contains a banned protocol for href attributes in links. + * This prevents some XSS attacks through javascript:, data:, vbscript:, and blob: URLs. + * + * @param val - The URL to check + * @returns True if the URL contains a banned protocol, false otherwise + */ +export function hasBannedHrefProtocol(val: string | URL) { + if (!isValidUrl(val)) { + return false; + } + const protocol = new URL(val).protocol; + return BANNED_HREF_PROTOCOLS.some(bp => bp === protocol); +} + +/** + * Sanitizes an href value by checking for dangerous protocols. + * Returns null if the href contains a dangerous protocol, otherwise returns the original href. + * This prevents some XSS attacks through javascript:, data:, vbscript:, and blob: URLs. + * + * @param href - The href value to sanitize + * @returns The sanitized href or null if dangerous + */ +export function sanitizeHref(href: string | undefined | null): string | null { + if (!href) { + return null; + } + + // For relative URLs (starting with / or # or ?), allow them through + if (href.startsWith('/') || href.startsWith('#') || href.startsWith('?')) { + return href; + } + + // For relative URLs without leading slash, allow them through + if (!href.includes(':')) { + return href; + } + + // Check if it's a valid URL with a dangerous protocol + try { + const url = new URL(href); + if (hasBannedHrefProtocol(url)) { + return null; + } + return href; + } catch { + // If URL parsing fails, it's likely a relative URL or malformed + // Allow relative URLs through, but be cautious with malformed ones + return href; + } +} + export const hasUrlInFragment = (_url: URL | string) => { return new URL(_url, DUMMY_URL_BASE).hash.startsWith('#/'); }; From b23e9673009ec329446908ad379010081a3d70d4 Mon Sep 17 00:00:00 2001 From: Jacob Foshee Date: Tue, 12 Aug 2025 17:23:08 -0500 Subject: [PATCH 14/15] feedback --- packages/clerk-js/src/utils/__tests__/url.spec.ts | 1 + packages/clerk-js/src/utils/url.ts | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/clerk-js/src/utils/__tests__/url.spec.ts b/packages/clerk-js/src/utils/__tests__/url.spec.ts index 10f2dc8f4c4..3c368500718 100644 --- a/packages/clerk-js/src/utils/__tests__/url.spec.ts +++ b/packages/clerk-js/src/utils/__tests__/url.spec.ts @@ -175,6 +175,7 @@ describe('sanitizeHref(href)', () => { [null, null], [undefined, null], ['', null], + [' ', null], // Safe relative URLs ['/path/to/page', '/path/to/page'], diff --git a/packages/clerk-js/src/utils/url.ts b/packages/clerk-js/src/utils/url.ts index c4af6761c48..9f511e82e6b 100644 --- a/packages/clerk-js/src/utils/url.ts +++ b/packages/clerk-js/src/utils/url.ts @@ -304,7 +304,7 @@ export function hasBannedProtocol(val: string | URL) { * @param val - The URL to check * @returns True if the URL contains a banned protocol, false otherwise */ -export function hasBannedHrefProtocol(val: string | URL) { +export function hasBannedHrefProtocol(val: string | URL): boolean { if (!isValidUrl(val)) { return false; } @@ -321,7 +321,7 @@ export function hasBannedHrefProtocol(val: string | URL) { * @returns The sanitized href or null if dangerous */ export function sanitizeHref(href: string | undefined | null): string | null { - if (!href) { + if (!href || href.trim() === '') { return null; } From 96dc277de4e55933304304dbb073ac423097f24d Mon Sep 17 00:00:00 2001 From: Jacob Foshee Date: Tue, 12 Aug 2025 17:31:39 -0500 Subject: [PATCH 15/15] Update sad-turkeys-rhyme.md --- .changeset/sad-turkeys-rhyme.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.changeset/sad-turkeys-rhyme.md b/.changeset/sad-turkeys-rhyme.md index 77aecfd1393..34b19fa4825 100644 --- a/.changeset/sad-turkeys-rhyme.md +++ b/.changeset/sad-turkeys-rhyme.md @@ -3,6 +3,8 @@ '@clerk/types': patch --- -Add optional `linkUrl` to `Avatar` +Add optional `isExternal` to `ApplicationLogo` Add optional `oAuthApplicationUrl` parameter to OAuth Consent mounting (which is used to provide a link to the OAuth App homepage). + +Harden `Link` component so it sanitizes the given `href` to avoid dangerous protocols.