From 4780f1da787d6d54b5b5ceb0a849a5971a4b35fd Mon Sep 17 00:00:00 2001 From: rootvector2 Date: Mon, 1 Jun 2026 19:34:26 +0530 Subject: [PATCH 1/5] sanitize urls on svg anchor href and xlink:href --- .../integration-tests/test/attributes-test.ts | 20 +++++++++++++++++++ .../runtime/lib/dom/sanitized-values.ts | 9 ++++++--- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/packages/@glimmer-workspace/integration-tests/test/attributes-test.ts b/packages/@glimmer-workspace/integration-tests/test/attributes-test.ts index eef13f25c13..a734f3fcac6 100644 --- a/packages/@glimmer-workspace/integration-tests/test/attributes-test.ts +++ b/packages/@glimmer-workspace/integration-tests/test/attributes-test.ts @@ -573,6 +573,26 @@ export class AttributesTests extends RenderTest { this.assertHTML(''); this.assertStableNodes(); } + + @test + 'svg a[href] marks javascript: protocol as unsafe'() { + this.render('', { foo: 'javascript:foo()' }); + let anchor = (this.element.firstChild as SimpleElement).firstChild as SimpleElement; + this.assert.strictEqual(this.readDOMAttr('href', anchor), 'unsafe:javascript:foo()'); + + this.rerender({ foo: 'http://foo.bar' }); + this.assert.strictEqual(this.readDOMAttr('href', anchor), 'http://foo.bar'); + } + + @test + 'svg a[xlink:href] marks javascript: protocol as unsafe'() { + this.render('', { foo: 'javascript:foo()' }); + let anchor = (this.element.firstChild as SimpleElement).firstChild as SimpleElement; + this.assert.strictEqual(this.readDOMAttr('xlink:href', anchor), 'unsafe:javascript:foo()'); + + this.rerender({ foo: 'http://foo.bar' }); + this.assert.strictEqual(this.readDOMAttr('xlink:href', anchor), 'http://foo.bar'); + } } jitSuite(AttributesTests); diff --git a/packages/@glimmer/runtime/lib/dom/sanitized-values.ts b/packages/@glimmer/runtime/lib/dom/sanitized-values.ts index a5203749453..d7568ae77c0 100644 --- a/packages/@glimmer/runtime/lib/dom/sanitized-values.ts +++ b/packages/@glimmer/runtime/lib/dom/sanitized-values.ts @@ -8,7 +8,7 @@ const badTags = ['A', 'BODY', 'LINK', 'IMG', 'IFRAME', 'BASE', 'FORM']; const badTagsForDataURI = ['EMBED']; -const badAttributes = ['href', 'src', 'background', 'action']; +const badAttributes = ['href', 'src', 'background', 'action', 'xlink:href']; const badAttributesForDataURI = ['src']; @@ -25,8 +25,11 @@ function checkDataURI(tagName: Nullable, attribute: string): boolean { return has(badTagsForDataURI, tagName) && has(badAttributesForDataURI, attribute); } -export function requiresSanitization(tagName: string, attribute: string): boolean { - return checkURI(tagName, attribute) || checkDataURI(tagName, attribute); +export function requiresSanitization(tagName: Nullable, attribute: string): boolean { + // SVG element tagNames are lowercase (e.g. `a`), so they never match the + // uppercase `badTags` entries unless we normalize first. + let normalizedTag = tagName === null ? tagName : tagName.toUpperCase(); + return checkURI(normalizedTag, attribute) || checkDataURI(normalizedTag, attribute); } interface NodeUrlParseResult { From 328caf0e363281a13bbe82d82fa137b817dab632 Mon Sep 17 00:00:00 2001 From: rootvector2 Date: Mon, 1 Jun 2026 21:07:22 +0530 Subject: [PATCH 2/5] normalize tagName inside checkURI/checkDataURI --- .../@glimmer/runtime/lib/dom/sanitized-values.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/@glimmer/runtime/lib/dom/sanitized-values.ts b/packages/@glimmer/runtime/lib/dom/sanitized-values.ts index d7568ae77c0..fa2a085a8a2 100644 --- a/packages/@glimmer/runtime/lib/dom/sanitized-values.ts +++ b/packages/@glimmer/runtime/lib/dom/sanitized-values.ts @@ -17,19 +17,19 @@ function has(array: Array, item: string): boolean { } function checkURI(tagName: Nullable, attribute: string): boolean { - return (tagName === null || has(badTags, tagName)) && has(badAttributes, attribute); + // SVG element tagNames are lowercase (e.g. `a`), so they never match the + // uppercase `badTags` entries unless we normalize first. + let normalizedTag = tagName === null ? tagName : tagName.toUpperCase(); + return (normalizedTag === null || has(badTags, normalizedTag)) && has(badAttributes, attribute); } function checkDataURI(tagName: Nullable, attribute: string): boolean { if (tagName === null) return false; - return has(badTagsForDataURI, tagName) && has(badAttributesForDataURI, attribute); + return has(badTagsForDataURI, tagName.toUpperCase()) && has(badAttributesForDataURI, attribute); } export function requiresSanitization(tagName: Nullable, attribute: string): boolean { - // SVG element tagNames are lowercase (e.g. `a`), so they never match the - // uppercase `badTags` entries unless we normalize first. - let normalizedTag = tagName === null ? tagName : tagName.toUpperCase(); - return checkURI(normalizedTag, attribute) || checkDataURI(normalizedTag, attribute); + return checkURI(tagName, attribute) || checkDataURI(tagName, attribute); } interface NodeUrlParseResult { @@ -111,7 +111,7 @@ export function sanitizeAttributeValue( return value.toHTML(); } - const tagName = element.tagName.toUpperCase(); + const tagName = element.tagName; let str = normalizeStringValue(value); From d678c09de51b40f40207e1a5d452d7e9b6196c46 Mon Sep 17 00:00:00 2001 From: rootvector2 Date: Mon, 1 Jun 2026 21:51:37 +0530 Subject: [PATCH 3/5] fold related url sanitization fixes into this PR simplify checkURI to inline the tag normalization, and bring in the other open sanitization gaps so they live behind the same tag/attribute matching: - formaction on button/input - data: protocol on iframe[src] and object[data] - strip ascii tab/newline/cr before the fastboot url protocol check --- .../integration-tests/test/attributes-test.ts | 46 +++++++++++++++++++ .../runtime/lib/dom/sanitized-values.ts | 41 ++++++++++++++--- 2 files changed, 81 insertions(+), 6 deletions(-) diff --git a/packages/@glimmer-workspace/integration-tests/test/attributes-test.ts b/packages/@glimmer-workspace/integration-tests/test/attributes-test.ts index a734f3fcac6..5c4e5a8c219 100644 --- a/packages/@glimmer-workspace/integration-tests/test/attributes-test.ts +++ b/packages/@glimmer-workspace/integration-tests/test/attributes-test.ts @@ -593,6 +593,34 @@ export class AttributesTests extends RenderTest { this.rerender({ foo: 'http://foo.bar' }); this.assert.strictEqual(this.readDOMAttr('xlink:href', anchor), 'http://foo.bar'); } + + @test + 'marks data: urls as unsafe on iframe[src] and object[data]'() { + this.render('', { + foo: 'data:text/html,', + }); + this.assertHTML(''); + this.assertStableRerender(); + + this.rerender({ foo: 'https://example.com/page' }); + this.assertHTML(''); + this.assertStableNodes(); + } + + @test + 'object[data] marks data: and javascript: urls as unsafe but allows http'() { + this.render('', { + foo: 'data:text/html,', + }); + this.assertHTML(''); + + this.rerender({ foo: 'javascript:foo()' }); + this.assertHTML(''); + + this.rerender({ foo: 'https://example.com/doc.pdf' }); + this.assertHTML(''); + this.assertStableNodes(); + } } jitSuite(AttributesTests); @@ -739,3 +767,21 @@ jitSuite( protected isSelfClosing = false; } ); + +jitSuite( + class extends BoundValuesToSpecialAttributeTests { + static suiteName = 'button[formaction] attribute'; + protected tag = 'button'; + protected attr = 'formaction'; + } +); + +jitSuite( + class extends BoundValuesToSpecialAttributeTests { + static suiteName = 'input[formaction] attribute'; + protected tag = 'input'; + protected attr = 'formaction'; + protected override isEmptyElement = true; + protected isSelfClosing = false; + } +); diff --git a/packages/@glimmer/runtime/lib/dom/sanitized-values.ts b/packages/@glimmer/runtime/lib/dom/sanitized-values.ts index fa2a085a8a2..55450adc7f5 100644 --- a/packages/@glimmer/runtime/lib/dom/sanitized-values.ts +++ b/packages/@glimmer/runtime/lib/dom/sanitized-values.ts @@ -4,14 +4,21 @@ import { isSafeString, normalizeStringValue } from '../dom/normalize'; const badProtocols = ['javascript:', 'vbscript:']; -const badTags = ['A', 'BODY', 'LINK', 'IMG', 'IFRAME', 'BASE', 'FORM']; +const badTags = ['A', 'BODY', 'LINK', 'IMG', 'IFRAME', 'BASE', 'FORM', 'BUTTON', 'INPUT']; const badTagsForDataURI = ['EMBED']; -const badAttributes = ['href', 'src', 'background', 'action', 'xlink:href']; +// Tags whose URL attribute is loaded as a nested document. A `data:` URL there +// is rendered and can execute script just like `javascript:`, so it has to be +// neutralized even though such tags legitimately point at http(s) resources. +const badTagsForDataProtocol = ['IFRAME', 'OBJECT']; + +const badAttributes = ['href', 'src', 'background', 'action', 'formaction', 'xlink:href']; const badAttributesForDataURI = ['src']; +const badAttributesForDataProtocol = ['src', 'data']; + function has(array: Array, item: string): boolean { return array.indexOf(item) !== -1; } @@ -19,8 +26,7 @@ function has(array: Array, item: string): boolean { function checkURI(tagName: Nullable, attribute: string): boolean { // SVG element tagNames are lowercase (e.g. `a`), so they never match the // uppercase `badTags` entries unless we normalize first. - let normalizedTag = tagName === null ? tagName : tagName.toUpperCase(); - return (normalizedTag === null || has(badTags, normalizedTag)) && has(badAttributes, attribute); + return (tagName === null || has(badTags, tagName.toUpperCase())) && has(badAttributes, attribute); } function checkDataURI(tagName: Nullable, attribute: string): boolean { @@ -28,8 +34,20 @@ function checkDataURI(tagName: Nullable, attribute: string): boolean { return has(badTagsForDataURI, tagName.toUpperCase()) && has(badAttributesForDataURI, attribute); } +function checkDataProtocol(tagName: Nullable, attribute: string): boolean { + if (tagName === null) return false; + return ( + has(badTagsForDataProtocol, tagName.toUpperCase()) && + has(badAttributesForDataProtocol, attribute) + ); +} + export function requiresSanitization(tagName: Nullable, attribute: string): boolean { - return checkURI(tagName, attribute) || checkDataURI(tagName, attribute); + return ( + checkURI(tagName, attribute) || + checkDataURI(tagName, attribute) || + checkDataProtocol(tagName, attribute) + ); } interface NodeUrlParseResult { @@ -66,7 +84,11 @@ function findProtocolForURL() { let protocol = null; if (typeof url === 'string') { - protocol = nodeURL.parse(url).protocol; + // browsers strip ASCII tab/newline/CR from urls before navigating, so + // `java\nscript:` runs as `javascript:`. `url.parse` keeps them and reports + // a null protocol, slipping past the badProtocols check. Strip them here to + // match the WHATWG `URL` parser used on the non-fastboot path. + protocol = nodeURL.parse(url.replace(/[\t\n\r]/gu, '')).protocol; } return protocol === null ? ':' : protocol; @@ -122,6 +144,13 @@ export function sanitizeAttributeValue( } } + if (checkDataProtocol(tagName, attribute)) { + let protocol = protocolForUrl(str); + if (protocol === 'data:' || has(badProtocols, protocol)) { + return `unsafe:${str}`; + } + } + if (checkDataURI(tagName, attribute)) { return `unsafe:${str}`; } From f267364cdad27214c93ccc817ec143f4d4a75392 Mon Sep 17 00:00:00 2001 From: rootvector2 Date: Tue, 2 Jun 2026 11:40:20 +0530 Subject: [PATCH 4/5] Sanitize javascript:/vbscript: urls in area[href] --- .../integration-tests/test/attributes-test.ts | 9 +++++++++ packages/@glimmer/runtime/lib/dom/sanitized-values.ts | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/@glimmer-workspace/integration-tests/test/attributes-test.ts b/packages/@glimmer-workspace/integration-tests/test/attributes-test.ts index 5c4e5a8c219..ad733aa3432 100644 --- a/packages/@glimmer-workspace/integration-tests/test/attributes-test.ts +++ b/packages/@glimmer-workspace/integration-tests/test/attributes-test.ts @@ -785,3 +785,12 @@ jitSuite( protected isSelfClosing = false; } ); + +jitSuite( + class extends BoundValuesToSpecialAttributeTests { + static suiteName = 'area[href] attribute'; + protected tag = 'area'; + protected attr = 'href'; + protected override isEmptyElement = true; + } +); diff --git a/packages/@glimmer/runtime/lib/dom/sanitized-values.ts b/packages/@glimmer/runtime/lib/dom/sanitized-values.ts index 55450adc7f5..01e97d7939a 100644 --- a/packages/@glimmer/runtime/lib/dom/sanitized-values.ts +++ b/packages/@glimmer/runtime/lib/dom/sanitized-values.ts @@ -4,7 +4,7 @@ import { isSafeString, normalizeStringValue } from '../dom/normalize'; const badProtocols = ['javascript:', 'vbscript:']; -const badTags = ['A', 'BODY', 'LINK', 'IMG', 'IFRAME', 'BASE', 'FORM', 'BUTTON', 'INPUT']; +const badTags = ['A', 'AREA', 'BODY', 'LINK', 'IMG', 'IFRAME', 'BASE', 'FORM', 'BUTTON', 'INPUT']; const badTagsForDataURI = ['EMBED']; From d367fd824e7d211b17ce2741e29ed6ada6281945 Mon Sep 17 00:00:00 2001 From: rootvector2 Date: Thu, 4 Jun 2026 14:30:10 +0530 Subject: [PATCH 5/5] fix comment wording: svg tagNames are case-preserved --- packages/@glimmer/runtime/lib/dom/sanitized-values.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/@glimmer/runtime/lib/dom/sanitized-values.ts b/packages/@glimmer/runtime/lib/dom/sanitized-values.ts index 01e97d7939a..364a36b8a8b 100644 --- a/packages/@glimmer/runtime/lib/dom/sanitized-values.ts +++ b/packages/@glimmer/runtime/lib/dom/sanitized-values.ts @@ -24,8 +24,9 @@ function has(array: Array, item: string): boolean { } function checkURI(tagName: Nullable, attribute: string): boolean { - // SVG element tagNames are lowercase (e.g. `a`), so they never match the - // uppercase `badTags` entries unless we normalize first. + // SVG tagNames are case-preserved, so the SVG `` element comes through as + // lowercase `a` and never matches the uppercase `badTags` entries unless we + // normalize first. return (tagName === null || has(badTags, tagName.toUpperCase())) && has(badAttributes, attribute); }