From c59b1c914f8c42ddcb7ee8fb8b4eeaa3ede57e76 Mon Sep 17 00:00:00 2001 From: Matic Jurglic Date: Mon, 2 Feb 2026 11:39:59 +0100 Subject: [PATCH 1/4] Prevent potential malicious code injection by whitelisting head html --- .../host/app/services/host-mode-service.ts | 7 +- packages/host/app/utils/sanitize-head-html.ts | 72 +++++++++++++++ .../unit/utils/sanitize-head-html-test.ts | 89 +++++++++++++++++++ 3 files changed, 167 insertions(+), 1 deletion(-) create mode 100644 packages/host/app/utils/sanitize-head-html.ts create mode 100644 packages/host/tests/unit/utils/sanitize-head-html-test.ts diff --git a/packages/host/app/services/host-mode-service.ts b/packages/host/app/services/host-mode-service.ts index 0653779e9c6..dca30ac4e70 100644 --- a/packages/host/app/services/host-mode-service.ts +++ b/packages/host/app/services/host-mode-service.ts @@ -7,6 +7,7 @@ import type HostModeStateService from '@cardstack/host/services/host-mode-state- import type OperatorModeStateService from '@cardstack/host/services/operator-mode-state-service'; import type RealmService from '@cardstack/host/services/realm'; import type RealmServerService from '@cardstack/host/services/realm-server'; +import { sanitizeHeadHTML } from '@cardstack/host/utils/sanitize-head-html'; interface PublishedRealmMetadata { urlString: string; @@ -274,7 +275,11 @@ export default class HostModeService extends Service { return; } - let fragment = document.createRange().createContextualFragment(headHTML); + let fragment = sanitizeHeadHTML(headHTML, document); + if (!fragment) { + return; + } + parent.insertBefore(fragment, end); } diff --git a/packages/host/app/utils/sanitize-head-html.ts b/packages/host/app/utils/sanitize-head-html.ts new file mode 100644 index 00000000000..2548de9b28d --- /dev/null +++ b/packages/host/app/utils/sanitize-head-html.ts @@ -0,0 +1,72 @@ +const ALLOWED_HEAD_TAGS = new Set(['meta', 'title']); +const ALLOWED_META_ATTRS = new Set(['name', 'property', 'content']); +const ALLOWED_TITLE_ATTRS = new Set([]); + +// Allowlist head markup before inserting into the document to reduce XSS risk. +export function sanitizeHeadHTML( + headHTML: string, + doc: Document, +): DocumentFragment | null { + let template = doc.createElement('template'); + template.innerHTML = headHTML; + + let fragment = doc.createDocumentFragment(); + for (let node of Array.from(template.content.childNodes)) { + let sanitized = sanitizeHeadNode(node, doc); + if (sanitized) { + fragment.appendChild(sanitized); + } + } + + return fragment.childNodes.length > 0 ? fragment : null; +} + +function sanitizeHeadNode(node: Node, doc: Document): Node | null { + if (node.nodeType !== Node.ELEMENT_NODE) { + return null; + } + + let element = node as Element; + let tagName = element.tagName.toLowerCase(); + if (!ALLOWED_HEAD_TAGS.has(tagName)) { + return null; + } + + switch (tagName) { + case 'meta': + return sanitizeMetaElement(element, doc); + case 'title': + return sanitizeTitleElement(element, doc); + default: + return null; + } +} + +function sanitizeMetaElement(element: Element, doc: Document): HTMLMetaElement { + let meta = doc.createElement('meta'); + copyAllowedAttributes(element, meta, ALLOWED_META_ATTRS); + return meta; +} + +function sanitizeTitleElement( + element: Element, + doc: Document, +): HTMLTitleElement { + let title = doc.createElement('title'); + copyAllowedAttributes(element, title, ALLOWED_TITLE_ATTRS); + title.textContent = element.textContent ?? ''; + return title; +} + +function copyAllowedAttributes( + source: Element, + target: Element, + allowed: Set, +) { + for (let attribute of Array.from(source.attributes)) { + let name = attribute.name.toLowerCase(); + if (allowed.has(name)) { + target.setAttribute(attribute.name, attribute.value); + } + } +} diff --git a/packages/host/tests/unit/utils/sanitize-head-html-test.ts b/packages/host/tests/unit/utils/sanitize-head-html-test.ts new file mode 100644 index 00000000000..223c57c23d1 --- /dev/null +++ b/packages/host/tests/unit/utils/sanitize-head-html-test.ts @@ -0,0 +1,89 @@ +import { module, test } from 'qunit'; + +import { sanitizeHeadHTML } from '@cardstack/host/utils/sanitize-head-html'; + +module('Unit | Utils | sanitizeHeadHTML', function () { + test('filters head markup to allowed elements and attributes', function (assert) { + let input = ` + Hello + + + + + + +
nope
+ `; + + let fragment = sanitizeHeadHTML(input, document); + assert.ok(fragment, 'returns a fragment when allowed elements exist'); + + let container = document.createElement('div'); + if (fragment) { + container.appendChild(fragment); + } + + let elements = Array.from(container.children); + assert.strictEqual(elements.length, 2, 'only allowed elements remain'); + + let title = container.querySelector('title'); + assert.ok(title, 'title is preserved'); + assert.strictEqual(title?.textContent, 'Hello'); + assert.false( + title?.hasAttribute('data-test-title') ?? false, + 'data attributes are removed', + ); + assert.false( + title?.hasAttribute('onclick') ?? false, + 'event handler attributes are removed', + ); + + let meta = container.querySelector('meta[name="description"]'); + assert.ok(meta, 'meta element is preserved'); + assert.strictEqual(meta?.getAttribute('content'), 'desc'); + assert.false( + meta?.hasAttribute('charset') ?? false, + 'disallowed meta attributes are removed', + ); + assert.false( + meta?.hasAttribute('onload') ?? false, + 'disallowed meta attributes are removed', + ); + + assert.strictEqual( + container.querySelector('link[rel="canonical"]'), + null, + 'link tags are removed', + ); + assert.strictEqual( + container.querySelector('link[rel="preload"]'), + null, + 'link tags are removed', + ); + assert.strictEqual( + container.querySelector('link[rel="icon"]'), + null, + 'link tags are removed', + ); + assert.strictEqual( + container.querySelector('script'), + null, + 'script tags are removed', + ); + assert.strictEqual( + container.querySelector('style'), + null, + 'style tags are removed', + ); + assert.strictEqual( + container.querySelector('div'), + null, + 'disallowed tags are removed', + ); + }); + + test('returns null when no allowed head elements remain', function (assert) { + let fragment = sanitizeHeadHTML('', document); + assert.strictEqual(fragment, null); + }); +}); From 6ad002fea9f8c520d8f2e985f572cc94fd95cdf1 Mon Sep 17 00:00:00 2001 From: Matic Jurglic Date: Mon, 2 Feb 2026 12:21:23 +0100 Subject: [PATCH 2/4] Sanitize link tags --- packages/host/app/utils/sanitize-head-html.ts | 67 ++++++++++++++- .../unit/utils/sanitize-head-html-test.ts | 83 +++++++++++++++++-- 2 files changed, 142 insertions(+), 8 deletions(-) diff --git a/packages/host/app/utils/sanitize-head-html.ts b/packages/host/app/utils/sanitize-head-html.ts index 2548de9b28d..c7a5d8b3583 100644 --- a/packages/host/app/utils/sanitize-head-html.ts +++ b/packages/host/app/utils/sanitize-head-html.ts @@ -1,6 +1,25 @@ -const ALLOWED_HEAD_TAGS = new Set(['meta', 'title']); +const ALLOWED_HEAD_TAGS = new Set(['meta', 'title', 'link']); const ALLOWED_META_ATTRS = new Set(['name', 'property', 'content']); const ALLOWED_TITLE_ATTRS = new Set([]); +const ALLOWED_LINK_ATTRS = new Set([ + 'rel', + 'href', + 'type', + 'sizes', + 'media', + 'crossorigin', + 'integrity', + 'referrerpolicy', + 'fetchpriority', +]); +const SAFE_LINK_REL_TOKENS = new Set([ + 'canonical', + 'icon', + 'shortcut', + 'apple-touch-icon', + 'mask-icon', + 'manifest', +]); // Allowlist head markup before inserting into the document to reduce XSS risk. export function sanitizeHeadHTML( @@ -37,6 +56,8 @@ function sanitizeHeadNode(node: Node, doc: Document): Node | null { return sanitizeMetaElement(element, doc); case 'title': return sanitizeTitleElement(element, doc); + case 'link': + return sanitizeLinkElement(element, doc); default: return null; } @@ -58,6 +79,50 @@ function sanitizeTitleElement( return title; } +function sanitizeLinkElement( + element: Element, + doc: Document, +): HTMLLinkElement | null { + let relValue = element.getAttribute('rel') ?? ''; + if (!isSafeLinkRel(relValue)) { + return null; + } + + let href = element.getAttribute('href'); + if (href && !isSafeLinkHref(href, doc)) { + return null; + } + + let link = doc.createElement('link'); + copyAllowedAttributes(element, link, ALLOWED_LINK_ATTRS); + return link; +} + +function isSafeLinkRel(relValue: string): boolean { + let tokens = relValue + .toLowerCase() + .split(/\s+/) + .filter(Boolean); + if (tokens.length === 0) { + return false; + } + return tokens.every((token) => SAFE_LINK_REL_TOKENS.has(token)); +} + +function isSafeLinkHref(href: string, doc: Document): boolean { + let trimmed = href.trim(); + if (!trimmed) { + return false; + } + + try { + let url = new URL(trimmed, doc.baseURI ?? window.location.origin); + return url.protocol === 'http:' || url.protocol === 'https:'; + } catch { + return false; + } +} + function copyAllowedAttributes( source: Element, target: Element, diff --git a/packages/host/tests/unit/utils/sanitize-head-html-test.ts b/packages/host/tests/unit/utils/sanitize-head-html-test.ts index 223c57c23d1..892f646bd17 100644 --- a/packages/host/tests/unit/utils/sanitize-head-html-test.ts +++ b/packages/host/tests/unit/utils/sanitize-head-html-test.ts @@ -24,7 +24,7 @@ module('Unit | Utils | sanitizeHeadHTML', function () { } let elements = Array.from(container.children); - assert.strictEqual(elements.length, 2, 'only allowed elements remain'); + assert.strictEqual(elements.length, 3, 'only allowed elements remain'); let title = container.querySelector('title'); assert.ok(title, 'title is preserved'); @@ -50,20 +50,26 @@ module('Unit | Utils | sanitizeHeadHTML', function () { 'disallowed meta attributes are removed', ); - assert.strictEqual( - container.querySelector('link[rel="canonical"]'), - null, - 'link tags are removed', + let link = container.querySelector('link[rel="canonical"]'); + assert.ok(link, 'safe link rel is preserved'); + assert.strictEqual(link?.getAttribute('href'), 'https://example.com'); + assert.false( + link?.hasAttribute('data-test-link') ?? false, + 'data attributes are removed', + ); + assert.false( + link?.hasAttribute('onclick') ?? false, + 'disallowed link attributes are removed', ); assert.strictEqual( container.querySelector('link[rel="preload"]'), null, - 'link tags are removed', + 'unsafe link rel is removed', ); assert.strictEqual( container.querySelector('link[rel="icon"]'), null, - 'link tags are removed', + 'unsafe link href is removed', ); assert.strictEqual( container.querySelector('script'), @@ -86,4 +92,67 @@ module('Unit | Utils | sanitizeHeadHTML', function () { let fragment = sanitizeHeadHTML('', document); assert.strictEqual(fragment, null); }); + + test('ignores text nodes, comments, and whitespace-only input', function (assert) { + let fragment = sanitizeHeadHTML( + ' \nOk\nText node\n', + document, + ); + assert.ok(fragment, 'fragment is returned when allowed elements exist'); + + let container = document.createElement('div'); + if (fragment) { + container.appendChild(fragment); + } + + assert.strictEqual( + container.querySelectorAll('title').length, + 1, + 'title element preserved', + ); + assert.strictEqual( + container.childNodes.length, + 1, + 'non-element nodes are filtered out', + ); + + let empty = sanitizeHeadHTML(' \n\t', document); + assert.strictEqual(empty, null, 'whitespace-only input returns null'); + }); + + test('preserves multiple title tags and filters nested disallowed elements', function (assert) { + let fragment = sanitizeHeadHTML( + 'FirstSecond
', + document, + ); + assert.ok(fragment, 'fragment is returned when allowed elements exist'); + + let container = document.createElement('div'); + if (fragment) { + container.appendChild(fragment); + } + + assert.strictEqual( + container.querySelectorAll('title').length, + 2, + 'multiple title tags are preserved', + ); + assert.strictEqual( + container.querySelectorAll('meta').length, + 0, + 'nested disallowed elements are removed', + ); + }); + + test('drops encoded javascript in link hrefs', function (assert) { + let fragment = sanitizeHeadHTML( + '', + document, + ); + assert.strictEqual( + fragment, + null, + 'encoded javascript href is rejected', + ); + }); }); From 7bf18c4f0938d155951aca67ba50e03776b939af Mon Sep 17 00:00:00 2001 From: Matic Jurglic Date: Mon, 2 Feb 2026 12:27:25 +0100 Subject: [PATCH 3/4] Remove redundant default --- packages/host/app/utils/sanitize-head-html.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/host/app/utils/sanitize-head-html.ts b/packages/host/app/utils/sanitize-head-html.ts index c7a5d8b3583..f1ef598b76f 100644 --- a/packages/host/app/utils/sanitize-head-html.ts +++ b/packages/host/app/utils/sanitize-head-html.ts @@ -58,9 +58,9 @@ function sanitizeHeadNode(node: Node, doc: Document): Node | null { return sanitizeTitleElement(element, doc); case 'link': return sanitizeLinkElement(element, doc); - default: - return null; } + + return null; } function sanitizeMetaElement(element: Element, doc: Document): HTMLMetaElement { From b32c147f05c1f099483545065667459acdf61278 Mon Sep 17 00:00:00 2001 From: Matic Jurglic Date: Mon, 2 Feb 2026 12:33:15 +0100 Subject: [PATCH 4/4] Cleanup & better tests --- packages/host/app/utils/sanitize-head-html.ts | 12 +++++---- .../unit/utils/sanitize-head-html-test.ts | 25 ++++++++++--------- 2 files changed, 20 insertions(+), 17 deletions(-) diff --git a/packages/host/app/utils/sanitize-head-html.ts b/packages/host/app/utils/sanitize-head-html.ts index f1ef598b76f..93fd752ebdc 100644 --- a/packages/host/app/utils/sanitize-head-html.ts +++ b/packages/host/app/utils/sanitize-head-html.ts @@ -21,11 +21,14 @@ const SAFE_LINK_REL_TOKENS = new Set([ 'manifest', ]); -// Allowlist head markup before inserting into the document to reduce XSS risk. export function sanitizeHeadHTML( headHTML: string, doc: Document, ): DocumentFragment | null { + if (typeof headHTML !== 'string') { + return null; + } + let template = doc.createElement('template'); template.innerHTML = headHTML; @@ -40,6 +43,8 @@ export function sanitizeHeadHTML( return fragment.childNodes.length > 0 ? fragment : null; } +// Reject any non-element nodes (text nodes, comments, document fragments, etc.) +// so that only explicit, allowlisted elements are inserted function sanitizeHeadNode(node: Node, doc: Document): Node | null { if (node.nodeType !== Node.ELEMENT_NODE) { return null; @@ -99,10 +104,7 @@ function sanitizeLinkElement( } function isSafeLinkRel(relValue: string): boolean { - let tokens = relValue - .toLowerCase() - .split(/\s+/) - .filter(Boolean); + let tokens = relValue.toLowerCase().split(/\s+/).filter(Boolean); if (tokens.length === 0) { return false; } diff --git a/packages/host/tests/unit/utils/sanitize-head-html-test.ts b/packages/host/tests/unit/utils/sanitize-head-html-test.ts index 892f646bd17..03613b588f1 100644 --- a/packages/host/tests/unit/utils/sanitize-head-html-test.ts +++ b/packages/host/tests/unit/utils/sanitize-head-html-test.ts @@ -7,6 +7,7 @@ module('Unit | Utils | sanitizeHeadHTML', function () { let input = ` Hello + @@ -24,17 +25,17 @@ module('Unit | Utils | sanitizeHeadHTML', function () { } let elements = Array.from(container.children); - assert.strictEqual(elements.length, 3, 'only allowed elements remain'); + assert.strictEqual(elements.length, 4, 'only allowed elements remain'); let title = container.querySelector('title'); assert.ok(title, 'title is preserved'); assert.strictEqual(title?.textContent, 'Hello'); assert.false( - title?.hasAttribute('data-test-title') ?? false, + Boolean(title?.hasAttribute('data-test-title')), 'data attributes are removed', ); assert.false( - title?.hasAttribute('onclick') ?? false, + Boolean(title?.hasAttribute('onclick')), 'event handler attributes are removed', ); @@ -42,23 +43,27 @@ module('Unit | Utils | sanitizeHeadHTML', function () { assert.ok(meta, 'meta element is preserved'); assert.strictEqual(meta?.getAttribute('content'), 'desc'); assert.false( - meta?.hasAttribute('charset') ?? false, + Boolean(meta?.hasAttribute('charset')), 'disallowed meta attributes are removed', ); assert.false( - meta?.hasAttribute('onload') ?? false, + Boolean(meta?.hasAttribute('onload')), 'disallowed meta attributes are removed', ); + let ogMeta = container.querySelector('meta[property="og:title"]'); + assert.ok(ogMeta, 'meta property is preserved'); + assert.strictEqual(ogMeta?.getAttribute('content'), 'OG Title'); + let link = container.querySelector('link[rel="canonical"]'); assert.ok(link, 'safe link rel is preserved'); assert.strictEqual(link?.getAttribute('href'), 'https://example.com'); assert.false( - link?.hasAttribute('data-test-link') ?? false, + Boolean(link?.hasAttribute('data-test-link')), 'data attributes are removed', ); assert.false( - link?.hasAttribute('onclick') ?? false, + Boolean(link?.hasAttribute('onclick')), 'disallowed link attributes are removed', ); assert.strictEqual( @@ -149,10 +154,6 @@ module('Unit | Utils | sanitizeHeadHTML', function () { '', document, ); - assert.strictEqual( - fragment, - null, - 'encoded javascript href is rejected', - ); + assert.strictEqual(fragment, null, 'encoded javascript href is rejected'); }); });