diff --git a/packages/@glimmer-workspace/integration-tests/test/attributes-test.ts b/packages/@glimmer-workspace/integration-tests/test/attributes-test.ts
index eef13f25c13..ad733aa3432 100644
--- a/packages/@glimmer-workspace/integration-tests/test/attributes-test.ts
+++ b/packages/@glimmer-workspace/integration-tests/test/attributes-test.ts
@@ -573,6 +573,54 @@ 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('');
+
+ this.rerender({ foo: 'https://example.com/doc.pdf' });
+ this.assertHTML('');
+ this.assertStableNodes();
+ }
}
jitSuite(AttributesTests);
@@ -719,3 +767,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..364a36b8a8b 100644
--- a/packages/@glimmer/runtime/lib/dom/sanitized-values.ts
+++ b/packages/@glimmer/runtime/lib/dom/sanitized-values.ts
@@ -4,29 +4,51 @@ 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 badAttributes = ['href', 'src', 'background', 'action'];
+// 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;
}
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 +85,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 +134,7 @@ export function sanitizeAttributeValue(
return value.toHTML();
}
- const tagName = element.tagName.toUpperCase();
+ const tagName = element.tagName;
let str = normalizeStringValue(value);
@@ -119,6 +145,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}`;
}