Skip to content

Commit affbe0f

Browse files
Jeremy Daerjeremy
authored andcommitted
Escape angle brackets in attachment JSON so pasted attachments survive DOMPurify
Copying an attachment out of Trix and pasting it back silently dropped it whenever the attachment JSON contained "</style>" or another sequence DOMPurify's SAFE_FOR_XML mode treats as a raw-text or comment terminator. Paste runs the clipboard's text/html through HTMLParser under SAFE_FOR_XML, and DOMPurify's attribute rule removes the whole data-trix-attachment attribute on a match, before the forceKeepAttr set by Trix's uponSanitizeAttribute hook is honored. Quoted mail with an embedded <style> block is the common case: in HEY, 67 of 153 real mail bodies lost their embedded content on paste. Dragging the same content was lossless. Escape "<" and ">" inside the JSON as "\u003c" and "\u003e" at both ends: AttachmentView emits data-trix-attachment and data-trix-attributes that way, so Trix's own HTML never carries a trigger sequence, and HTMLSanitizer rewrites those attributes before DOMPurify sees them, so stored, server- rendered and older-Trix HTML with literal brackets survives too. In JSON text angle brackets only occur inside string literals, where the escapes spell the same characters, so JSON.parse reads back the same value; the sanitizer only rewrites values that already parse as JSON.
1 parent 2f6cd63 commit affbe0f

12 files changed

Lines changed: 275 additions & 8 deletions

File tree

action_text-trix/app/assets/javascripts/trix.js

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1332,6 +1332,19 @@ $\
13321332
offset: leftIndex
13331333
};
13341334
};
1335+
const angleBracketEscapes = {
1336+
"<": "\\u003c",
1337+
">": "\\u003e"
1338+
};
1339+
1340+
// Escapes "<" and ">" in JSON text as "\u003c" and "\u003e". In JSON they can only occur
1341+
// inside string literals, where the escapes spell the same characters, so JSON.parse reads
1342+
// the result back to the same value.
1343+
//
1344+
// This keeps sequences such as "</style>", "-->" and "]]>" out of the HTML attributes Trix
1345+
// stores JSON in: DOMPurify's SAFE_FOR_XML mode drops any attribute containing one, and it
1346+
// does so before honoring the hook that keeps data-trix-* attributes.
1347+
const escapeAngleBracketsInJSON = json => json.replace(/[<>]/g, bracket => angleBracketEscapes[bracket]);
13351348

13361349
class Hash extends TrixObject {
13371350
static fromCommonAttributesOfObjects() {
@@ -3256,6 +3269,7 @@ $\
32563269
});
32573270
stashedAttributes = [];
32583271
});
3272+
const JSON_ATTRIBUTES = "data-trix-attachment data-trix-attributes".split(" ");
32593273
const DEFAULT_ALLOWED_ATTRIBUTES = "style href src width height language class".split(" ");
32603274
const DEFAULT_FORBIDDEN_PROTOCOLS = "javascript:".split(" ");
32613275
const DEFAULT_FORBIDDEN_ELEMENTS = "script iframe form noscript".split(" ");
@@ -3336,6 +3350,20 @@ $\
33363350
element.removeAttribute(name);
33373351
}
33383352
});
3353+
3354+
// HTML from older Trix versions, server-side renderers and stored content carries the
3355+
// JSON with literal angle brackets. The hooks above put back a data-trix-* attribute that
3356+
// SAFE_FOR_XML drops for containing "</style>" or another raw-text closing sequence, but
3357+
// the restored value still carries it. Escaping the brackets before DOMPurify sees the
3358+
// value leaves it nothing to drop, and JSON.parse reads the same value back. A value that
3359+
// doesn't parse is left alone: HTMLParser ignores it either way, and rewriting it could
3360+
// only turn it into something that parses.
3361+
JSON_ATTRIBUTES.forEach(name => {
3362+
const value = element.getAttribute(name);
3363+
if (value && parsesAsJSON(value)) {
3364+
element.setAttribute(name, escapeAngleBracketsInJSON(value));
3365+
}
3366+
});
33393367
return element;
33403368
}
33413369
normalizeListElementNesting() {
@@ -3360,6 +3388,14 @@ $\
33603388
return element.getAttribute("data-trix-serialize") === "false" && !nodeIsAttachmentElement(element);
33613389
}
33623390
}
3391+
const parsesAsJSON = string => {
3392+
try {
3393+
JSON.parse(string);
3394+
return true;
3395+
} catch (error) {
3396+
return false;
3397+
}
3398+
};
33633399
const createBodyElementForHTML = function () {
33643400
let html = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : "";
33653401
// Remove everything after </html>
@@ -3480,15 +3516,15 @@ $\
34803516
}
34813517
getData() {
34823518
const data = {
3483-
trixAttachment: JSON.stringify(this.attachment),
3519+
trixAttachment: toJSONAttribute(this.attachment),
34843520
trixContentType: this.attachment.getContentType(),
34853521
trixId: this.attachment.id
34863522
};
34873523
const {
34883524
attributes
34893525
} = this.attachmentPiece;
34903526
if (!attributes.isEmpty()) {
3491-
data.trixAttributes = JSON.stringify(attributes);
3527+
data.trixAttributes = toJSONAttribute(attributes);
34923528
}
34933529
if (this.attachment.isPending()) {
34943530
data.trixSerialize = false;
@@ -3535,6 +3571,11 @@ $\
35353571
trixSerialize: false
35363572
}
35373573
});
3574+
3575+
// Attachment JSON is emitted with angle brackets escaped so that the HTML Trix produces
3576+
// survives being pasted back into Trix, whose insertHTML parses under DOMPurify's
3577+
// SAFE_FOR_XML mode.
3578+
const toJSONAttribute = object => escapeAngleBracketsInJSON(JSON.stringify(object));
35383579
const htmlContainsTagName = function (html, tagName) {
35393580
const div = makeElement("div");
35403581
HTMLSanitizer.setHTML(div, html || "");

src/test/system/pasting_test.js

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@ import { OBJECT_REPLACEMENT_CHARACTER } from "trix/constants"
44
import {
55
TEST_IMAGE_URL,
66
assert,
7+
attachmentHTML,
78
clickToolbarButton,
89
createFile,
910
expandSelection,
1011
expectDocument,
12+
insertText,
1113
moveCursor,
1214
pasteContent,
1315
pressKey,
@@ -18,6 +20,8 @@ import {
1820
typeCharacters,
1921
} from "test/test_helper"
2022
import { delay, nextFrame } from "../test_helpers/timing_helpers"
23+
import Attachment from "trix/models/attachment"
24+
import Text from "trix/models/text"
2125

2226
testGroup("Pasting", { template: "editor_empty" }, () => {
2327
test("paste plain text", async () => {
@@ -191,6 +195,34 @@ testGroup("Pasting", { template: "editor_empty" }, () => {
191195
assert.notOk(img.hasAttribute("onerror"), "img should not have an onerror attribute")
192196
})
193197

198+
test("paste Trix's own HTML for an attachment whose content and caption close a style tag", async () => {
199+
const content = "<style>.quoted { color: red }</style><p>quoted mail</p>"
200+
const attachment = new Attachment({ content, contentType: "text/html" })
201+
insertText(Text.textForAttachmentWithAttributes(attachment, { caption: "</style>" }))
202+
await nextFrame()
203+
204+
await pasteContent("text/html", getEditorElement().value)
205+
const pieces = getDocument().getAttachmentPieces()
206+
207+
assert.equal(pieces.length, 2, "pasted attachment was dropped")
208+
assert.equal(pieces[1].attachment.getContent(), content)
209+
assert.equal(pieces[1].getCaption(), "</style>")
210+
expectDocument(`${OBJECT_REPLACEMENT_CHARACTER}${OBJECT_REPLACEMENT_CHARACTER}\n`)
211+
})
212+
213+
test("paste stored HTML for an attachment whose content and caption close a style tag", async () => {
214+
const content = "<style>.quoted { color: red }</style><p>quoted mail</p>"
215+
const html = attachmentHTML({ content, contentType: "text/html" }, { caption: "</style>" })
216+
217+
await pasteContent("text/html", `copy${html}me`)
218+
const [ piece ] = getDocument().getAttachmentPieces()
219+
220+
assert.ok(piece, "pasted attachment was dropped")
221+
assert.equal(piece.attachment.getContent(), content)
222+
assert.equal(piece.getCaption(), "</style>")
223+
expectDocument(`copy${OBJECT_REPLACEMENT_CHARACTER}me\n`)
224+
})
225+
194226
test("prefers plain text when html lacks formatting", async () => {
195227
const pasteData = {
196228
"text/html": "<meta charset='utf-8'>a\nb",

src/test/test_helpers/editor_helpers.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,3 +58,16 @@ export const replaceDocument = function (document) {
5858

5959

6060
const render = () => getEditorController().render()
61+
62+
// Attachment markup as stored content and server-side renderers emit it: the JSON is
63+
// escaped only as far as an attribute value needs, so any angle brackets in it are
64+
// literal. (Browsers that escape angle brackets when serializing attributes would hide
65+
// that shape, so this doesn't go through outerHTML.)
66+
export const attachmentHTML = function (attachment, attributes) {
67+
const attributeHTML = (name, value) =>
68+
` ${name}="${JSON.stringify(value).replace(/&/g, "&amp;").replace(/"/g, "&quot;")}"`
69+
70+
const attributesHTML = attributes ? attributeHTML("data-trix-attributes", attributes) : ""
71+
72+
return `<figure${attributeHTML("data-trix-attachment", attachment)}${attributesHTML}></figure>`
73+
}

src/test/test_helpers/fixtures/fixtures.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -477,12 +477,13 @@ export const fixtures = {
477477
const attachment = new Attachment({ content, contentType, href })
478478
const text = Text.textForAttachmentWithAttributes(attachment)
479479

480+
// Mirrors what AttachmentView emits: angle brackets in the JSON are escaped.
480481
const figure = makeElement({
481482
tagName: "figure",
482483
className: "attachment attachment--content",
483484
editable: false,
484485
data: {
485-
trixAttachment: JSON.stringify(attachment),
486+
trixAttachment: JSON.stringify(attachment).replace(/</g, "\\u003c").replace(/>/g, "\\u003e"),
486487
trixContentType: contentType,
487488
trixId: attachment.id,
488489
},

src/test/unit.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import "test/unit/document_test"
66
import "test/unit/document_json_deserialization_test"
77
import "test/unit/document_view_test"
88
import "test/unit/helpers/custom_elements_test"
9+
import "test/unit/helpers/strings_test"
910
import "test/unit/html_parser_test"
1011
import "test/unit/html_sanitizer_test"
1112
import "test/unit/location_mapper_test"
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,32 @@
11
import { assert, eachFixture, test, testGroup } from "test/test_helper"
22

3+
import Attachment from "trix/models/attachment"
4+
import Block from "trix/models/block"
5+
import Document from "trix/models/document"
6+
import DocumentView from "trix/views/document_view"
7+
import Text from "trix/models/text"
8+
39
testGroup("DocumentView", () => {
410
eachFixture((name, details) => {
511
test(name, () => {
612
assert.documentHTMLEqual(details.document, details.html)
713
})
814
})
15+
16+
// Pasting the rendered HTML back into Trix parses it under DOMPurify's SAFE_FOR_XML
17+
// mode, which drops any attribute whose value contains "</style>".
18+
test("renders attachment JSON without angle brackets", () => {
19+
const content = "<style>p { color: red }</style><p>quoted mail</p>"
20+
const attachment = new Attachment({ content, contentType: "text/html" })
21+
const text = Text.textForAttachmentWithAttributes(attachment, { caption: "</style>" })
22+
const figure = DocumentView.render(new Document([ new Block(text) ])).querySelector("figure")
23+
24+
const attachmentJSON = figure.getAttribute("data-trix-attachment")
25+
const attributesJSON = figure.getAttribute("data-trix-attributes")
26+
27+
assert.notOk(/[<>]/.test(attachmentJSON), "raw angle brackets in attachment JSON")
28+
assert.notOk(/[<>]/.test(attributesJSON), "raw angle brackets in attributes JSON")
29+
assert.equal(JSON.parse(attachmentJSON).content, content)
30+
assert.equal(JSON.parse(attributesJSON).caption, "</style>")
31+
})
932
})
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { assert, test, testGroup } from "test/test_helper"
2+
import { escapeAngleBracketsInJSON } from "trix/core/helpers"
3+
4+
testGroup("Helpers: Strings", () => {
5+
testGroup("escapeAngleBracketsInJSON", () => {
6+
test("escapes every angle bracket", () => {
7+
const json = JSON.stringify({ content: "<style>a</style><!-- b --><![CDATA[c]]>", caption: "<" })
8+
const escaped = escapeAngleBracketsInJSON(json)
9+
10+
assert.notOk(/[<>]/.test(escaped), "raw angle brackets left in: " + escaped)
11+
assert.equal(escaped, "{\"content\":\"\\u003cstyle\\u003ea\\u003c/style\\u003e\\u003c!-- b --\\u003e\\u003c![CDATA[c]]\\u003e\",\"caption\":\"\\u003c\"}")
12+
})
13+
14+
test("parses back to the same value", () => {
15+
const values = [
16+
{ content: "<style>a</style>" },
17+
{ content: "\\<", caption: "\\\\>" },
18+
{ content: "\u003c\u003e", caption: "\\u003c" },
19+
{ content: "<\ud83d\ude00>", caption: "\"<\"" },
20+
{ nested: [ "<", { deeper: [ ">" ] } ], number: 1, flag: true, nothing: null },
21+
]
22+
23+
values.forEach((value) => {
24+
const json = JSON.stringify(value)
25+
assert.deepEqual(JSON.parse(escapeAngleBracketsInJSON(json)), value, json)
26+
})
27+
})
28+
29+
test("is idempotent", () => {
30+
const json = JSON.stringify({ content: "<style>a</style>" })
31+
const escaped = escapeAngleBracketsInJSON(json)
32+
33+
assert.equal(escapeAngleBracketsInJSON(escaped), escaped)
34+
})
35+
})
36+
})

src/test/unit/html_parser_test.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {
22
TEST_IMAGE_URL,
33
assert,
4+
attachmentHTML,
45
createCursorTarget,
56
eachFixture,
67
fixtures,
@@ -302,6 +303,28 @@ testGroup("HTMLParser", () => {
302303
assert.equal(document.getAttachmentPieces().length, 1)
303304
})
304305

306+
test("parses attachment whose content closes a style tag when pasting", () => {
307+
const content = "<style>p { color: red }</style><p>quoted mail</p>"
308+
const html = attachmentHTML({ contentType: "text/html", content }, { caption: "</style>" })
309+
const document = HTMLParser.parse(html, { purifyOptions: { SAFE_FOR_XML: true } }).getDocument()
310+
const [ piece ] = document.getAttachmentPieces()
311+
312+
assert.ok(piece, "attachment was dropped")
313+
assert.equal(piece.attachment.getContent(), content)
314+
assert.equal(piece.getCaption(), "</style>")
315+
})
316+
317+
test("parses attachment whose content nests an attachment closing a style tag when pasting", () => {
318+
const inner = attachmentHTML({ contentType: "text/html", content: "<style>p { color: red }</style><p>quoted</p>" })
319+
const content = `<p>reply</p>${inner}`
320+
const html = attachmentHTML({ contentType: "text/html", content })
321+
const document = HTMLParser.parse(html, { purifyOptions: { SAFE_FOR_XML: true } }).getDocument()
322+
const [ attachment ] = document.getAttachments()
323+
324+
assert.ok(attachment, "attachment was dropped")
325+
assert.equal(attachment.getContent(), content)
326+
})
327+
305328
test("parses attachment caption from large html string", () => {
306329
let { html } = fixtures["image attachment with edited caption"]
307330

src/test/unit/html_sanitizer_test.js

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {
22
assert,
3+
attachmentHTML,
34
test,
45
testGroup,
56
} from "test/test_helper"
@@ -53,7 +54,9 @@ testGroup("HTMLSanitizer", () => {
5354
const value = `{"contentType":"text/html","content":"${markup}"}`
5455
const html = `<figure data-trix-attachment='${value}'></figure>`
5556
const body = HTMLSanitizer.sanitize(html, { purifyOptions: { SAFE_FOR_XML: true } }).getBody()
56-
assert.equal(body.querySelector("figure").getAttribute("data-trix-attachment"), value)
57+
const kept = body.querySelector("figure").getAttribute("data-trix-attachment")
58+
assert.deepEqual(JSON.parse(kept), JSON.parse(value))
59+
assert.notOk(/[<>]/.test(kept), "raw angle brackets left in: " + kept)
5760
})
5861
})
5962

@@ -62,6 +65,60 @@ testGroup("HTMLSanitizer", () => {
6265
const body = HTMLSanitizer.sanitize(html, { purifyOptions: { SAFE_FOR_XML: true } }).getBody()
6366
assert.equal(body.querySelector("a").hasAttribute("class"), false)
6467
})
68+
69+
// DOMPurify's SAFE_FOR_XML attribute rule drops any attribute whose value contains a
70+
// sequence that could close a raw-text element or a comment, before the forceKeepAttr set
71+
// by Trix's uponSanitizeAttribute hook is honored. The afterSanitizeAttributes hook in
72+
// html_sanitizer.js puts the attribute back; escaping the JSON first means the value never
73+
// trips the rule, so what comes out carries no raw angle brackets either.
74+
const safeForXMLTriggers = [
75+
"</style>", "</script>", "</title>", "</xmp>", "</textarea>", "</noscript>", "</iframe>", "</noembed>", "</noframes>",
76+
"-->", "--!>", "]]>",
77+
]
78+
79+
safeForXMLTriggers.forEach((trigger) => {
80+
test(`keeps attachment JSON containing ${trigger} under SAFE_FOR_XML`, () => {
81+
const attachment = { contentType: "text/html", content: `<p>before ${trigger} after</p>` }
82+
const attributes = { caption: `caption ${trigger}` }
83+
const sanitized = HTMLSanitizer.sanitize(attachmentHTML(attachment, attributes), { purifyOptions: { SAFE_FOR_XML: true } })
84+
const figure = sanitized.body.querySelector("figure")
85+
86+
assert.ok(figure, "attachment element was dropped")
87+
assert.deepEqual(JSON.parse(figure.getAttribute("data-trix-attachment")), attachment)
88+
assert.deepEqual(JSON.parse(figure.getAttribute("data-trix-attributes")), attributes)
89+
assert.notOk(/[<>]/.test(figure.getAttribute("data-trix-attachment")), "raw angle brackets left in attachment JSON")
90+
assert.notOk(/[<>]/.test(figure.getAttribute("data-trix-attributes")), "raw angle brackets left in attributes JSON")
91+
})
92+
})
93+
94+
test("keeps nested attachment JSON containing </style> under SAFE_FOR_XML", () => {
95+
const inner = { contentType: "text/html", content: "<style>p { color: red }</style><p>quoted</p>" }
96+
const attachment = { contentType: "text/html", content: `<p>reply</p>${attachmentHTML(inner)}` }
97+
const sanitized = HTMLSanitizer.sanitize(attachmentHTML(attachment), { purifyOptions: { SAFE_FOR_XML: true } })
98+
const figure = sanitized.body.querySelector("figure")
99+
100+
assert.ok(figure, "attachment element was dropped")
101+
assert.deepEqual(JSON.parse(figure.getAttribute("data-trix-attachment")), attachment)
102+
})
103+
104+
test("keeps attachment attributes JSON containing </style> under SAFE_FOR_XML", () => {
105+
const attachment = { contentType: "image/png", filename: "example.png" }
106+
const attributes = { caption: "</style>" }
107+
const sanitized = HTMLSanitizer.sanitize(attachmentHTML(attachment, attributes), { purifyOptions: { SAFE_FOR_XML: true } })
108+
const figure = sanitized.body.querySelector("figure")
109+
110+
assert.ok(figure, "attachment element was dropped")
111+
assert.deepEqual(JSON.parse(figure.getAttribute("data-trix-attachment")), attachment)
112+
assert.deepEqual(JSON.parse(figure.getAttribute("data-trix-attributes")), attributes)
113+
})
114+
115+
test("leaves malformed attachment JSON alone", () => {
116+
const html = "<figure data-trix-attachment=\"{&quot;x:}<\" data-trix-attributes=\"<>\"></figure>"
117+
const figure = HTMLSanitizer.sanitize(html).body.querySelector("figure")
118+
119+
assert.equal(figure.getAttribute("data-trix-attachment"), "{\"x:}<")
120+
assert.equal(figure.getAttribute("data-trix-attributes"), "<>")
121+
})
65122
})
66123

67124
const withDOMPurifyConfig = (attrConfig = {}, fn) => {

src/trix/core/helpers/strings.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,3 +73,14 @@ const utf16StringDifference = function(a, b) {
7373
offset: leftIndex,
7474
}
7575
}
76+
77+
const angleBracketEscapes = { "<": "\\u003c", ">": "\\u003e" }
78+
79+
// Escapes "<" and ">" in JSON text as "\u003c" and "\u003e". In JSON they can only occur
80+
// inside string literals, where the escapes spell the same characters, so JSON.parse reads
81+
// the result back to the same value.
82+
//
83+
// This keeps sequences such as "</style>", "-->" and "]]>" out of the HTML attributes Trix
84+
// stores JSON in: DOMPurify's SAFE_FOR_XML mode drops any attribute containing one, and it
85+
// does so before honoring the hook that keeps data-trix-* attributes.
86+
export const escapeAngleBracketsInJSON = (json) => json.replace(/[<>]/g, (bracket) => angleBracketEscapes[bracket])

0 commit comments

Comments
 (0)