From d6314506287fd63cbc9f1b5aaee7c395956d134e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alberto=20Fern=C3=A1ndez-Capel?= Date: Mon, 4 Mar 2024 10:51:54 +0000 Subject: [PATCH 1/9] Allow custom HTML attributes in blocks In particular, keeps the assigned language attribute assigned to code blocks. This is useful for syntax highlighting libraries that use the language attribute to determine the language of the code block. --- src/inspector/templates/document.js | 4 ++++ src/test/test_helpers/fixtures/fixtures.js | 10 ++++++-- src/test/unit/html_parser_test.js | 2 +- src/trix/config/block_attributes.js | 1 + src/trix/models/block.js | 18 ++++++++++---- src/trix/models/composition.js | 10 ++++++++ src/trix/models/document.js | 6 +++++ src/trix/models/editor.js | 5 ++++ src/trix/models/html_parser.js | 28 +++++++++++++++++----- src/trix/models/html_sanitizer.js | 2 +- src/trix/views/block_view.js | 13 +++++++--- 11 files changed, 81 insertions(+), 18 deletions(-) diff --git a/src/inspector/templates/document.js b/src/inspector/templates/document.js index e74ffcde7..d9ce0b3b1 100644 --- a/src/inspector/templates/document.js +++ b/src/inspector/templates/document.js @@ -13,6 +13,10 @@ window.JST["trix/inspector/templates/document"] = function() { Attributes: ${JSON.stringify(block.attributes)} +
+ HTML Attributes: ${JSON.stringify(block.htmlAttributes)} +
+
Text: ${text.id}, Pieces: ${pieces.length}, Length: ${text.getLength()} diff --git a/src/test/test_helpers/fixtures/fixtures.js b/src/test/test_helpers/fixtures/fixtures.js index dc3f999c2..9224889af 100644 --- a/src/test/test_helpers/fixtures/fixtures.js +++ b/src/test/test_helpers/fixtures/fixtures.js @@ -41,9 +41,9 @@ const { css } = config const createDocument = function (...parts) { const blocks = parts.map((part) => { - const [ string, textAttributes, blockAttributes ] = Array.from(part) + const [ string, textAttributes, blockAttributes, htmlAttributes = {} ] = Array.from(part) const text = Text.textForStringWithAttributes(string, textAttributes) - return new Block(text, blockAttributes) + return new Block(text, blockAttributes, htmlAttributes) }) return new Document(blocks) @@ -192,6 +192,12 @@ export const fixtures = { html: `
${blockComment}12\n3
`, }, + "code with custom language": { + document: createDocument([ "puts \"Hello world!\"", {}, [ "code" ], { "language": "ruby" } ]), + html: `
${blockComment}puts "Hello world!"
`, + serializedHTML: "
puts \"Hello world!\"
" + }, + "multiple blocks with block comments in their text": { document: createDocument([ `a${blockComment}b`, {}, [ "quote" ] ], [ `${blockComment}c`, {}, [ "code" ] ]), html: `
${blockComment}a<!--block-->b
${blockComment}<!--block-->c
`, diff --git a/src/test/unit/html_parser_test.js b/src/test/unit/html_parser_test.js index a970ef47d..836186c5b 100644 --- a/src/test/unit/html_parser_test.js +++ b/src/test/unit/html_parser_test.js @@ -17,7 +17,7 @@ const cursorTargetLeft = createCursorTarget("left").outerHTML const cursorTargetRight = createCursorTarget("right").outerHTML testGroup("HTMLParser", () => { - eachFixture((name, { html, serializedHTML, document }) => { + eachFixture((name, { html, document }) => { test(name, () => { const parsedDocument = HTMLParser.parse(html).getDocument() assert.documentHTMLEqual(parsedDocument.copyUsingObjectsFromDocument(document), html) diff --git a/src/trix/config/block_attributes.js b/src/trix/config/block_attributes.js index 2c572699f..75e025b42 100644 --- a/src/trix/config/block_attributes.js +++ b/src/trix/config/block_attributes.js @@ -16,6 +16,7 @@ const attributes = { code: { tagName: "pre", terminal: true, + htmlAttributes: [ "language" ], text: { plaintext: true, }, diff --git a/src/trix/models/block.js b/src/trix/models/block.js index a822605a0..36d498925 100644 --- a/src/trix/models/block.js +++ b/src/trix/models/block.js @@ -5,19 +5,21 @@ import { arraysAreEqual, getBlockConfig, getListAttributeNames, + objectsAreEqual, spliceArray, } from "trix/core/helpers" export default class Block extends TrixObject { static fromJSON(blockJSON) { const text = Text.fromJSON(blockJSON.text) - return new this(text, blockJSON.attributes) + return new this(text, blockJSON.attributes, blockJSON.htmlAttributes) } - constructor(text, attributes) { + constructor(text, attributes, htmlAttributes) { super(...arguments) this.text = applyBlockBreakToText(text || new Text()) this.attributes = attributes || [] + this.htmlAttributes = htmlAttributes || {} } isEmpty() { @@ -27,11 +29,11 @@ export default class Block extends TrixObject { isEqualTo(block) { if (super.isEqualTo(block)) return true - return this.text.isEqualTo(block?.text) && arraysAreEqual(this.attributes, block?.attributes) + return this.text.isEqualTo(block?.text) && arraysAreEqual(this.attributes, block?.attributes) && objectsAreEqual(this.htmlAttributes, block?.htmlAttributes) } copyWithText(text) { - return new Block(text, this.attributes) + return new Block(text, this.attributes, this.htmlAttributes) } copyWithoutText() { @@ -39,7 +41,7 @@ export default class Block extends TrixObject { } copyWithAttributes(attributes) { - return new Block(this.text, attributes) + return new Block(this.text, attributes, this.htmlAttributes) } copyWithoutAttributes() { @@ -60,6 +62,11 @@ export default class Block extends TrixObject { return this.copyWithAttributes(attributes) } + addHTMLAttribute(attribute, value) { + const htmlAttributes = Object.assign({}, this.htmlAttributes, { [attribute]: value }) + return new Block(this.text, this.attributes, htmlAttributes) + } + removeAttribute(attribute) { const { listAttribute } = getBlockConfig(attribute) const attributes = removeLastValue(removeLastValue(this.attributes, attribute), listAttribute) @@ -173,6 +180,7 @@ export default class Block extends TrixObject { return { text: this.text, attributes: this.attributes, + htmlAttributes: this.htmlAttributes, } } diff --git a/src/trix/models/composition.js b/src/trix/models/composition.js index 5454a1f51..0438d3b12 100644 --- a/src/trix/models/composition.js +++ b/src/trix/models/composition.js @@ -341,6 +341,16 @@ export default class Composition extends BasicObject { } } + setHTMLAtributeAtPosition(position, attributeName, value) { + const block = this.document.getBlockAtPosition(position) + const allowedHTMLAttributes = getBlockConfig(block.getLastAttribute())?.htmlAttributes + + if (block && allowedHTMLAttributes?.includes(attributeName)) { + const newDocument = this.document.setHTMLAttributeAtPosition(position, attributeName, value) + this.setDocument(newDocument) + } + } + setTextAttribute(attributeName, value) { const selectedRange = this.getSelectedRange() if (!selectedRange) return diff --git a/src/trix/models/document.js b/src/trix/models/document.js index c8bf6396e..957045423 100644 --- a/src/trix/models/document.js +++ b/src/trix/models/document.js @@ -282,6 +282,12 @@ export default class Document extends TrixObject { return this.removeAttributeAtRange(attribute, range) } + setHTMLAttributeAtPosition(position, name, value) { + const block = this.getBlockAtPosition(position) + const updatedBlock = block.addHTMLAttribute(name, value) + return this.replaceBlock(block, updatedBlock) + } + insertBlockBreakAtRange(range) { let blocks range = normalizeRange(range) diff --git a/src/trix/models/editor.js b/src/trix/models/editor.js index 367940d60..8092b1052 100644 --- a/src/trix/models/editor.js +++ b/src/trix/models/editor.js @@ -137,6 +137,11 @@ export default class Editor { return this.composition.removeCurrentAttribute(name) } + // HTML attributes + setHTMLAtributeAtPosition(position, name, value) { + this.composition.setHTMLAtributeAtPosition(position, name, value) + } + // Nesting level canDecreaseNestingLevel() { diff --git a/src/trix/models/html_parser.js b/src/trix/models/html_parser.js index 5dc065a10..c09b4a1b8 100644 --- a/src/trix/models/html_parser.js +++ b/src/trix/models/html_parser.js @@ -33,9 +33,9 @@ const pieceForAttachment = (attachment, attributes = {}) => { return { attachment, attributes, type } } -const blockForAttributes = (attributes = {}) => { +const blockForAttributes = (attributes = {}, htmlAttributes = {}) => { const text = [] - return { text, attributes } + return { text, attributes, htmlAttributes } } const parseTrixDataAttribute = (element, name) => { @@ -133,8 +133,9 @@ export default class HTMLParser extends BasicObject { return this.appendStringWithAttributes("\n") } else if (element === this.containerElement || this.isBlockElement(element)) { const attributes = this.getBlockAttributes(element) + const htmlAttributes = this.getBlockHTMLAttributes(element) if (!arraysAreEqual(attributes, this.currentBlock?.attributes)) { - this.currentBlock = this.appendBlockForAttributesWithElement(attributes, element) + this.currentBlock = this.appendBlockForAttributesWithElement(attributes, element, htmlAttributes) this.currentBlockElement = element } } @@ -147,9 +148,10 @@ export default class HTMLParser extends BasicObject { if (elementIsBlockElement && !this.isBlockElement(element.firstChild)) { if (!this.isInsignificantTextNode(element.firstChild) || !this.isBlockElement(element.firstElementChild)) { const attributes = this.getBlockAttributes(element) + const htmlAttributes = this.getBlockHTMLAttributes(element) if (element.firstChild) { if (!(currentBlockContainsElement && arraysAreEqual(attributes, this.currentBlock.attributes))) { - this.currentBlock = this.appendBlockForAttributesWithElement(attributes, element) + this.currentBlock = this.appendBlockForAttributesWithElement(attributes, element, htmlAttributes) this.currentBlockElement = element } else { return this.appendStringWithAttributes("\n") @@ -233,9 +235,9 @@ export default class HTMLParser extends BasicObject { // Document construction - appendBlockForAttributesWithElement(attributes, element) { + appendBlockForAttributesWithElement(attributes, element, htmlAttributes = {}) { this.blockElements.push(element) - const block = blockForAttributes(attributes) + const block = blockForAttributes(attributes, htmlAttributes) this.blocks.push(block) return block } @@ -350,6 +352,20 @@ export default class HTMLParser extends BasicObject { return attributes.reverse() } + getBlockHTMLAttributes(element) { + const attributes = {} + const blockConfig = Object.values(config.blockAttributes).find(settings => settings.tagName === tagName(element)) + const allowedAttributes = blockConfig?.htmlAttributes || [] + + allowedAttributes.forEach((attribute) => { + if (element.hasAttribute(attribute)) { + attributes[attribute] = element.getAttribute(attribute) + } + }) + + return attributes + } + findBlockElementAncestors(element) { const ancestors = [] while (element && element !== this.containerElement) { diff --git a/src/trix/models/html_sanitizer.js b/src/trix/models/html_sanitizer.js index 6e342e51a..0782bd7b9 100644 --- a/src/trix/models/html_sanitizer.js +++ b/src/trix/models/html_sanitizer.js @@ -2,7 +2,7 @@ import BasicObject from "trix/core/basic_object" import { nodeIsAttachmentElement, removeNode, tagName, walkTree } from "trix/core/helpers" -const DEFAULT_ALLOWED_ATTRIBUTES = "style href src width height class".split(" ") +const DEFAULT_ALLOWED_ATTRIBUTES = "style href src width height language class".split(" ") const DEFAULT_FORBIDDEN_PROTOCOLS = "javascript:".split(" ") const DEFAULT_FORBIDDEN_ELEMENTS = "script iframe form".split(" ") diff --git a/src/trix/views/block_view.js b/src/trix/views/block_view.js index 158d9f6b9..fe3fbae63 100644 --- a/src/trix/views/block_view.js +++ b/src/trix/views/block_view.js @@ -42,12 +42,13 @@ export default class BlockView extends ObjectView { } createContainerElement(depth) { - let attributes, className + const attributes = {} + let className const attributeName = this.attributes[depth] - const { tagName } = getBlockConfig(attributeName) + const { tagName, htmlAttributes } = getBlockConfig(attributeName) if (depth === 0 && this.block.isRTL()) { - attributes = { dir: "rtl" } + Object.assign(attributes, { dir: "rtl" }) } if (attributeName === "attachmentGallery") { @@ -55,6 +56,12 @@ export default class BlockView extends ObjectView { className = `${css.attachmentGallery} ${css.attachmentGallery}--${size}` } + Object.entries(this.block.htmlAttributes).forEach(([ name, value ]) => { + if (htmlAttributes.includes(name)) { + attributes[name] = value + } + }) + return makeElement({ tagName, className, attributes }) } From 54a8cd0c3f72832fec48a77427b58a3ea8167041 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alberto=20Fern=C3=A1ndez-Capel?= Date: Tue, 12 Mar 2024 14:14:00 +0000 Subject: [PATCH 2/9] Default to empty HTML attributes --- src/trix/views/block_view.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/trix/views/block_view.js b/src/trix/views/block_view.js index fe3fbae63..c3d2600ea 100644 --- a/src/trix/views/block_view.js +++ b/src/trix/views/block_view.js @@ -46,7 +46,8 @@ export default class BlockView extends ObjectView { let className const attributeName = this.attributes[depth] - const { tagName, htmlAttributes } = getBlockConfig(attributeName) + const { tagName, htmlAttributes = [] } = getBlockConfig(attributeName) + if (depth === 0 && this.block.isRTL()) { Object.assign(attributes, { dir: "rtl" }) } From b53531b89663c029cfe2ca710e94809241c10575 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 16 Mar 2024 23:02:23 +0000 Subject: [PATCH 3/9] Bump follow-redirects from 1.15.4 to 1.15.6 Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.15.4 to 1.15.6. - [Release notes](https://github.com/follow-redirects/follow-redirects/releases) - [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.4...v1.15.6) --- updated-dependencies: - dependency-name: follow-redirects dependency-type: indirect ... Signed-off-by: dependabot[bot] --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 754ac2c17..e24c7dbc0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3348,9 +3348,9 @@ flatted@^3.2.7: integrity sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ== follow-redirects@^1.0.0: - version "1.15.4" - resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.4.tgz#cdc7d308bf6493126b17ea2191ea0ccf3e535adf" - integrity sha512-Cr4D/5wlrb0z9dgERpUL3LrmPKVDsETIJhaCMeDfuFYcqa5bldGV6wBsAN6X/vxlXQtFBMrXdXxdL8CbDTGniw== + version "1.15.6" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.6.tgz#7f815c0cda4249c74ff09e95ef97c23b5fd0399b" + integrity sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA== forever-agent@~0.6.1: version "0.6.1" From 968cedaa989e3feb120fd500abb92e382d980dc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alberto=20Fern=C3=A1ndez-Capel?= Date: Wed, 27 Mar 2024 11:36:25 +0000 Subject: [PATCH 4/9] v2.1.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index db8589e58..bd9605c06 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "trix", - "version": "2.0.10", + "version": "2.1.0", "description": "A rich text editor for everyday writing", "main": "dist/trix.umd.min.js", "module": "dist/trix.esm.min.js", From c6023ed325686abb771750fd4935f237f4eb2ae0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 11 Apr 2024 12:03:13 +0000 Subject: [PATCH 5/9] Bump tar from 6.2.0 to 6.2.1 Bumps [tar](https://github.com/isaacs/node-tar) from 6.2.0 to 6.2.1. - [Release notes](https://github.com/isaacs/node-tar/releases) - [Changelog](https://github.com/isaacs/node-tar/blob/main/CHANGELOG.md) - [Commits](https://github.com/isaacs/node-tar/compare/v6.2.0...v6.2.1) --- updated-dependencies: - dependency-name: tar dependency-type: indirect ... Signed-off-by: dependabot[bot] --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index e24c7dbc0..65a806069 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6288,9 +6288,9 @@ tar-stream@^2.1.4, tar-stream@^2.2.0: readable-stream "^3.1.1" tar@^6.0.2, tar@^6.1.2: - version "6.2.0" - resolved "https://registry.yarnpkg.com/tar/-/tar-6.2.0.tgz#b14ce49a79cb1cd23bc9b016302dea5474493f73" - integrity sha512-/Wo7DcT0u5HUV486xg675HtjNd3BXZ6xDbzsCUZPt5iw8bTQ63bP0Raut3mvro9u+CUyq7YQd8Cx55fsZXxqLQ== + version "6.2.1" + resolved "https://registry.yarnpkg.com/tar/-/tar-6.2.1.tgz#717549c541bc3c2af15751bea94b1dd068d4b03a" + integrity sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A== dependencies: chownr "^2.0.0" fs-minipass "^2.0.0" From 5e03f4a7dd5a53969f6b67e0a4c3765cb0b5220d Mon Sep 17 00:00:00 2001 From: Lewis Buckley Date: Fri, 26 Apr 2024 14:49:26 +0100 Subject: [PATCH 6/9] Sanitize noscript to prevent copy and paste XSS --- src/test/system/pasting_test.js | 15 +++++++++++++++ src/trix/models/html_sanitizer.js | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/test/system/pasting_test.js b/src/test/system/pasting_test.js index 419d2f237..8bcb9f642 100644 --- a/src/test/system/pasting_test.js +++ b/src/test/system/pasting_test.js @@ -89,6 +89,21 @@ testGroup("Pasting", { template: "editor_empty" }, () => { delete window.unsanitized }) + test("paste unsafe html with noscript", async () => { + window.unsanitized = [] + const pasteData = { + "text/plain": "x", + "text/html": `\ +
+ ` + } + + await pasteContent(pasteData) + await delay(20) + assert.deepEqual(window.unsanitized, []) + delete window.unsanitized + }) + test("prefers plain text when html lacks formatting", async () => { const pasteData = { "text/html": "a\nb", diff --git a/src/trix/models/html_sanitizer.js b/src/trix/models/html_sanitizer.js index 0782bd7b9..12893dc30 100644 --- a/src/trix/models/html_sanitizer.js +++ b/src/trix/models/html_sanitizer.js @@ -4,7 +4,7 @@ import { nodeIsAttachmentElement, removeNode, tagName, walkTree } from "trix/cor const DEFAULT_ALLOWED_ATTRIBUTES = "style href src width height language class".split(" ") const DEFAULT_FORBIDDEN_PROTOCOLS = "javascript:".split(" ") -const DEFAULT_FORBIDDEN_ELEMENTS = "script iframe form".split(" ") +const DEFAULT_FORBIDDEN_ELEMENTS = "script iframe form noscript".split(" ") export default class HTMLSanitizer extends BasicObject { static sanitize(html, options) { From 1abe3d27ee66135d7e759632f834cde9d36a1696 Mon Sep 17 00:00:00 2001 From: Lewis Buckley Date: Fri, 26 Apr 2024 14:49:01 +0100 Subject: [PATCH 7/9] Test attachment content is sanitized --- src/test/system/pasting_test.js | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/test/system/pasting_test.js b/src/test/system/pasting_test.js index 8bcb9f642..fa9891c6d 100644 --- a/src/test/system/pasting_test.js +++ b/src/test/system/pasting_test.js @@ -104,6 +104,21 @@ testGroup("Pasting", { template: "editor_empty" }, () => { delete window.unsanitized }) + test("paste data-trix-attachment unsafe html", async () => { + window.unsanitized = [] + const pasteData = { + "text/plain": "x", + "text/html": `\ + copy
me + `, + } + + await pasteContent(pasteData) + await delay(20) + assert.deepEqual(window.unsanitized, []) + delete window.unsanitized + }) + test("prefers plain text when html lacks formatting", async () => { const pasteData = { "text/html": "a\nb", From 14bac183313e5fb0ea61eafae4eed5de84848d10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alberto=20Fern=C3=A1ndez-Capel?= Date: Wed, 1 May 2024 14:50:29 +0100 Subject: [PATCH 8/9] Sanitize HTML content in data-trix-* attributes Prevents XSS attacks by crafting a malicious HTML content in the data-trix-* attributes. --- src/trix/models/html_parser.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/trix/models/html_parser.js b/src/trix/models/html_parser.js index c09b4a1b8..de3d3a7dc 100644 --- a/src/trix/models/html_parser.js +++ b/src/trix/models/html_parser.js @@ -40,7 +40,13 @@ const blockForAttributes = (attributes = {}, htmlAttributes = {}) => { const parseTrixDataAttribute = (element, name) => { try { - return JSON.parse(element.getAttribute(`data-trix-${name}`)) + const data = JSON.parse(element.getAttribute(`data-trix-${name}`)) + + if (data.contentType === "text/html" && data.content) { + data.content = HTMLSanitizer.sanitize(data.content).getHTML() + } + + return data } catch (error) { return {} } From 0c79bcb854b8e8ee23e7bec571fe9d8dbfab9e5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alberto=20Fern=C3=A1ndez-Capel?= Date: Fri, 3 May 2024 10:33:47 +0100 Subject: [PATCH 9/9] v2.1.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index bd9605c06..87bcffa57 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "trix", - "version": "2.1.0", + "version": "2.1.1", "description": "A rich text editor for everyday writing", "main": "dist/trix.umd.min.js", "module": "dist/trix.esm.min.js",