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..93fd752ebdc --- /dev/null +++ b/packages/host/app/utils/sanitize-head-html.ts @@ -0,0 +1,139 @@ +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', +]); + +export function sanitizeHeadHTML( + headHTML: string, + doc: Document, +): DocumentFragment | null { + if (typeof headHTML !== 'string') { + return 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; +} + +// 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; + } + + 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); + case 'link': + return sanitizeLinkElement(element, doc); + } + + 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 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, + 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..03613b588f1 --- /dev/null +++ b/packages/host/tests/unit/utils/sanitize-head-html-test.ts @@ -0,0 +1,159 @@ +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, 4, 'only allowed elements remain'); + + let title = container.querySelector('title'); + assert.ok(title, 'title is preserved'); + assert.strictEqual(title?.textContent, 'Hello'); + assert.false( + Boolean(title?.hasAttribute('data-test-title')), + 'data attributes are removed', + ); + assert.false( + Boolean(title?.hasAttribute('onclick')), + '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( + Boolean(meta?.hasAttribute('charset')), + 'disallowed meta attributes are removed', + ); + assert.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( + Boolean(link?.hasAttribute('data-test-link')), + 'data attributes are removed', + ); + assert.false( + Boolean(link?.hasAttribute('onclick')), + 'disallowed link attributes are removed', + ); + assert.strictEqual( + container.querySelector('link[rel="preload"]'), + null, + 'unsafe link rel is removed', + ); + assert.strictEqual( + container.querySelector('link[rel="icon"]'), + null, + 'unsafe link href is 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); + }); + + 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'); + }); +});