diff --git a/.changeset/clever-pans-tap.md b/.changeset/clever-pans-tap.md new file mode 100644 index 0000000000..0ddc24ef41 --- /dev/null +++ b/.changeset/clever-pans-tap.md @@ -0,0 +1,5 @@ +--- +'@shopify/hydrogen': patch +--- + +Fix XSS vulnerability diff --git a/package-lock.json b/package-lock.json index a76a9bc5c2..3f66b87f62 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30978,7 +30978,7 @@ }, "packages/cli": { "name": "@shopify/cli-hydrogen", - "version": "5.5.1", + "version": "5.5.2", "license": "MIT", "dependencies": { "@ast-grep/napi": "0.11.0", @@ -31873,7 +31873,7 @@ "dependencies": { "@remix-run/react": "1.19.1", "@shopify/cli": "3.49.2", - "@shopify/cli-hydrogen": "^5.5.1", + "@shopify/cli-hydrogen": "^5.5.2", "@shopify/hydrogen": "^2023.7.13", "@shopify/remix-oxygen": "^1.1.8", "@total-typescript/ts-reset": "^0.4.2", @@ -31904,7 +31904,7 @@ "dependencies": { "@remix-run/react": "1.19.1", "@shopify/cli": "3.49.2", - "@shopify/cli-hydrogen": "^5.5.1", + "@shopify/cli-hydrogen": "^5.5.2", "@shopify/hydrogen": "^2023.7.13", "@shopify/remix-oxygen": "^1.1.8", "graphql": "^16.6.0", @@ -42976,7 +42976,7 @@ "@remix-run/dev": "1.19.1", "@remix-run/react": "1.19.1", "@shopify/cli": "3.49.2", - "@shopify/cli-hydrogen": "^5.5.1", + "@shopify/cli-hydrogen": "^5.5.2", "@shopify/hydrogen": "^2023.7.13", "@shopify/oxygen-workers-types": "^3.17.3", "@shopify/prettier-config": "^1.1.2", @@ -49857,7 +49857,7 @@ "@remix-run/eslint-config": "1.19.1", "@remix-run/react": "1.19.1", "@shopify/cli": "3.49.2", - "@shopify/cli-hydrogen": "^5.5.1", + "@shopify/cli-hydrogen": "^5.5.2", "@shopify/hydrogen": "^2023.7.13", "@shopify/oxygen-workers-types": "^3.17.3", "@shopify/prettier-config": "^1.1.2", diff --git a/packages/hydrogen/src/seo/escape.ts b/packages/hydrogen/src/seo/escape.ts new file mode 100644 index 0000000000..bfc8650414 --- /dev/null +++ b/packages/hydrogen/src/seo/escape.ts @@ -0,0 +1,15 @@ +// This is taken from remix: https://github.com/remix-run/remix/blob/main/packages/remix-server-runtime/markup.ts + +const ESCAPE_LOOKUP: {[match: string]: string} = { + '&': '\\u0026', + '>': '\\u003e', + '<': '\\u003c', + '\u2028': '\\u2028', + '\u2029': '\\u2029', +}; + +const ESCAPE_REGEX = /[&><\u2028\u2029]/g; + +export function escapeHtml(html: string) { + return html.replace(ESCAPE_REGEX, (match) => ESCAPE_LOOKUP[match]); +} diff --git a/packages/hydrogen/src/seo/generate-seo-tags.ts b/packages/hydrogen/src/seo/generate-seo-tags.ts index 048a2882cb..70c951966c 100644 --- a/packages/hydrogen/src/seo/generate-seo-tags.ts +++ b/packages/hydrogen/src/seo/generate-seo-tags.ts @@ -1,6 +1,7 @@ import type {ComponentPropsWithoutRef} from 'react'; import type {Maybe} from '@shopify/hydrogen-react/storefront-api-types'; import type {Thing, WithContext} from 'schema-dts'; +import {escapeHtml} from './escape'; const ERROR_PREFIX = 'Error in SEO input: '; @@ -500,7 +501,9 @@ export function generateSeoTags< 'script', { type: 'application/ld+json', - children: JSON.stringify(block), + children: JSON.stringify(block, (k, value) => { + return typeof value === 'string' ? escapeHtml(value) : value; + }), }, // @ts-expect-error `json-ld-${block?.['@type'] || block?.name || index++}`, diff --git a/packages/hydrogen/src/seo/seo.test.ts b/packages/hydrogen/src/seo/seo.test.ts index 972e35da05..2eb52d9ee4 100644 --- a/packages/hydrogen/src/seo/seo.test.ts +++ b/packages/hydrogen/src/seo/seo.test.ts @@ -264,6 +264,35 @@ describe('seo', () => { `); }); + + it('escapes script content', async () => { + vi.mocked(useMatches).mockReturnValueOnce([ + fillMatch({ + data: { + seo: { + jsonLd: { + '@context': 'https://schema.org', + '@type': 'Organization', + name: 'Hydrogen Root', + description: '', + }, + }, + }, + }), + ]); + + const {asFragment} = render(createElement(Seo)); + + expect(asFragment()).toMatchInlineSnapshot(` + + + + `); + }); }); function fillMatch(partial: Partial = {}) {