Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -573,6 +573,54 @@ export class AttributesTests extends RenderTest {
this.assertHTML('<svg viewBox="0 0 100 100" />');
this.assertStableNodes();
}

@test
'svg a[href] marks javascript: protocol as unsafe'() {
this.render('<svg><a href={{this.foo}}></a></svg>', { 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('<svg><a xlink:href={{this.foo}}></a></svg>', { 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('<iframe src={{this.foo}}></iframe>', {
foo: 'data:text/html,<script>alert(1)</script>',
});
this.assertHTML('<iframe src="unsafe:data:text/html,<script>alert(1)</script>"></iframe>');
this.assertStableRerender();

this.rerender({ foo: 'https://example.com/page' });
this.assertHTML('<iframe src="https://example.com/page"></iframe>');
this.assertStableNodes();
}

@test
'object[data] marks data: and javascript: urls as unsafe but allows http'() {
this.render('<object data={{this.foo}}></object>', {
foo: 'data:text/html,<script>alert(1)</script>',
});
this.assertHTML('<object data="unsafe:data:text/html,<script>alert(1)</script>"></object>');

this.rerender({ foo: 'javascript:foo()' });
this.assertHTML('<object data="unsafe:javascript:foo()"></object>');

this.rerender({ foo: 'https://example.com/doc.pdf' });
this.assertHTML('<object data="https://example.com/doc.pdf"></object>');
this.assertStableNodes();
}
}

jitSuite(AttributesTests);
Expand Down Expand Up @@ -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;
}
);
49 changes: 41 additions & 8 deletions packages/@glimmer/runtime/lib/dom/sanitized-values.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>, item: string): boolean {
return array.indexOf(item) !== -1;
}

function checkURI(tagName: Nullable<string>, attribute: string): boolean {
return (tagName === null || has(badTags, tagName)) && has(badAttributes, attribute);
// SVG tagNames are case-preserved, so the SVG `<a>` 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<string>, 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<string>, 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<string>, attribute: string): boolean {
return (
checkURI(tagName, attribute) ||
checkDataURI(tagName, attribute) ||
checkDataProtocol(tagName, attribute)
);
}

interface NodeUrlParseResult {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -108,7 +134,7 @@ export function sanitizeAttributeValue(
return value.toHTML();
}

const tagName = element.tagName.toUpperCase();
const tagName = element.tagName;

let str = normalizeStringValue(value);

Expand All @@ -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}`;
}
Expand Down
Loading