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, }} />