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
19 changes: 12 additions & 7 deletions lib/NodeUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -137,12 +137,15 @@ function escapeMatchingClosingTag(rawText, parentTag) {
if (!rawText.toLowerCase().includes(parentClosingTag)) {
return rawText; // fast path
}
const result = [...rawText];
const matches = rawText.matchAll(new RegExp(parentClosingTag, 'ig'));
for (const match of matches) {
result[match.index] = '<';
}
return result.join('');
// Replace via String.prototype.replace so we don't have to reconcile
// UTF-16 code-unit offsets (match.index) with code-point indexing
// (`[...rawText]`). Astral characters (e.g. emoji) before the match
// would otherwise shift the replacement and leave a real `</tag>`
// break-out in the output.
return rawText.replace(
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was doing some regression testing and also found this issue with iframe and TextContent using astral characters like '😀', so we'll fix it in the same PR; it's added as another commit.

new RegExp(parentClosingTag, 'ig'),
(m) => '&lt;' + m.slice(1)
);
}

const CLOSING_COMMENT_REGEXP = /--!?>/;
Expand Down Expand Up @@ -191,7 +194,9 @@ function serializeOne(kid, parent) {
var ss = kid.serialize();
// If an element can have raw content, this content may
// potentially require escaping to avoid XSS.
if (hasRawContent[tagname.toUpperCase()]) {
var upperTag = tagname.toUpperCase();
if (hasRawContent[upperTag] ||
(upperTag === 'NOSCRIPT' && kid.ownerDocument._scripting_enabled)) {
ss = escapeMatchingClosingTag(ss, tagname);
}
if (html && extraNewLine[tagname] && ss.charAt(0)==='\n') s += '\n';
Expand Down
49 changes: 49 additions & 0 deletions test/xss.js
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,41 @@ exports.styleMatchingClosingTagSkipsUnclosedCommentedContent = function () {
return alertFired(html).should.eventually.be.false('alert fired for: ' + html);
};

exports.noscriptMatchingClosingTagInRawText = function () {
// Use a parsed document so `_scripting_enabled` is true, matching how
// Angular SSR / platform-server uses domino. With scripting enabled,
// <noscript> is a raw-text element on serialization and its text data
// must have any `</noscript` closing-tag prefix escaped, otherwise an
// attacker-controlled text payload can break out and inject a live
// <script> sibling in the receiving browser.
const document = domino.createDocument('<!doctype html><html><body></body></html>');
const noscript = document.createElement('noscript');
noscript.textContent = 'abc</noscript><script>alert(1)</script>';
document.body.appendChild(noscript);

document.body
.serialize()
.should.equal('<noscript>abc&lt;/noscript><script>alert(1)</script></noscript>');

const html = document.serialize();
return alertFired(html).should.eventually.be.false('alert fired for: ' + html);
};

exports.iframeMatchingClosingTagWithAstralPrefix = function () {
// Astral characters (e.g. emoji) before a `</iframe>` inside iframe text
// content must still trigger the closing-tag escape, otherwise the
// payload breaks out and a sibling <script> executes in the browser.
const document = domino.createDocument('<!doctype html><html><body><iframe></iframe></body></html>');
const iframe = document.getElementsByTagName('iframe')[0];
iframe.textContent =
'\uD83D\uDE00'.repeat(20) +
"</iframe><script>/*AAAAAAAAAAAAAAAAAAAAAAAAAAAA*/alert(1)</script>";

const html = document.serialize();
html.should.not.match(/<\/iframe><script>/);
return alertFired(html).should.eventually.be.false('alert fired for: ' + html);
};

exports.scriptMatchingClosingTagInRawText = function () {
const document = domino.createDocument('');
const script = document.createElement('script');
Expand Down Expand Up @@ -316,6 +351,20 @@ exports.verifyEscapeMatchingClosingTag = function () {
'iframe',
'<xmp></style><script>alert(1)</script></xmp>',
],

// Astral (non-BMP) characters before the closing tag must not shift
// the position of the escape: regex `match.index` is a UTF-16 code-unit
// offset while a code-point array would be off by one per astral char.
[
'\uD83D\uDE00'.repeat(20) + '</iframe><script>alert(1)</script>',
'iframe',
'\uD83D\uDE00'.repeat(20) + '&lt;/iframe><script>alert(1)</script>',
],
[
'\uD83D\uDE00</style><script>alert(1)</script>',
'style',
'\uD83D\uDE00&lt;/style><script>alert(1)</script>',
],
];
for (const [rawContent, parentTag, expected] of cases) {
NodeUtils.ɵescapeMatchingClosingTag(rawContent, parentTag).should.equal(expected);
Expand Down