Visitar URL original
fix(core): avoid infinite loop in i18n if DOM is clobbered by crisbeto · Pull Request #71253 · angular/angular · GitHub
Skip to content
Open
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
7 changes: 4 additions & 3 deletions packages/core/src/render3/i18n/i18n_parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
assertString,
} from '../../util/assert';
import {CharCode} from '../../util/char_code';
import {getFirstChild, getNextSibling, getNodeName} from '../../util/dom';
import {loadIcuContainerVisitor} from './i18n_icu_container_visitor';

import {getDocument} from '../interfaces/document';
Expand Down Expand Up @@ -801,13 +802,13 @@ function walkIcuTree(
depth: number,
): number {
let bindingMask = 0;
let currentNode = parentNode.firstChild;
let currentNode = getFirstChild(parentNode);
while (currentNode) {
const newIndex = allocExpando(tView, lView, 1, null);
switch (currentNode.nodeType) {
case Node.ELEMENT_NODE:
const element = currentNode as Element;
const tagName = element.tagName.toLowerCase();
const tagName = getNodeName(element).toLowerCase();
if (Object.hasOwn(VALID_ELEMENTS, tagName)) {
addCreateNodeAndAppend(create, ELEMENT_MARKER, tagName, parentIdx, newIndex);
tView.data[newIndex] = tagName;
Expand Down Expand Up @@ -922,7 +923,7 @@ function walkIcuTree(
}
break;
}
currentNode = currentNode.nextSibling;
currentNode = getNextSibling(currentNode);
}
return bindingMask;
}
Expand Down
53 changes: 1 addition & 52 deletions packages/core/src/sanitization/html_sanitizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
*/

import {XSS_SECURITY_URL} from '../error_details_base_url';
import {getFirstChild, getNextSibling, getNodeName} from '../util/dom';
import {TrustedHTML} from '../util/security/trusted_type_defs';
import {trustedHTMLFromString} from '../util/security/trusted_types';

Expand Down Expand Up @@ -217,58 +218,6 @@ class SanitizingHtmlSerializer {
}
}

/**
* Verifies whether a given child node is a descendant of a given parent node.
* It may not be the case when properties like `.firstChild` are clobbered and
* accessing `.firstChild` results in an unexpected node returned.
*/
function isClobberedElement(parentNode: Node, childNode: Node): boolean {
return (
(parentNode.compareDocumentPosition(childNode) & Node.DOCUMENT_POSITION_CONTAINED_BY) !==
Node.DOCUMENT_POSITION_CONTAINED_BY
);
}

/**
* Retrieves next sibling node and makes sure that there is no
* clobbering of the `nextSibling` property happening.
*/
function getNextSibling(node: Node): Node | null {
const nextSibling = node.nextSibling;
// Make sure there is no `nextSibling` clobbering: navigating to
// the next sibling and going back to the previous one should result
// in the original node.
if (nextSibling && node !== nextSibling.previousSibling) {
throw clobberedElementError(nextSibling);
}
return nextSibling;
}

/**
* Retrieves first child node and makes sure that there is no
* clobbering of the `firstChild` property happening.
*/
function getFirstChild(node: Node): Node | null {
const firstChild = node.firstChild;
if (firstChild && isClobberedElement(node, firstChild)) {
throw clobberedElementError(firstChild);
}
return firstChild;
}

/** Gets a reasonable nodeName, even for clobbered nodes. */
export function getNodeName(node: Node): string {
const nodeName = node.nodeName;
// If the property is clobbered, assume it is an `HTMLFormElement`.
return typeof nodeName === 'string' ? nodeName : 'FORM';
}

function clobberedElementError(node: Node) {
return new Error(
`Failed to sanitize html because the element is clobbered: ${(node as Element).outerHTML}`,
);
}

// Regular Expressions for parsing tags and attributes
const SURROGATE_PAIR_REGEXP = /[\uD800-\uDBFF][\uDC00-\uDFFF]/g;
// ! to ~ is the ASCII range.
Expand Down
52 changes: 52 additions & 0 deletions packages/core/src/util/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,55 @@ export function escapeCommentText(value: string): string {
text.replace(COMMENT_DELIMITER, COMMENT_DELIMITER_ESCAPED),
);
}

/**
* Verifies whether a given child node is a descendant of a given parent node.
* It may not be the case when properties like `.firstChild` are clobbered and
* accessing `.firstChild` results in an unexpected node returned.
*/
function isClobberedElement(parentNode: Node, childNode: Node): boolean {
return (
(parentNode.compareDocumentPosition(childNode) & Node.DOCUMENT_POSITION_CONTAINED_BY) !==
Node.DOCUMENT_POSITION_CONTAINED_BY
);
}

/**
* Retrieves next sibling node and makes sure that there is no
* clobbering of the `nextSibling` property happening.
*/
export function getNextSibling(node: Node): Node | null {
const nextSibling = node.nextSibling;
// Make sure there is no `nextSibling` clobbering: navigating to
// the next sibling and going back to the previous one should result
// in the original node.
if (nextSibling && node !== nextSibling.previousSibling) {
throw clobberedElementError(nextSibling);
}
return nextSibling;
}

/**
* Retrieves first child node and makes sure that there is no
* clobbering of the `firstChild` property happening.
*/
export function getFirstChild(node: Node): Node | null {
const firstChild = node.firstChild;
if (firstChild && isClobberedElement(node, firstChild)) {
throw clobberedElementError(firstChild);
}
return firstChild;
}

/** Gets a reasonable nodeName, even for clobbered nodes. */
export function getNodeName(node: Node): string {
const nodeName = node.nodeName;
// If the property is clobbered, assume it is an `HTMLFormElement`.
return typeof nodeName === 'string' ? nodeName : 'FORM';
}

function clobberedElementError(node: Node) {
return new Error(
`Action failed because the element is clobbered: ${(node as Element).outerHTML}`,
);
}
25 changes: 20 additions & 5 deletions packages/core/test/acceptance/i18n_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1813,7 +1813,6 @@ describe('runtime i18n', () => {
}</div>
`,
standalone: false,

changeDetection: ChangeDetectionStrategy.Eager,})
class AppComponent {
type = 'A';
Expand Down Expand Up @@ -1866,7 +1865,7 @@ describe('runtime i18n', () => {
<div i18n="@@idA">{count$ | async, select, 1 {{{count$ | async}} item} 2 {two items}}</div>
`,
standalone: false,

changeDetection: ChangeDetectionStrategy.Eager,})
class AppComponent {
count$ = new BehaviorSubject<number>(1);
Expand Down Expand Up @@ -3312,7 +3311,6 @@ describe('runtime i18n', () => {
<div i18n>before|<div myDir>inside</div>|after</div>
`,
standalone: false,

changeDetection: ChangeDetectionStrategy.Eager,})
class MyApp {}

Expand Down Expand Up @@ -3397,7 +3395,6 @@ describe('runtime i18n', () => {
<div i18n [title]="null | async"><div>A</div></div>
<div i18n>{{(null | async)||'B'}}<div></div></div>`,
standalone: false,

changeDetection: ChangeDetectionStrategy.Eager,})
class MyApp {}

Expand All @@ -3419,7 +3416,6 @@ describe('runtime i18n', () => {
</middle>
</parent>`,
standalone: false,

changeDetection: ChangeDetectionStrategy.Eager,})
class MyApp {}

Expand Down Expand Up @@ -3774,6 +3770,25 @@ describe('runtime i18n', () => {
const input: HTMLInputElement = fixture.nativeElement.querySelector('input');
expect(input.getAttribute('formaction')).toMatch(/^unsafe:/);
});

it('should not be vulnerable to DOM clobbering of nodeName/tagName in ICU expressions', () => {
loadTranslations({
[computeMsgId('{VAR_PLURAL, plural, other {other}}')]:
'{VAR_PLURAL, plural, other {<input name=nodeName form=x><input name=tagName form=x>hello<form id=x></form>}}',
});
const fixture = initWithTemplate(AppComp, `<div i18n>{count, plural, other {other}}</div>`);
expect(fixture.nativeElement.textContent).toBe('hello');
});

it('should not enter an infinite loop when nextSibling or firstChild is clobbered in ICU expressions', () => {
loadTranslations({
[computeMsgId('{VAR_PLURAL, plural, other {other}}')]:
'{VAR_PLURAL, plural, other {<input name=nextSibling form=x>hello<form id=x></form>}}',
});
expect(() => {
initWithTemplate(AppComp, `<div i18n>{count, plural, other {other}}</div>`);
}).toThrowError(/Action failed because the element is clobbered/);
});
}
});
});
Expand Down
2 changes: 1 addition & 1 deletion packages/core/test/sanitization/html_sanitizer_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ describe('HTML sanitizer', () => {
// depending on a platform.
if (isBrowser) {
// Running in a real browser
const errorMsg = 'Failed to sanitize html because the element is clobbered: ';
const errorMsg = 'Action failed because the element is clobbered: ';
expect(nextSibling).toThrowError(`${errorMsg}<input name="nextSibling" form="a">`);
expect(firstChild).toThrowError(`${errorMsg}<object form="a" id="firstChild"></object>`);
} else {
Expand Down
Loading