diff --git a/.changeset/richtext-safe-links.md b/.changeset/richtext-safe-links.md
new file mode 100644
index 0000000000..125d6b7197
--- /dev/null
+++ b/.changeset/richtext-safe-links.md
@@ -0,0 +1,6 @@
+---
+'@shopify/hydrogen-react': patch
+'@shopify/hydrogen': patch
+---
+
+Render rich text links with unsupported protocols or malformed URLs as text in the default components while preserving their formatting.
diff --git a/packages/hydrogen-react/src/RichText.components.tsx b/packages/hydrogen-react/src/RichText.components.tsx
index 169c911249..8408935a9d 100644
--- a/packages/hydrogen-react/src/RichText.components.tsx
+++ b/packages/hydrogen-react/src/RichText.components.tsx
@@ -1,4 +1,5 @@
import {createElement, type ReactNode} from 'react';
+import {isSafeRichTextUrl} from './is-safe-rich-text-url.js';
export type CustomComponents = {
/** The root node of the rich text. Defaults to `
` */
@@ -95,6 +96,8 @@ function RichTextLink({
children?: ReactNode[];
};
}): ReactNode {
+ if (!isSafeRichTextUrl(node.url)) return node.children;
+
return (
{node.children}
diff --git a/packages/hydrogen-react/src/RichText.test.tsx b/packages/hydrogen-react/src/RichText.test.tsx
index 8f39a19c7e..aa22c2f7f9 100644
--- a/packages/hydrogen-react/src/RichText.test.tsx
+++ b/packages/hydrogen-react/src/RichText.test.tsx
@@ -1,4 +1,4 @@
-import {describe, expect, it} from 'vitest';
+import {describe, expect, it, vi} from 'vitest';
import {
RICH_TEXT_HEADING_1,
@@ -162,3 +162,88 @@ describe(' ', () => {
});
});
});
+
+function linkData(url: string) {
+ return JSON.stringify({
+ type: 'root',
+ children: [
+ {
+ type: 'paragraph',
+ children: [
+ {
+ type: 'link',
+ url,
+ title: 'Link title',
+ target: '_blank',
+ children: [{type: 'text', value: 'Formatted link', bold: true}],
+ },
+ {type: 'text', value: 'Sibling text'},
+ ],
+ },
+ ],
+ });
+}
+
+function renderRichText(
+ url: string,
+ components?: React.ComponentProps['components'],
+) {
+ return render( );
+}
+
+describe(' ', () => {
+ it.each([
+ 'javascript:alert(1)',
+ 'JaVaScRiPt:alert(1)',
+ ' \t\r\njavascript:alert(1)',
+ '\u0000javascript:alert(1)',
+ 'java\tscript:alert(1)',
+ 'java\nscript:alert(1)',
+ 'java\rscript:alert(1)',
+ 'data:text/html,',
+ 'https://[',
+ ])('renders unsafe URL %j as formatted text', (url) => {
+ const {container} = renderRichText(url);
+ expect(container.querySelector('strong')).toHaveTextContent(
+ 'Formatted link',
+ );
+ expect(container).toHaveTextContent('Sibling text');
+ expect(container.querySelector('a')).toBeNull();
+ });
+
+ it.each([
+ 'https://shopify.com',
+ '/products/foo',
+ 'products/foo',
+ '?page=2',
+ '#details',
+ '//shopify.com/products',
+ 'mailto:help@example.com',
+ 'tel:+15555550123',
+ 'sms:+15555550123',
+ ])('preserves safe URL %j and link attributes', (url) => {
+ const {container} = renderRichText(url);
+ const link = container.querySelector('a');
+ expect(link).toHaveAttribute('href', url);
+ expect(link).toHaveAttribute('title', 'Link title');
+ expect(link).toHaveAttribute('target', '_blank');
+ expect(link?.querySelector('strong')).toHaveTextContent('Formatted link');
+ });
+
+ it('preserves custom link overrides and original node properties', () => {
+ const url = 'javascript:alert(1)';
+ const customLink = vi.fn(({node}) => (
+ {node.children}
+ ));
+ const {container} = renderRichText(url, {link: customLink});
+ expect(customLink.mock.calls[0][0].node).toMatchObject({
+ url,
+ title: 'Link title',
+ target: '_blank',
+ });
+ expect(
+ container.querySelector('[data-custom-link] strong'),
+ ).toHaveTextContent('Formatted link');
+ expect(container.querySelector('a')).toBeNull();
+ });
+});
diff --git a/packages/hydrogen-react/src/is-safe-rich-text-url.test.ts b/packages/hydrogen-react/src/is-safe-rich-text-url.test.ts
new file mode 100644
index 0000000000..27556d6026
--- /dev/null
+++ b/packages/hydrogen-react/src/is-safe-rich-text-url.test.ts
@@ -0,0 +1,43 @@
+import {describe, expect, it} from 'vitest';
+import {isSafeRichTextUrl} from './is-safe-rich-text-url.js';
+
+describe('isSafeRichTextUrl', () => {
+ it.each([
+ '/products/foo',
+ 'products/foo',
+ '../products/foo',
+ '?page=2',
+ '#details',
+ '',
+ '//shopify.com/products',
+ 'https://shopify.com',
+ 'HTTP://shopify.com',
+ 'mailto:help@example.com',
+ 'tel:+15555550123',
+ 'sms:+15555550123',
+ ])('accepts %j', (url) => {
+ expect(isSafeRichTextUrl(url)).toBe(true);
+ });
+
+ it.each([
+ 'javascript:alert(1)',
+ 'JaVaScRiPt:alert(1)',
+ ' \t\r\njavascript:alert(1)',
+ '\u0000javascript:alert(1)',
+ 'java\tscript:alert(1)',
+ 'java\nscript:alert(1)',
+ 'java\rscript:alert(1)',
+ 'data:text/html,',
+ 'vbscript:msgbox(1)',
+ 'file:///etc/passwd',
+ 'ftp://example.com',
+ 'blob:https://example.com/id',
+ 'https://[',
+ ])('rejects %j', (url) => {
+ expect(isSafeRichTextUrl(url)).toBe(false);
+ });
+
+ it('rejects non-string runtime input', () => {
+ expect(isSafeRichTextUrl(null as unknown as string)).toBe(false);
+ });
+});
diff --git a/packages/hydrogen-react/src/is-safe-rich-text-url.ts b/packages/hydrogen-react/src/is-safe-rich-text-url.ts
new file mode 100644
index 0000000000..f886b6855f
--- /dev/null
+++ b/packages/hydrogen-react/src/is-safe-rich-text-url.ts
@@ -0,0 +1,11 @@
+/** Whether a URL is safe for the default rich text link renderers. */
+export function isSafeRichTextUrl(url: string): boolean {
+ if (typeof url !== 'string') return false;
+
+ try {
+ const {protocol} = new URL(url, 'https://richtext.invalid');
+ return ['http:', 'https:', 'mailto:', 'tel:', 'sms:'].includes(protocol);
+ } catch {
+ return false;
+ }
+}
diff --git a/packages/hydrogen/src/RichText.test.tsx b/packages/hydrogen/src/RichText.test.tsx
new file mode 100644
index 0000000000..1cc6e43dc4
--- /dev/null
+++ b/packages/hydrogen/src/RichText.test.tsx
@@ -0,0 +1,96 @@
+import {describe, expect, it, vi} from 'vitest';
+import {render} from '@testing-library/react';
+import {MemoryRouter} from 'react-router';
+import {RichText} from './RichText';
+
+function linkData(url: string) {
+ return JSON.stringify({
+ type: 'root',
+ children: [
+ {
+ type: 'paragraph',
+ children: [
+ {
+ type: 'link',
+ url,
+ title: 'Link title',
+ target: '_blank',
+ children: [{type: 'text', value: 'Formatted link', bold: true}],
+ },
+ {type: 'text', value: 'Sibling text'},
+ ],
+ },
+ ],
+ });
+}
+
+function renderRichText(
+ url: string,
+ components?: React.ComponentProps['components'],
+) {
+ return render(
+
+
+ ,
+ );
+}
+
+describe(' ', () => {
+ it.each([
+ 'javascript:alert(1)',
+ 'JaVaScRiPt:alert(1)',
+ ' \t\r\njavascript:alert(1)',
+ '\u0000javascript:alert(1)',
+ 'java\tscript:alert(1)',
+ 'java\nscript:alert(1)',
+ 'java\rscript:alert(1)',
+ 'data:text/html,',
+ 'https://[',
+ ])('renders unsafe URL %j as formatted text', (url) => {
+ const {container} = renderRichText(url);
+ expect(container.querySelector('strong')).toHaveTextContent(
+ 'Formatted link',
+ );
+ expect(container).toHaveTextContent('Sibling text');
+ expect(container.querySelector('a')).toBeNull();
+ });
+
+ it.each([
+ 'https://shopify.com',
+ '/products/foo',
+ 'products/foo',
+ '?page=2',
+ '#details',
+ '//shopify.com/products',
+ 'mailto:help@example.com',
+ 'tel:+15555550123',
+ 'sms:+15555550123',
+ ])('preserves safe URL %j and link attributes', (url) => {
+ const {container} = renderRichText(url);
+ const link = container.querySelector('a');
+ const expectedHref = ['products/foo', '?page=2', '#details'].includes(url)
+ ? `/${url}`
+ : url;
+ expect(link).toHaveAttribute('href', expectedHref);
+ expect(link).toHaveAttribute('title', 'Link title');
+ expect(link).toHaveAttribute('target', '_blank');
+ expect(link?.querySelector('strong')).toHaveTextContent('Formatted link');
+ });
+
+ it('preserves custom link overrides and original node properties', () => {
+ const url = 'javascript:alert(1)';
+ const customLink = vi.fn(({node}) => (
+ {node.children}
+ ));
+ const {container} = renderRichText(url, {link: customLink});
+ expect(customLink.mock.calls[0][0].node).toMatchObject({
+ url,
+ title: 'Link title',
+ target: '_blank',
+ });
+ expect(
+ container.querySelector('[data-custom-link] strong'),
+ ).toHaveTextContent('Formatted link');
+ expect(container.querySelector('a')).toBeNull();
+ });
+});
diff --git a/packages/hydrogen/src/RichText.tsx b/packages/hydrogen/src/RichText.tsx
index c805834b5f..e6337f9114 100644
--- a/packages/hydrogen/src/RichText.tsx
+++ b/packages/hydrogen/src/RichText.tsx
@@ -1,21 +1,26 @@
import {Link} from 'react-router';
import {RichText as OriginalRichText} from '@shopify/hydrogen-react';
+import {isSafeRichTextUrl} from '@shopify/hydrogen-react/is-safe-rich-text-url';
export const RichText: typeof OriginalRichText = function (props) {
return (
(
-
- {node.children}
-
- ),
+ link: ({node}) => {
+ if (!isSafeRichTextUrl(node.url)) return node.children;
+
+ return (
+
+ {node.children}
+
+ );
+ },
...props.components,
}}
/>