diff --git a/packages/@glimmer-workspace/integration-tests/test/attributes-test.ts b/packages/@glimmer-workspace/integration-tests/test/attributes-test.ts index eef13f25c13..3b846e11365 100644 --- a/packages/@glimmer-workspace/integration-tests/test/attributes-test.ts +++ b/packages/@glimmer-workspace/integration-tests/test/attributes-test.ts @@ -573,6 +573,58 @@ 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'); + } + + @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(''); + + // the allowed URL must be same-origin and actually loadable: Safari 15 + // hangs (and times out the BrowserStack run) when an points at + // unreachable cross-origin content + let allowedUrl = new URL('/testem.js', window.location.href).href; + this.rerender({ foo: allowedUrl }); + this.assertHTML(``); + this.assertStableNodes(); + } } jitSuite(AttributesTests); @@ -719,3 +771,30 @@ 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; + } +); + +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 a5203749453..58cf8908a16 100644 --- a/packages/@glimmer/runtime/lib/dom/sanitized-values.ts +++ b/packages/@glimmer/runtime/lib/dom/sanitized-values.ts @@ -4,29 +4,44 @@ import { isSafeString, normalizeStringValue } from '../dom/normalize'; const badProtocols = ['javascript:', 'vbscript:']; -const badTags = ['A', 'BODY', 'LINK', 'IMG', 'IFRAME', 'BASE', 'FORM']; - +const badTags = ['A', 'AREA', 'BODY', 'LINK', 'IMG', 'IFRAME', 'BASE', 'FORM', 'BUTTON', 'INPUT']; const badTagsForDataURI = ['EMBED']; +const badTagsForDataProtocol = ['IFRAME', 'OBJECT']; -const badAttributes = ['href', 'src', 'background', 'action']; - +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; } function checkURI(tagName: Nullable, attribute: string): boolean { - return (tagName === null || has(badTags, tagName)) && has(badAttributes, attribute); + // 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); } 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); +} + +function checkDataProtocol(tagName: Nullable, attribute: string): boolean { + if (tagName === null) return false; + return ( + has(badTagsForDataProtocol, tagName.toUpperCase()) && + has(badAttributesForDataProtocol, attribute) + ); } -export function requiresSanitization(tagName: string, attribute: string): boolean { - return checkURI(tagName, attribute) || checkDataURI(tagName, attribute); +export function requiresSanitization(tagName: Nullable, attribute: string): boolean { + return ( + checkURI(tagName, attribute) || + checkDataURI(tagName, attribute) || + checkDataProtocol(tagName, attribute) + ); } interface NodeUrlParseResult { @@ -63,7 +78,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; @@ -108,7 +127,7 @@ export function sanitizeAttributeValue( return value.toHTML(); } - const tagName = element.tagName.toUpperCase(); + const tagName = element.tagName; let str = normalizeStringValue(value); @@ -119,6 +138,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}`; }