diff --git a/cookbook/llms/markets.prompt.md b/cookbook/llms/markets.prompt.md
index d3aa684c84..b96a2efc4d 100644
--- a/cookbook/llms/markets.prompt.md
+++ b/cookbook/llms/markets.prompt.md
@@ -98,6 +98,7 @@ selected.
- app/components/CountrySelector.tsx
- app/components/Link.tsx
- app/lib/i18n.ts
+- app/lib/sanitizeHtml.ts
- app/routes/($locale)._index.tsx
- app/routes/($locale).account.$.tsx
- app/routes/($locale).account._index.tsx
@@ -571,7 +572,731 @@ export function useLocalizedPath(
~~~
-#### Step 1.6: Update ProductItem to use locale-aware Link
+#### Step 1.6: Create an HTML sanitizer for merchant-managed rich text
+
+Pages, blog articles, policies and product descriptions come from the Storefront API
+as HTML written by merchants, staff and apps, and the routes render that HTML with
+`dangerouslySetInnerHTML`. Treat it as untrusted: create a `sanitizeHtml()` helper that
+keeps formatting, tables, images and safe links, and removes anything that can run
+script, such as `
Shop now
',
+ ),
+ ).toBe('
Welcome
Shop now
');
+ });
+
+ it('removes inline scripts and their content', () => {
+ expect(sanitizeHtml('
a
b
')).toBe(
+ '
a
b
',
+ );
+ });
+
+ it('removes scripts with mixed-case tag names', () => {
+ expect(sanitizeHtml('ok')).toBe('ok');
+ });
+
+ it('treats script content as raw text', () => {
+ expect(sanitizeHtml('after')).toBe(
+ 'after',
+ );
+ });
+
+ it('removes everything after an unclosed script tag', () => {
+ expect(sanitizeHtml('
');
+ });
+
+ it('removes an unterminated comment and the rest of the input', () => {
+ expect(sanitizeHtml('
a
', index + 4);
+ index = end === -1 ? length : end + 3;
+ continue;
+ }
+ if (next === '!' || next === '?') {
+ const end = input.indexOf('>', index + 2);
+ index = end === -1 ? length : end + 1;
+ continue;
+ }
+
+ // End tag.
+ if (next === '/') {
+ END_TAG_NAME.lastIndex = index + 2;
+ const nameMatch = END_TAG_NAME.exec(input);
+ const end = input.indexOf('>', index + 2);
+ if (!nameMatch) {
+ // `>` and ` ...>` are not end tags; the browser drops them.
+ index = end === -1 ? length : end + 1;
+ continue;
+ }
+ index = end === -1 ? length : end + 1;
+ const name = nameMatch[0].toLowerCase();
+ if (!openCounts.get(name)) continue;
+ closeFrom(openTags.lastIndexOf(name));
+ continue;
+ }
+
+ // Anything that is not `<` followed by a letter is text.
+ if (!/[a-zA-Z]/.test(next)) {
+ output += '<';
+ index += 1;
+ continue;
+ }
+
+ const tag = readStartTag(input, index);
+ if (!tag) {
+ // Unterminated tag at the end of the input; the browser drops it too.
+ break;
+ }
+ index = tag.end;
+
+ if (DROP_WITH_CONTENT_TAGS.has(tag.name)) {
+ // `` and `` are complete elements; everything else is
+ // dropped up to its end tag.
+ const closedForeignElement =
+ tag.selfClosing && (tag.name === 'svg' || tag.name === 'math');
+ if (!closedForeignElement) index = skipElement(input, index, tag.name);
+ continue;
+ }
+
+ if (!ALLOWED_TAGS.has(tag.name)) {
+ // Unknown or unsafe wrapper: drop the tag, keep its text content.
+ continue;
+ }
+
+ if (TABLE_PART_TAGS.has(tag.name) && !openCounts.get('table')) continue;
+
+ // Write the end tags that the browser implies, so the browser does not
+ // close an element that the sanitizer still treats as open.
+ if (tag.name === 'table') closeFrom(top(tableTargets));
+ if (tag.name === 'li') closeFrom(top(listItemTargets));
+ if (tag.name === 'dd' || tag.name === 'dt') {
+ closeFrom(top(definitionTargets));
+ }
+ if (CLOSES_P_TAGS.has(tag.name)) closeFrom(top(paragraphTargets));
+
+ output += `<${tag.name}${serializeAttributes(tag.name, tag.attributes)}>`;
+ // The browser ignores `/>` on non-void HTML elements, so `` opens an
+ // element that stays open until its end tag.
+ if (!VOID_TAGS.has(tag.name)) open(tag.name);
+ }
+
+ closeFrom(0);
+
+ return output;
+}
+
+function readStartTag(
+ input: string,
+ start: number,
+):
+ | {name: string; attributes: Attribute[]; selfClosing: boolean; end: number}
+ | undefined {
+ const length = input.length;
+ let index = start + 1;
+ let name = '';
+
+ while (index < length && !/[\s/>]/.test(input.charAt(index))) {
+ name += input.charAt(index);
+ index += 1;
+ }
+
+ const attributes: Attribute[] = [];
+ const seen = new Set();
+ let selfClosing = false;
+
+ while (index < length) {
+ const char = input.charAt(index);
+
+ if (char === '>') {
+ return {
+ name: name.toLowerCase(),
+ attributes,
+ selfClosing,
+ end: index + 1,
+ };
+ }
+
+ if (/\s/.test(char)) {
+ index += 1;
+ continue;
+ }
+
+ if (char === '/') {
+ selfClosing = input.charAt(index + 1) === '>';
+ index += 1;
+ continue;
+ }
+
+ selfClosing = false;
+
+ // Attribute name. A leading `=` belongs to the name, as in the HTML spec.
+ let attributeName = char;
+ index += 1;
+ while (index < length && !/[\s/>=]/.test(input.charAt(index))) {
+ attributeName += input.charAt(index);
+ index += 1;
+ }
+
+ while (index < length && /\s/.test(input.charAt(index))) index += 1;
+
+ let value = '';
+ if (input.charAt(index) === '=') {
+ index += 1;
+ while (index < length && /\s/.test(input.charAt(index))) index += 1;
+
+ const quote = input.charAt(index);
+ if (quote === '"' || quote === "'") {
+ const close = input.indexOf(quote, index + 1);
+ if (close === -1) return undefined;
+ value = input.slice(index + 1, close);
+ index = close + 1;
+ } else {
+ const valueStart = index;
+ while (index < length && !/[\s>]/.test(input.charAt(index))) {
+ index += 1;
+ }
+ value = input.slice(valueStart, index);
+ }
+ }
+
+ const normalizedName = attributeName.toLowerCase();
+ if (!seen.has(normalizedName)) {
+ seen.add(normalizedName);
+ attributes.push({name: normalizedName, value});
+ }
+ }
+
+ return undefined;
+}
+
+/** Returns the index just past the element's end tag, or the input length. */
+function skipElement(input: string, start: number, name: string): number {
+ if (name === 'plaintext') return input.length;
+
+ // Raw text elements end at the first matching end tag. Other elements can
+ // nest, so count same-named start and end tags.
+ const pattern = RAW_TEXT_TAGS.has(name)
+ ? new RegExp(`<(/)${name}(?=[\\s/>])`, 'gi')
+ : new RegExp(`<(/?)${name}(?=[\\s/>])`, 'gi');
+ // In SVG and MathML, `/>` closes the element, so a nested `` or
+ // `` does not add a level.
+ const foreign = name === 'svg' || name === 'math';
+ let depth = 1;
+ let index = start;
+ while (depth > 0) {
+ pattern.lastIndex = index;
+ const match = pattern.exec(input);
+ if (!match) return input.length;
+ if (match[1]) {
+ const end = input.indexOf('>', match.index);
+ if (end === -1) return input.length;
+ depth -= 1;
+ index = end + 1;
+ } else if (foreign) {
+ const tag = readStartTag(input, match.index);
+ if (!tag) return input.length;
+ if (!tag.selfClosing) depth += 1;
+ index = tag.end;
+ } else {
+ const end = input.indexOf('>', match.index);
+ if (end === -1) return input.length;
+ depth += 1;
+ index = end + 1;
+ }
+ }
+ return index;
+}
+
+function serializeAttributes(tag: string, attributes: Attribute[]): string {
+ const allowed = TAG_ATTRIBUTES[tag];
+ let output = '';
+ let opensNewWindow = false;
+ const relTokens = new Set();
+
+ for (const {name, value} of attributes) {
+ if (!GLOBAL_ATTRIBUTES.has(name) && !allowed?.has(name)) continue;
+ // `rel` is written below so links can't opt out of `noopener`.
+ if (name === 'rel') {
+ for (const token of value.toLowerCase().split(/\s+/)) {
+ if (ALLOWED_REL_TOKENS.has(token)) relTokens.add(token);
+ }
+ continue;
+ }
+
+ if (URL_ATTRIBUTES.has(name)) {
+ const url = sanitizeUrl(
+ value,
+ tag === 'img' ? SAFE_IMAGE_PROTOCOLS : SAFE_LINK_PROTOCOLS,
+ );
+ if (url === undefined) continue;
+ output += ` ${name}="${escapeAttributeValue(url)}"`;
+ continue;
+ }
+
+ if (name === 'target') {
+ const target = value.trim().toLowerCase();
+ if (!['_blank', '_parent', '_self', '_top'].includes(target)) continue;
+ opensNewWindow = target === '_blank';
+ output += ` target="${target}"`;
+ continue;
+ }
+
+ if (NUMERIC_ATTRIBUTES.has(name)) {
+ const number = value.trim();
+ if (!/^\d{1,5}%?$/.test(number)) continue;
+ output += ` ${name}="${number}"`;
+ continue;
+ }
+
+ if (name === 'dir' && !/^(ltr|rtl|auto)$/i.test(value.trim())) continue;
+ if (
+ name === 'scope' &&
+ !/^(row|col|rowgroup|colgroup)$/i.test(value.trim())
+ )
+ continue;
+ if (name === 'type' && !/^[1aAiI]$/.test(value.trim())) continue;
+
+ if (name === 'open' || name === 'reversed') {
+ output += ` ${name}`;
+ continue;
+ }
+
+ // These attributes can't run script, so references are kept as written
+ // and only a bare `&`, `<`, `>` or `"` is escaped.
+ output += ` ${name}="${escapeText(value).replace(/"/g, '"')}"`;
+ }
+
+ if (tag === 'a' && opensNewWindow) {
+ relTokens.add('noopener');
+ relTokens.add('noreferrer');
+ }
+ if (relTokens.size) {
+ output += ` rel="${[...relTokens].join(' ')}"`;
+ }
+
+ return output;
+}
+
+/**
+ * Returns the URL to write, or `undefined` to drop the attribute. The value is
+ * decoded first so `javascript:` and `java	script:` are caught, and
+ * the decoded value is what gets written back (re-escaped), so the browser
+ * sees exactly the URL that was checked.
+ */
+function sanitizeUrl(
+ value: string,
+ allowedProtocols: Set,
+): string | undefined {
+ // The URL parser trims C0 controls and spaces and ignores tabs and newlines.
+ // The trailing run is trimmed with a loop: a `+$` regex is quadratic on a
+ // long run of spaces that is not at the end of the value.
+ const decoded = decodeReferences(value);
+ let end = decoded.length;
+ while (end > 0 && isTrimmedUrlChar(decoded.charCodeAt(end - 1))) end -= 1;
+ const url = decoded
+ .slice(0, end)
+ .replace(/^[\u0000-\u0020\u007f-\u009f]+/, '')
+ .replace(/[\t\n\r]/g, '');
+
+ if (url === '') return undefined;
+
+ const scheme = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(url);
+ if (!scheme) {
+ // A colon before any `/`, `?` or `#` would make the browser read a scheme.
+ const firstColon = url.indexOf(':');
+ const firstDelimiter = url.search(/[/?#]/);
+ if (
+ firstColon !== -1 &&
+ (firstDelimiter === -1 || firstColon < firstDelimiter)
+ ) {
+ return undefined;
+ }
+ return url;
+ }
+
+ return allowedProtocols.has(scheme[1].toLowerCase()) ? url : undefined;
+}
+
+function isTrimmedUrlChar(code: number): boolean {
+ return code <= 0x20 || (code >= 0x7f && code <= 0x9f);
+}
+
+/**
+ * Decodes numeric character references and a small set of named ones. Any
+ * other `&` survives as a literal character and is escaped on output, so the
+ * browser can never decode something this function did not.
+ */
+function decodeReferences(value: string): string {
+ return value.replace(
+ /&(?:#(\d+)|#[xX]([0-9a-fA-F]+)|([a-zA-Z][a-zA-Z0-9]*));?/g,
+ (match, decimal?: string, hex?: string, named?: string) => {
+ if (decimal || hex) {
+ const codePoint = parseInt(decimal ?? hex ?? '', decimal ? 10 : 16);
+ if (
+ !Number.isFinite(codePoint) ||
+ codePoint === 0 ||
+ codePoint > 0x10ffff ||
+ (codePoint >= 0xd800 && codePoint <= 0xdfff)
+ ) {
+ return '\ufffd';
+ }
+ return String.fromCodePoint(codePoint);
+ }
+ if (named && match.endsWith(';')) {
+ const decoded = NAMED_REFERENCES[named.toLowerCase()];
+ if (decoded !== undefined) return decoded;
+ }
+ return match;
+ },
+ );
+}
+
+/** Escapes text, leaving well-formed character references intact. */
+function escapeText(text: string): string {
+ return text
+ .replace(/&(?!#\d+;|#[xX][0-9a-fA-F]+;|[a-zA-Z][a-zA-Z0-9]*;)/g, '&')
+ .replace(//g, '>');
+}
+
+function escapeAttributeValue(value: string): string {
+ return value
+ .replace(/&/g, '&')
+ .replace(/"/g, '"')
+ .replace(//g, '>');
+}
diff --git a/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).blogs.$blogHandle.$articleHandle.tsx b/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).blogs.$blogHandle.$articleHandle.tsx
index 1a838ae388..3c42840094 100644
--- a/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).blogs.$blogHandle.$articleHandle.tsx
+++ b/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).blogs.$blogHandle.$articleHandle.tsx
@@ -2,6 +2,7 @@ import {useLoaderData} from 'react-router';
import type {Route} from './+types/($locale).blogs.$blogHandle.$articleHandle';
import {Image} from '@shopify/hydrogen';
import {redirectIfHandleIsLocalized} from '~/lib/redirect';
+import {sanitizeHtml} from '~/lib/sanitizeHtml';
export const meta: Route.MetaFunction = ({data}) => {
return [{title: `Hydrogen | ${data?.article.title ?? ''} article`}];
@@ -51,7 +52,11 @@ async function loadCriticalData({context, request, params}: Route.LoaderArgs) {
},
);
- const article = blog.articleByHandle;
+ const article = {
+ ...blog.articleByHandle,
+ // Article content is merchant-managed HTML: sanitize before rendering it raw.
+ contentHtml: sanitizeHtml(blog.articleByHandle.contentHtml),
+ };
return {article};
}
diff --git a/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).pages.$handle.tsx b/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).pages.$handle.tsx
index b3129e0e4e..6443ea52dc 100644
--- a/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).pages.$handle.tsx
+++ b/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).pages.$handle.tsx
@@ -1,8 +1,7 @@
-import {
- useLoaderData,
-} from 'react-router';
+import {useLoaderData} from 'react-router';
import type {Route} from './+types/($locale).pages.$handle';
import {redirectIfHandleIsLocalized} from '~/lib/redirect';
+import {sanitizeHtml} from '~/lib/sanitizeHtml';
export const meta: Route.MetaFunction = ({data}) => {
return [{title: `Hydrogen | ${data?.page.title ?? ''}`}];
@@ -22,11 +21,7 @@ export async function loader(args: Route.LoaderArgs) {
* Load data necessary for rendering content above the fold. This is the critical data
* needed to render the page. If it's unavailable, the whole page should 400 or 500 error.
*/
-async function loadCriticalData({
- context,
- request,
- params,
-}: Route.LoaderArgs) {
+async function loadCriticalData({context, request, params}: Route.LoaderArgs) {
if (!params.handle) {
throw new Error('Missing page handle');
}
@@ -47,7 +42,8 @@ async function loadCriticalData({
redirectIfHandleIsLocalized(request, {handle: params.handle, data: page});
return {
- page,
+ // Page bodies are merchant-managed HTML: sanitize before rendering it raw.
+ page: {...page, body: sanitizeHtml(page.body)},
};
}
diff --git a/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).policies.$handle.tsx b/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).policies.$handle.tsx
index 7375735555..9069a637a6 100644
--- a/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).policies.$handle.tsx
+++ b/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).policies.$handle.tsx
@@ -1,9 +1,8 @@
-import {
- useLoaderData,
-} from 'react-router';
+import {useLoaderData} from 'react-router';
import {Link} from '~/components/Link';
import type {Route} from './+types/($locale).policies.$handle';
import {type Shop} from '@shopify/hydrogen/storefront-api-types';
+import {sanitizeHtml} from '~/lib/sanitizeHtml';
type SelectedPolicies = keyof Pick<
Shop,
@@ -41,7 +40,8 @@ export async function loader({params, context}: Route.LoaderArgs) {
throw new Response('Could not find the policy', {status: 404});
}
- return {policy};
+ // Policy bodies are merchant-managed HTML: sanitize before rendering it raw.
+ return {policy: {...policy, body: sanitizeHtml(policy.body)}};
}
export default function Policy() {
diff --git a/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).products.$handle.tsx b/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).products.$handle.tsx
index d1cddf684a..17330ff72a 100644
--- a/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).products.$handle.tsx
+++ b/cookbook/recipes/markets/ingredients/templates/skeleton/app/routes/($locale).products.$handle.tsx
@@ -14,6 +14,7 @@ import {ProductForm} from '~/components/ProductForm';
import {redirectIfHandleIsLocalized} from '~/lib/redirect';
import type {WithLocale} from '~/lib/i18n';
import {DEFAULT_LOCALE} from '~/lib/i18n';
+import {sanitizeHtml} from '~/lib/sanitizeHtml';
export const meta: Route.MetaFunction = (args) => {
const rootMatch = args.matches.at(0) ?? null;
@@ -48,11 +49,7 @@ export async function loader(args: Route.LoaderArgs) {
* Load data necessary for rendering content above the fold. This is the critical data
* needed to render the page. If it's unavailable, the whole page should 400 or 500 error.
*/
-async function loadCriticalData({
- context,
- params,
- request,
-}: Route.LoaderArgs) {
+async function loadCriticalData({context, params, request}: Route.LoaderArgs) {
const {handle} = params;
const {storefront} = context;
@@ -80,7 +77,11 @@ async function loadCriticalData({
redirectIfHandleIsLocalized(request, {handle, data: product});
return {
- product,
+ // Product descriptions are merchant-managed HTML: sanitize before rendering it raw.
+ product: {
+ ...product,
+ descriptionHtml: sanitizeHtml(product.descriptionHtml),
+ },
};
}
diff --git a/cookbook/recipes/markets/recipe.yaml b/cookbook/recipes/markets/recipe.yaml
index cb015a4eb2..e63050d2cb 100644
--- a/cookbook/recipes/markets/recipe.yaml
+++ b/cookbook/recipes/markets/recipe.yaml
@@ -67,6 +67,10 @@ ingredients:
description: Comprehensive i18n utilities including locale detection, path
transformation hooks, URL cleaning functions, and locale validation.
Centralizes all localization logic in one place.
+ - path: templates/skeleton/app/lib/sanitizeHtml.ts
+ description: An allowlist HTML sanitizer for merchant-managed rich text (page
+ bodies, article content, policies and product descriptions) before it is
+ rendered with dangerouslySetInnerHTML.
- path: templates/skeleton/app/routes/($locale)._index.tsx
description: A route that renders a localized version of the home page.
- path: templates/skeleton/app/routes/($locale).account.$.tsx
@@ -198,8 +202,25 @@ steps:
7. Support for case-insensitive locale matching
ingredients:
- path: templates/skeleton/app/lib/i18n.ts
- - type: PATCH
+ - type: NEW_FILE
step: "1.6"
+ name: Create an HTML sanitizer for merchant-managed rich text
+ description: |
+ Pages, blog articles, policies and product descriptions come from the Storefront API
+ as HTML written by merchants, staff and apps, and the routes render that HTML with
+ `dangerouslySetInnerHTML`. Treat it as untrusted: create a `sanitizeHtml()` helper that
+ keeps formatting, tables, images and safe links, and removes anything that can run
+ script, such as `