Skip to content

Commit b858309

Browse files
dgp1130thePunderWoman
authored andcommitted
fix(core): block creation of sensitive URI attributes from ICU messages
Translators are not allowed to write HTML which creates URI attributes. I opted to ban any values going into an attribute at all, to prevent even links to malicious content, rather than just sanitizing URIs. I also converted this blocklist into an allowlist. Now, we only allowing setting known attributes (while sanitizing URI attributes). This significantly reduces risk of missing a vulnerable attribute and does not require an exhaustive list of all potential attributes. BREAKING CHANGE: Angular now only applies known attributes from HTML in translated ICU content. Unknown attributes are dropped and not rendered. (cherry picked from commit 306f367)
1 parent 5c4d9b2 commit b858309

3 files changed

Lines changed: 75 additions & 10 deletions

File tree

packages/core/src/render3/i18n/i18n_parse.ts

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -808,7 +808,6 @@ function walkIcuTree(
808808
const attr = elAttrs.item(i)!;
809809
const lowerAttrName = attr.name.toLowerCase();
810810
const hasBinding = !!attr.value.match(BINDING_REGEXP);
811-
// we assume the input string is safe, unless it's using a binding
812811
if (hasBinding) {
813812
if (VALID_ATTRS.hasOwnProperty(lowerAttrName)) {
814813
if (URI_ATTRS[lowerAttrName]) {
@@ -831,8 +830,29 @@ function walkIcuTree(
831830
`(see ${XSS_SECURITY_URL})`,
832831
);
833832
}
833+
} else if (VALID_ATTRS[lowerAttrName]) {
834+
if (URI_ATTRS[lowerAttrName]) {
835+
// Don't sanitize, because no value is acceptable in sensitive attributes.
836+
// Translators are not allowed to create URIs.
837+
if (typeof ngDevMode !== 'undefined' && ngDevMode) {
838+
console.warn(
839+
`WARNING: ignoring unsafe attribute ` +
840+
`${lowerAttrName} on element ${tagName} ` +
841+
`(see ${XSS_SECURITY_URL})`,
842+
);
843+
}
844+
addCreateAttribute(create, newIndex, attr.name, 'unsafe:blocked');
845+
} else {
846+
addCreateAttribute(create, newIndex, attr.name, attr.value);
847+
}
834848
} else {
835-
addCreateAttribute(create, newIndex, attr);
849+
if (typeof ngDevMode !== 'undefined' && ngDevMode) {
850+
console.warn(
851+
`WARNING: ignoring unknown attribute name ` +
852+
`${lowerAttrName} on element ${tagName} ` +
853+
`(see ${XSS_SECURITY_URL})`,
854+
);
855+
}
836856
}
837857
}
838858
const elementNode: I18nElementNode = {
@@ -945,10 +965,11 @@ function addCreateNodeAndAppend(
945965
);
946966
}
947967

948-
function addCreateAttribute(create: IcuCreateOpCodes, newIndex: number, attr: Attr) {
949-
create.push(
950-
(newIndex << IcuCreateOpCode.SHIFT_REF) | IcuCreateOpCode.Attr,
951-
attr.name,
952-
attr.value,
953-
);
968+
function addCreateAttribute(
969+
create: IcuCreateOpCodes,
970+
newIndex: number,
971+
attrName: string,
972+
attrValue: string,
973+
) {
974+
create.push((newIndex << IcuCreateOpCode.SHIFT_REF) | IcuCreateOpCode.Attr, attrName, attrValue);
954975
}

packages/core/test/acceptance/i18n_spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3434,7 +3434,7 @@ describe('runtime i18n', () => {
34343434
{parameters.length, plural,
34353435
=1 {
34363436
Affects parameter
3437-
<span class="parameter-name" attr="should_be_present">{{ parameters[0].name }}</span>
3437+
<span class="parameter-name" label="should_be_present">{{ parameters[0].name }}</span>
34383438
}
34393439
other {
34403440
Affects {{parameters.length}} parameters, including
@@ -3453,7 +3453,7 @@ describe('runtime i18n', () => {
34533453
const fixture = TestBed.createComponent(MyApp);
34543454
fixture.detectChanges();
34553455
const span = (fixture.nativeElement as HTMLElement).querySelector('span')!;
3456-
expect(span.getAttribute('attr')).toEqual('should_be_present');
3456+
expect(span.getAttribute('label')).toEqual('should_be_present');
34573457
expect(span.getAttribute('class')).toEqual('parameter-name');
34583458
});
34593459

packages/core/test/render3/i18n/i18n_parse_spec.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,50 @@ describe('i18n_parse', () => {
297297
);
298298
});
299299
});
300+
301+
it('should properly sanitize malicious URLs like `<a href="evil.test">` injected into translations', () => {
302+
const tI18n = toT18n(`{
303+
�0�, select,
304+
A {<a href="javascript:console.log('hacked!');">malicious JS</a>}
305+
other {<a href="https://evil.test">malicious link</a>}
306+
}`);
307+
308+
fixture.apply(() => {
309+
applyCreateOpCodes(fixture.lView, tI18n.create, fixture.host, null);
310+
expect(fixture.host.innerHTML).toEqual(`<!--ICU ${HEADER_OFFSET + 0}:0-->`);
311+
});
312+
313+
fixture.apply(() => {
314+
ɵɵi18nExp('A');
315+
ɵɵi18nApply(0);
316+
expect(fixture.host.innerHTML).toEqual(
317+
`<a href="unsafe:blocked">malicious JS</a><!--ICU ${HEADER_OFFSET + 0}:0-->`,
318+
);
319+
});
320+
321+
fixture.apply(() => {
322+
ɵɵi18nExp('other');
323+
ɵɵi18nApply(0);
324+
expect(fixture.host.innerHTML).toEqual(
325+
`<a href="unsafe:blocked">malicious link</a><!--ICU ${HEADER_OFFSET + 0}:0-->`,
326+
);
327+
});
328+
});
329+
330+
it('should ignore unknown attributes', () => {
331+
const tI18n = toT18n(`{�0�, select, A {<div unknown="unknown"></div>} }`);
332+
333+
fixture.apply(() => {
334+
applyCreateOpCodes(fixture.lView, tI18n.create, fixture.host, null);
335+
expect(fixture.host.innerHTML).toEqual(`<!--ICU ${HEADER_OFFSET + 0}:0-->`);
336+
});
337+
338+
fixture.apply(() => {
339+
ɵɵi18nExp('A');
340+
ɵɵi18nApply(0);
341+
expect(fixture.host.innerHTML).toEqual(`<div></div><!--ICU ${HEADER_OFFSET + 0}:0-->`);
342+
});
343+
});
300344
});
301345

302346
function toT18n(text: string) {

0 commit comments

Comments
 (0)