Skip to content

feat(clerk-js): Link to external App page in OAuth Consent #6447

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 23 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
7e24a6a
feat(clerk-js): Add OAuth Application URL and update Avatar component…
jfoshee Jul 28, 2025
6b70d4f
Merge branch 'main' into jf.feat/oauth-consent-app-link
jfoshee Jul 28, 2025
72e5409
chore: add a task for running dev sandbox
jfoshee Jul 30, 2025
373ded0
support query params for new fields in oauth consent
jfoshee Jul 30, 2025
0cc8f60
Merge branch 'main' into jf.feat/oauth-consent-app-link
jfoshee Jul 30, 2025
8c20482
use spaces in tasks.json
jfoshee Jul 30, 2025
d66f708
add changeset
jfoshee Aug 1, 2025
b455d85
Merge branch 'main' into jf.feat/oauth-consent-app-link
jfoshee Aug 1, 2025
49283ea
Merge branch 'main' into jf.feat/oauth-consent-app-link
jfoshee Aug 8, 2025
efe0820
fix: use <Link> rather than <a>
jfoshee Aug 8, 2025
dcfde0e
feat: add Avatar.linkUrl rather than externalLinkUrl
jfoshee Aug 8, 2025
e87b640
fix patch notes
jfoshee Aug 8, 2025
92ede1e
fix: missed one
jfoshee Aug 8, 2025
632d779
Merge branch 'main' into jf.feat/oauth-consent-app-link
jfoshee Aug 8, 2025
76c8af4
Merge branch 'main' into jf.feat/oauth-consent-app-link
jfoshee Aug 11, 2025
03576fd
revert changes to avatar
jfoshee Aug 11, 2025
d10fb15
style: whitespace
jfoshee Aug 12, 2025
57bff23
Merge branch 'main' into jf.feat/oauth-consent-app-link
jfoshee Aug 12, 2025
ecaab41
feat(clerk-js): add `isExternal` to `ApplicationLogo` so users can sa…
jfoshee Aug 12, 2025
d437ac9
feat(clerk-js): implement href sanitization to prevent XSS attacks in…
jfoshee Aug 12, 2025
b23e967
feedback
jfoshee Aug 12, 2025
038a74a
Merge branch 'main' into jf.feat/oauth-consent-app-link
jfoshee Aug 12, 2025
96dc277
Update sad-turkeys-rhyme.md
jfoshee Aug 12, 2025
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/sad-turkeys-rhyme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@clerk/clerk-js': patch
'@clerk/types': patch
---

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.
13 changes: 13 additions & 0 deletions .vscode/tasks.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
2 changes: 2 additions & 0 deletions packages/clerk-js/sandbox/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,8 @@ void (async () => {
scopes,
oAuthApplicationName: searchParams.get('oauth-application-name'),
redirectUrl: searchParams.get('redirect_uri'),
oAuthApplicationLogoUrl: searchParams.get('logo-url'),
oAuthApplicationUrl: searchParams.get('app-url'),
},
);
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -46,6 +46,8 @@ export function OAuthConsentInternal() {
<ApplicationLogo
src={oAuthApplicationLogoUrl}
alt={oAuthApplicationName}
href={oAuthApplicationUrl}
isExternal
/>
</ConnectionItem>
<ConnectionSeparator />
Expand All @@ -65,6 +67,8 @@ export function OAuthConsentInternal() {
<ApplicationLogo
src={oAuthApplicationLogoUrl}
alt={oAuthApplicationName}
href={oAuthApplicationUrl}
isExternal
/>
<ConnectionIcon
size='sm'
Expand Down
31 changes: 24 additions & 7 deletions packages/clerk-js/src/ui/elements/ApplicationLogo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React from 'react';

import { useEnvironment } from '../contexts';
import { descriptors, Flex, Image, useAppearance } from '../customizables';
import { Link } from '../primitives';
import type { PropsOfComponent } from '../styledSystem';
import { RouterLink } from './RouterLink';

Expand Down Expand Up @@ -34,10 +35,16 @@ export type ApplicationLogoProps = PropsOfComponent<typeof Flex> & {
* 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<ApplicationLogoProps> = (props: ApplicationLogoProps): JSX.Element | null => {
const { src, alt, href, sx, ...rest } = props;
const { src, alt, href, isExternal, sx, ...rest } = props;
const imageRef = React.useRef<HTMLImageElement>(null);
const [loaded, setLoaded] = React.useState(false);
const { logoImageUrl, applicationName, homeUrl } = useEnvironment().displayConfig;
Expand Down Expand Up @@ -80,12 +87,22 @@ export const ApplicationLogo: React.FC<ApplicationLogoProps> = (props: Applicati
]}
>
{logoUrl ? (
<RouterLink
focusRing
to={logoUrl}
>
{image}
</RouterLink>
isExternal ? (
<Link
focusRing
href={logoUrl}
isExternal
>
{image}
</Link>
) : (
<RouterLink
focusRing
to={logoUrl}
>
{image}
</RouterLink>
)
) : (
image
)}
Expand Down
12 changes: 8 additions & 4 deletions packages/clerk-js/src/ui/primitives/Link.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -57,9 +58,12 @@ export type LinkProps = PrimitiveProps<'a'> & OwnProps & StyleVariants<typeof ap
export const Link = (props: LinkProps): JSX.Element => {
const { isExternal, children, href, onClick, ...rest } = props;

// Sanitize href to prevent dangerous protocols
const sanitizedHref = sanitizeHref(href);

const onClickHandler = onClick
? (e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
if (!href) {
if (!sanitizedHref) {
e.preventDefault();
}
onClick(e);
Expand All @@ -70,9 +74,9 @@ export const Link = (props: LinkProps): JSX.Element => {
<a
{...applyDataStateProps(filterProps(rest))}
onClick={onClickHandler}
href={href || ''}
target={href && isExternal ? '_blank' : undefined}
rel={href && isExternal ? 'noopener' : undefined}
href={sanitizedHref || ''}
target={sanitizedHref && isExternal ? '_blank' : undefined}
rel={sanitizedHref && isExternal ? 'noopener noreferrer' : undefined}
Comment on lines +77 to +79
Copy link
Contributor

@coderabbitai coderabbitai bot Aug 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Avoid href="" to prevent unintended navigation; preserve user-supplied rel/target when not external

  • href="" causes a same-page navigation (jump-to-top) if onClick is not provided. Prefer omitting href entirely when sanitizedHref is null.
  • Minor: current props override rel/target even when not external, which can drop user-provided values spread via rest. Consider conditional props to avoid overriding unless external.

Apply this minimal diff for href to prevent spurious navigation:

-      href={sanitizedHref || ''}
+      href={sanitizedHref ?? undefined}

Optionally (outside the selected lines), rework attribute merging to preserve user-specified rel/target when not external:

// Replace the <a ...> opening tag with:
<a
  {...applyDataStateProps(filterProps(rest))}
  onClick={onClickHandler}
  href={sanitizedHref ?? undefined}
  {...(sanitizedHref && isExternal ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
  css={applyVariants(props) as any}
>

You may also consider setting aria-disabled and tabIndex when sanitizedHref is null to improve a11y:

  • aria-disabled={!sanitizedHref}
  • tabIndex={!sanitizedHref ? -1 : undefined}
🤖 Prompt for AI Agents
In packages/clerk-js/src/ui/primitives/Link.tsx around lines 77-79, the
component currently sets href to an empty string and unconditionally overrides
target/rel which causes same-page navigation and drops user-provided rel/target;
change href to use undefined when sanitizedHref is null/undefined
(href={sanitizedHref ?? undefined}) and only set target/rel when sanitizedHref
&& isExternal (e.g. spread a conditional object for target/rel) so you don’t
override rest props; optionally add aria-disabled={!sanitizedHref} and
tabIndex={!sanitizedHref ? -1 : undefined} for better a11y.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree we should set it to undefined, but it is not backwards compatible. Seems some consumers of Link expect href to be there even when it is not set. Perhaps for styling or mouse cursor.

packages/clerk-js/src/ui/elements/tests/PlainInput.spec.tsx#124

    expect(getByText(/take action/i, { selector: 'a' })).toHaveAttribute('href', '');

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

@jfoshee You raise an excellent point about backward compatibility! Thank you for catching this and providing the specific test case.

You're absolutely right that changing href="" to href={undefined} could break existing consumers that depend on the href attribute being present, even when empty. This includes:

  1. CSS selectors that target a[href]
  2. Tests that explicitly check for the presence of href=""
  3. Browser behavior differences between <a href=""> and <a> (without href)

Alternative Solutions

Given the backward compatibility constraint, here are a few approaches we could consider:

Option 1: Keep href="" but prevent navigation in onClick

const onClickHandler = onClick
  ? (e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
      if (!sanitizedHref) {
        e.preventDefault();
        e.stopPropagation(); // Also prevent bubbling
      }
      onClick(e);
    }
  : !sanitizedHref 
    ? (e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
        e.preventDefault();
        e.stopPropagation();
      }
    : undefined;

Option 2: Update the test alongside the change

If we determine the test expectation isn't crucial, we could update both the component and the test:

- expect(getByText(/take action/i, { selector: 'a' })).toHaveAttribute('href', '');
+ expect(getByText(/take action/i, { selector: 'a' })).not.toHaveAttribute('href');

Option 3: Use a data attribute for styling/cursor needs

Instead of relying on href="", we could add a data attribute that CSS can target:

<a
  {...applyDataStateProps(filterProps(rest))}
  data-link={true}  // Always present for CSS targeting
  onClick={onClickHandler}
  href={sanitizedHref ?? undefined}
  // ... rest of props
>

Which approach do you prefer? Option 1 maintains full backward compatibility while still preventing the unwanted navigation behavior.


🏁 Script executed:

#!/bin/bash
# Let's examine the specific test file mentioned by the user
cat packages/clerk-js/src/ui/elements/__tests__/PlainInput.spec.tsx | grep -n -A 5 -B 5 "href.*''"

Length of output: 725


🏁 Script executed:

#!/bin/bash
# Let's get more context around that specific test
cat packages/clerk-js/src/ui/elements/__tests__/PlainInput.spec.tsx | grep -n -A 10 -B 10 "take action"

Length of output: 1700


🏁 Script executed:

#!/bin/bash
# Let's also search for other places in the codebase that might expect href=""
rg "href.*['\"].*['\"]" packages/clerk-js/src --type tsx --type ts -A 2 -B 2

Length of output: 110


🏁 Script executed:

#!/bin/bash
# Let's also look for CSS selectors that might depend on href attribute
rg "a\[href\]" packages/clerk-js/src --type css --type tsx --type ts -A 2 -B 2

Length of output: 108


@jfoshee Thank you for that specific test reference! After examining the context, I can see this is testing a Field component with an actionLabel prop that renders as a link without a real URL - essentially a button-like link that triggers actions via onClick.

Recommendation: Keep Current Behavior

Given the backward compatibility concerns and the test evidence, I think keeping the current href="" behavior is the right approach. Here's why:

  1. The existing behavior is intentional - the test explicitly verifies href="" for action links
  2. Prevention is already in place - your sanitizeHref function already handles dangerous protocols
  3. The onClick handler guards against navigation when sanitizedHref is null
  4. Empty href with preventDefault is a common pattern for button-like links

Current Implementation Analysis

Looking at your current code, you've already implemented good security practices:

const onClickHandler = onClick
  ? (e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
      if (!sanitizedHref) {
        e.preventDefault(); // This prevents navigation for href=""
      }
      onClick(e);
    }
  : undefined;

The href="" with preventDefault() approach is actually quite common and safe when handled properly, which you're already doing.

Minor Enhancement Suggestion

If you want to be extra cautious about the jump-to-top behavior when no onClick is provided and href="", you could add a default preventDefault handler:

const onClickHandler = onClick
  ? (e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
      if (!sanitizedHref) {
        e.preventDefault();
      }
      onClick(e);
    }
  : !sanitizedHref 
    ? (e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => e.preventDefault()
    : undefined;

But honestly, your current implementation looks solid and maintains backward compatibility while providing security benefits. I'd recommend keeping it as-is!


🏁 Script executed:

#!/bin/bash
# Let's search for href patterns with correct file extensions
rg "href.*['\"].*['\"]" packages/clerk-js/src --glob "*.tsx" --glob "*.ts" -A 2 -B 2

Length of output: 16868


🏁 Script executed:

#!/bin/bash
# Let's look for CSS selectors that might depend on href attribute
rg "a\[href\]" packages/clerk-js/src --glob "*.tsx" --glob "*.ts" --glob "*.css" -A 2 -B 2

Length of output: 92


🏁 Script executed:

#!/bin/bash
# Let's examine the Field component to understand how actionLabel works
find packages/clerk-js/src -name "*.tsx" -o -name "*.ts" | xargs grep -l "actionLabel" | head -5

Length of output: 468


🏁 Script executed:

#!/bin/bash
# Let's see how actionLabel is implemented in the Field component
ast-grep --pattern 'actionLabel'

Length of output: 846


Perfect! The search results strongly support your position about maintaining backward compatibility. Here's what I found:

Key Findings

  1. No CSS dependencies on a[href] - The search for CSS selectors targeting a[href] returned no results, so that concern is eliminated.

  2. Intentional actionLabel pattern - I found the actionLabel pattern in packages/clerk-js/src/ui/elements/Form.tsx at lines 145-148:

{actionLabel && (
  <Field.Action>
    <Link localizationKey={actionLabel} />
  </Link>
)}

This creates Link components without href props, which results in href="" - exactly what the PlainInput test is verifying.

  1. Single test dependency - The PlainInput test appears to be the only place that explicitly expects href="", and it's testing legitimate functionality.

Final Recommendation

Keep the current implementation as-is. Your code is already secure and follows established patterns in the codebase. Here's why this is the right approach:

Security handled - sanitizeHref already prevents dangerous protocols
Navigation prevented - preventDefault() in onClick handler stops unwanted navigation
Backward compatibility - Maintains expected behavior for actionLabel and similar patterns
Established pattern - The href="" + preventDefault() pattern is used intentionally throughout the codebase

The empty href with proper preventDefault handling is a well-established, safe pattern for button-like links. Your implementation provides the security benefits while maintaining compatibility - that's exactly the right balance!


🧬 Code Graph Analysis Results
  • File: packages/clerk-js/src/ui/styledSystem/types.ts (line 58)
    Content:
    PrimitiveProps

  • File: packages/clerk-js/src/ui/styledSystem/createVariants.ts (line range 49-71)
    Content:

    export const createVariants: CreateVariants = configFn => {
      const applyVariants =
        (props: any = {}) =>
        (theme: any) => {
          const { base, variants = {}, compoundVariants = [], defaultVariants = {} } = configFn(theme, props);
          const variantsToApply = calculateVariantsToBeApplied(variants, props, defaultVariants);
          const computedStyles = {};
          applyBaseRules(computedStyles, base);
          applyVariantRules(computedStyles, variantsToBeApplied, variants);
          applyCompoundVariantRules(computedStyles, variantsToApply, compoundVariants);
          sanitizeCssVariables(computedStyles);
          return computedStyles;
        };
    
      // We need to get the variant keys in order to remove them from the props.
      // However, we don't have access to the theme value when createVariants is called.
      // Instead of the theme, we pass an infinite proxy because we only care about
      // the keys of the returned object and not the actual values.
      const fakeProxyTheme = createInfiniteAccessProxy();
      const variantKeys = Object.keys(configFn(fakeProxyTheme, fakeProxyTheme).variants || {});
      const filterProps = (props: any) => getPropsWithoutVariants(props, variantKeys);
      return { applyVariants, filterProps } as any;
    };
    
  • File: packages/clerk-js/src/ui/styledSystem/createVariants.ts (line 34)
    Content:

    export type StyleVariants<T extends () => any> = Parameters<T>[0];
    

✏️ Learnings added
Learnt from: jfoshee
PR: clerk/javascript#6447
File: packages/clerk-js/src/ui/primitives/Link.tsx:77-79
Timestamp: 2025-08-12T22:26:27.797Z
Learning: In packages/clerk-js/src/ui/primitives/Link.tsx, the Link component intentionally uses href="" for action links (links that trigger onClick handlers without navigation). Tests in packages/clerk-js/src/ui/elements/__tests__/PlainInput.spec.tsx specifically verify this behavior. Changing href="" to undefined would break backward compatibility with existing consumers that expect the href attribute to be present.

Learnt from: jfoshee
PR: clerk/javascript#6447
File: packages/clerk-js/src/ui/primitives/Link.tsx:77-79
Timestamp: 2025-08-12T22:26:27.797Z
Learning: The actionLabel pattern in packages/clerk-js/src/ui/elements/Form.tsx intentionally creates Link components without href props, resulting in href="" for action-only links that trigger onClick handlers without navigation. This is a deliberate design pattern used throughout the Clerk codebase for button-like links.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

css={applyVariants(props) as any}
>
{children}
Expand Down
77 changes: 76 additions & 1 deletion packages/clerk-js/src/utils/__tests__/url.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
createAllowedRedirectOrigins,
getETLDPlusOneFromFrontendApi,
getSearchParameterFromHash,
hasBannedHrefProtocol,
hasBannedProtocol,
hasExternalAccountSignUpError,
isAllowedRedirect,
Expand All @@ -18,6 +19,7 @@ import {
mergeFragmentIntoUrl,
relativeToAbsoluteUrl,
requiresUserInput,
sanitizeHref,
trimLeadingSlash,
trimTrailingSlash,
} from '../url';
Expand All @@ -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);
});
});
Expand Down Expand Up @@ -147,6 +149,79 @@ 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:[email protected]', 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],
[' ', 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:[email protected]', 'mailto:[email protected]'],

// Dangerous protocols - should return null
['javascript:alert("xss")', null],
['javascript:console.log(document.cookies)', null],
['data:text/html,<script>alert("xss")</script>', 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/');
Expand Down
65 changes: 65 additions & 0 deletions packages/clerk-js/src/utils/url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Comment on lines +24 to +26
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Reassess banning blob: (and vbscript:) in hrefs; verify repo-wide impact

Blocking blob: at the sanitizer level will disable legitimate download links (URL.createObjectURL) everywhere Link is used. vbscript: is fine to ban; blob: is commonly used and origin-bound. If the intent is to harden OAuth consent only, consider scoping or allowing blob:.

Run this script to detect potential breakage from banning blob: URLs:

If you decide to keep downloads working, drop blob: from the banned list:

-const BANNED_HREF_PROTOCOLS = ['javascript:', 'data:', 'vbscript:', 'blob:'] as const;
+const BANNED_HREF_PROTOCOLS = ['javascript:', 'data:', 'vbscript:'] as const;

🏁 Script executed:

#!/bin/bash
# Find usages that generate or expect blob: URLs
rg -n --no-heading -S "URL\.createObjectURL|createObjectURL\(|\bblob:" -A 2 -B 2

# Look for anchors/Link components likely used for downloads
rg -n --no-heading -S "<a [^>]*download|download\s*=" -A 2 -B 2
rg -n --no-heading -S "<Link[^>]*download|download\s*=" -A 2 -B 2

# Find all sanitizeHref call sites to assess scope
rg -n --no-heading -S "sanitizeHref\(" -A 2 -B 2

Length of output: 11819


🏁 Script executed:

#!/bin/bash
# Display Link.tsx around sanitizeHref usage for context
sed -n '1,200p' packages/clerk-js/src/ui/primitives/Link.tsx

Length of output: 2398


Allow blob: URLs for legitimate download links

Drop blob: from the banned href protocols to restore support for URL.createObjectURL-based downloads (e.g. backup codes).

• packages/clerk-js/src/utils/url.ts
– Remove 'blob:' from BANNED_HREF_PROTOCOLS
– Update comments/JSDoc to list only javascript:, data:, and vbscript: as dangerous

• packages/clerk-js/src/utils/tests/url.spec.ts
– In the isDangerousProtocol test cases, change the blob:… entry’s expected value from true to false
– In the sanitizeHref test cases, change the blob:… entry’s expected return from null to the original blob URL

Example diffs:

--- a/packages/clerk-js/src/utils/url.ts
+++ b/packages/clerk-js/src/utils/url.ts
@@
// Protocols that are dangerous specifically for href attributes in links
-const BANNED_HREF_PROTOCOLS = ['javascript:', 'data:', 'vbscript:', 'blob:'] as const;
+const BANNED_HREF_PROTOCOLS = ['javascript:', 'data:', 'vbscript:'] as const;
@@
- * This prevents some XSS attacks through javascript:, data:, vbscript:, and blob: URLs.
+ * This prevents some XSS attacks through javascript:, data:, and vbscript: URLs.
--- a/packages/clerk-js/src/utils/__tests__/url.spec.ts
+++ b/packages/clerk-js/src/utils/__tests__/url.spec.ts
@@ describe('isDangerousProtocol(val)', () => {
-  ['blob:https://example.com/123…',   true],
+  ['blob:https://example.com/123…',   false],
@@ describe('sanitizeHref(href)', () => {
-  ['blob:https://example.com/123…',   null],
+  ['blob:https://example.com/123…',   'blob:https://example.com/123…'],
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Protocols that are dangerous specifically for href attributes in links
const BANNED_HREF_PROTOCOLS = ['javascript:', 'data:', 'vbscript:', 'blob:'] as const;
// Protocols that are dangerous specifically for href attributes in links
const BANNED_HREF_PROTOCOLS = ['javascript:', 'data:', 'vbscript:'] as const;
🤖 Prompt for AI Agents
In packages/clerk-js/src/utils/url.ts around lines 24-26, remove 'blob:' from
the BANNED_HREF_PROTOCOLS constant and update the surrounding comment/JSDoc to
list only 'javascript:', 'data:', and 'vbscript:' as dangerous protocols; in
packages/clerk-js/src/utils/__tests__/url.spec.ts update the tests: change the
isDangerousProtocol case for the blob: URL to expect false, and update the
sanitizeHref test case for the blob: URL to expect the original blob URL (not
null). Ensure tests and JSDoc/comments remain consistent with the new behavior.

const { isDevOrStagingUrl } = createDevOrStagingUrlCache();
export { isDevOrStagingUrl };
const accountPortalCache = new Map<string, boolean>();
Expand Down Expand Up @@ -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;
Expand All @@ -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): boolean {
if (!isValidUrl(val)) {
return false;
}
const protocol = new URL(val).protocol;
return BANNED_HREF_PROTOCOLS.some(bp => bp === protocol);
}

Comment on lines +300 to +314
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Correctness: handle URL instances without reparsing and ensure consistent boolean return

Same issue as hasBannedProtocol. Avoid reparsing when val is already a URL; current isValidUrl(val) returns false for URL objects, weakening the check.

Apply:

-export function hasBannedHrefProtocol(val: string | URL): boolean {
-  if (!isValidUrl(val)) {
-    return false;
-  }
-  const protocol = new URL(val).protocol;
-  return BANNED_HREF_PROTOCOLS.some(bp => bp === protocol);
-}
+export function hasBannedHrefProtocol(val: string | URL): boolean {
+  if (val instanceof URL) {
+    return BANNED_HREF_PROTOCOLS.some(bp => bp === val.protocol);
+  }
+  if (!isValidUrl(val)) {
+    return false;
+  }
+  const protocol = new URL(val).protocol;
+  return BANNED_HREF_PROTOCOLS.some(bp => bp === protocol);
+}

Add tests for URL inputs and mixed-case schemes (scheme is normalized, but test guards regressions).

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/**
* 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): boolean {
if (!isValidUrl(val)) {
return false;
}
const protocol = new URL(val).protocol;
return BANNED_HREF_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): boolean {
if (val instanceof URL) {
return BANNED_HREF_PROTOCOLS.some(bp => bp === val.protocol);
}
if (!isValidUrl(val)) {
return false;
}
const protocol = new URL(val).protocol;
return BANNED_HREF_PROTOCOLS.some(bp => bp === protocol);
}
🤖 Prompt for AI Agents
In packages/clerk-js/src/utils/url.ts around lines 300 to 314,
hasBannedHrefProtocol currently reparses val even when it's already a URL and
relies on isValidUrl which returns false for URL objects; change the logic to
first handle URL instances without reparsing (use val.protocol), and for string
inputs validate and parse them as before; normalize the protocol to lowercase
before comparing and return a boolean consistently. Also add unit tests covering
URL object inputs and mixed-case schemes to prevent regressions.

/**
* 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 || href.trim() === '') {
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('#/');
};
Expand Down
4 changes: 4 additions & 0 deletions packages/types/src/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2002,6 +2002,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.
*/
Expand Down
Loading