diff options
| author | abd-msyukyu-odoo <abd@odoo.com> | 2025-02-04 14:38:02 +0100 |
|---|---|---|
| committer | abd-msyukyu-odoo <abd@odoo.com> | 2025-02-06 16:44:09 +0000 |
| commit | c7a60568d3ef77dc628e0534f5066157ffda2ba5 (patch) | |
| tree | f4216800d4398179d16f7b9f3fda430229074b31 | |
| parent | 2797d34bfd7773eb2b6aa615009b4fb8687a0853 (diff) | |
[FIX] html_editor: convert xml self-closing elements to html
HTML content is often saved as XML (i.e. through templates) and saved as such in
the database. This introduces issues when elements are written under their
`self-closing` format (i.e. `<a/>` or `<br/>`) as browsers will incorrectly
parse these values as HTML by adding a closing tag at an arbitrary position
which may modify the initial nodes configuration. This can be prevented by
post-processing untrusted content before the html parsing.
This commit is an overhaul of:
https://github.com/odoo/odoo/commit/26b922ef5cad42da7e188195e919e54878d472fc
https://github.com/odoo/odoo/commit/1a8a943d8373864165cb513b3767b866d626fad8
https://github.com/odoo/odoo/commit/4e547b24a323f8e59a11c5b78cbea38effc37938
in order to apply the conversion at critical entry points for editor assets:
- HtmlField is an entry point for data coming from the server, stored on the
record. Every access of the record data should not be trusted and go through
the post-processing.
- HtmlViewer is an entry point as it can be used as a standalone (see
`website_knowledge`, or the HistoryDialog) for data coming from the server.
- Editor is an entry point as it was developed to be useable as a standalone.
task-4547973
closes odoo/odoo#196442
Related: odoo/enterprise#78532
Signed-off-by: David Monjoie (dmo) <dmo@odoo.com>
| -rw-r--r-- | addons/html_editor/static/src/fields/html_field.js | 21 | ||||
| -rw-r--r-- | addons/html_editor/static/src/fields/html_viewer.js | 12 | ||||
| -rw-r--r-- | addons/html_editor/static/src/utils/html.js | 4 | ||||
| -rw-r--r-- | addons/html_editor/static/src/utils/sanitize.js | 22 | ||||
| -rw-r--r-- | addons/html_editor/static/tests/html_field.test.js | 48 | ||||
| -rw-r--r-- | addons/html_editor/static/tests/html_viewer.test.js | 24 |
6 files changed, 115 insertions, 16 deletions
diff --git a/addons/html_editor/static/src/fields/html_field.js b/addons/html_editor/static/src/fields/html_field.js index b5b4fe1cbe0..a8a36f2b9d4 100644 --- a/addons/html_editor/static/src/fields/html_field.js +++ b/addons/html_editor/static/src/fields/html_field.js @@ -11,7 +11,7 @@ import { } from "@html_editor/others/embedded_components/embedding_sets"; import { normalizeHTML } from "@html_editor/utils/html"; import { Wysiwyg } from "@html_editor/wysiwyg"; -import { Component, status, useRef, useState } from "@odoo/owl"; +import { Component, markup, status, useRef, useState } from "@odoo/owl"; import { localization } from "@web/core/l10n/localization"; import { _t } from "@web/core/l10n/translation"; import { registry } from "@web/core/registry"; @@ -22,6 +22,7 @@ import { standardFieldProps } from "@web/views/fields/standard_field_props"; import { TranslationButton } from "@web/views/fields/translation_button"; import { HtmlViewer } from "./html_viewer"; import { withSequence } from "@html_editor/utils/resource"; +import { fixInvalidHTML, instanceofMarkup } from "@html_editor/utils/sanitize"; /** * Check whether the current value contains nodes that would break @@ -84,17 +85,12 @@ export class HtmlField extends Component { useRecordObserver((record) => { // Reset Wysiwyg when we discard or onchange value - const newValue = record.data[this.props.name]; + const newValue = fixInvalidHTML(record.data[this.props.name]); if (!this.isDirty) { - const value = normalizeHTML( - newValue.toString(), - this.clearElementToCompare.bind(this) - ); + const value = normalizeHTML(newValue, this.clearElementToCompare.bind(this)); if (this.lastValue !== value) { this.state.key++; - this.state.containsComplexHTML = computeContainsComplexHTML( - record.data[this.props.name] - ); + this.state.containsComplexHTML = computeContainsComplexHTML(newValue); this.lastValue = value; } } @@ -109,7 +105,12 @@ export class HtmlField extends Component { } get value() { - return this.props.record.data[this.props.name]; + const value = this.props.record.data[this.props.name]; + const newVal = fixInvalidHTML(value); + if (instanceofMarkup(value)) { + return markup(newVal); + } + return newVal; } get displayReadonly() { diff --git a/addons/html_editor/static/src/fields/html_viewer.js b/addons/html_editor/static/src/fields/html_viewer.js index 4b31bdc8e38..29b8a481dd9 100644 --- a/addons/html_editor/static/src/fields/html_viewer.js +++ b/addons/html_editor/static/src/fields/html_viewer.js @@ -1,5 +1,6 @@ import { Component, + markup, onMounted, onWillStart, onWillUnmount, @@ -10,6 +11,7 @@ import { } from "@odoo/owl"; import { getBundle } from "@web/core/assets"; import { memoize } from "@web/core/utils/functions"; +import { fixInvalidHTML, instanceofMarkup } from "@html_editor/utils/sanitize"; import { TableOfContentManager } from "@html_editor/others/embedded_components/core/table_of_content/table_of_content_manager"; export class HtmlViewer extends Component { @@ -96,11 +98,15 @@ export class HtmlViewer extends Component { /** * Allows overrides to process the value used in the Html Viewer. * - * @param { Markup } value - * @returns { Markup } + * @param { string | Markup } value + * @returns { string | Markup } */ formatValue(value) { - return value; + const newVal = fixInvalidHTML(value); + if (instanceofMarkup(value)) { + return markup(newVal); + } + return newVal; } /** diff --git a/addons/html_editor/static/src/utils/html.js b/addons/html_editor/static/src/utils/html.js index d9784f2aead..5e660681da4 100644 --- a/addons/html_editor/static/src/utils/html.js +++ b/addons/html_editor/static/src/utils/html.js @@ -1,5 +1,3 @@ -import { fixInvalidHTML } from "./sanitize"; - /** * @param { Document } document * @param { string } html @@ -29,7 +27,7 @@ export function parseHTML(document, html) { */ export function normalizeHTML(content, cleanup = () => {}) { const parser = new document.defaultView.DOMParser(); - const body = parser.parseFromString(fixInvalidHTML(content), "text/html").body; + const body = parser.parseFromString(content, "text/html").body; cleanup(body); return body.innerHTML; } diff --git a/addons/html_editor/static/src/utils/sanitize.js b/addons/html_editor/static/src/utils/sanitize.js index 4836e3f27f5..e8e90aa1d63 100644 --- a/addons/html_editor/static/src/utils/sanitize.js +++ b/addons/html_editor/static/src/utils/sanitize.js @@ -1,5 +1,6 @@ import { containsAnyInline } from "./dom_info"; import { wrapInlinesInBlocks } from "./dom"; +import { markup } from "@odoo/owl"; export function initElementForEdition(element, options = {}) { if ( @@ -16,7 +17,28 @@ export function initElementForEdition(element, options = {}) { } } +/** + * Properly close common XML-like self-closing elements to avoid HTML parsing + * issues. + * + * @param {string} content + * @returns {string} + */ export function fixInvalidHTML(content) { + if (!content) { + return content; + } + // TODO: improve the regex to support nodes with data-attributes containing + // `/` and `>` characters. const regex = /<\s*(a|strong|t)[^<]*?\/\s*>/g; return content.replace(regex, (match, g0) => match.replace(/\/\s*>/, `></${g0}>`)); } + +let Markup = null; + +export function instanceofMarkup(value) { + if (!Markup) { + Markup = markup("").constructor; + } + return value instanceof Markup; +} diff --git a/addons/html_editor/static/tests/html_field.test.js b/addons/html_editor/static/tests/html_field.test.js index 7821d3f1d1e..f911df725bf 100644 --- a/addons/html_editor/static/tests/html_field.test.js +++ b/addons/html_editor/static/tests/html_field.test.js @@ -324,6 +324,54 @@ test("links should open on a new tab in readonly", async () => { } }); +test("XML-like self-closing elements are fixed in readonly mode", async () => { + Partner._records = [ + { + id: 1, + txt: `<a href="#"/>outside<a href="#">inside</a>`, + }, + ]; + await mountView({ + type: "form", + resId: 1, + resIds: [1, 2], + resModel: "partner", + arch: ` + <form> + <field name="txt" widget="html" readonly="1"/> + </form>`, + }); + expect(".odoo-editor-editable").toHaveCount(0); + expect(`[name="txt"] .o_readonly`).toHaveCount(1); + expect(`[name="txt"] .o_readonly`).toHaveInnerHTML( + `<a href="#" target="_blank" rel="noreferrer"></a>outside<a href="#" target="_blank" rel="noreferrer">inside</a>` + ); +}); + +test("XML-like self-closing elements are fixed in editable mode", async () => { + Partner._records = [ + { + id: 1, + txt: `<a href="#"/>outside<a href="#">inside</a>`, + }, + ]; + await mountView({ + type: "form", + resId: 1, + resIds: [1, 2], + resModel: "partner", + arch: ` + <form> + <field name="txt" widget="html"/> + </form>`, + }); + expect(".odoo-editor-editable").toHaveCount(1); + expect(`[name="txt"] .o_readonly`).toHaveCount(0); + expect(`[name="txt"] .odoo-editor-editable`).toHaveInnerHTML( + `<div class="o-paragraph">outside<a href="#">inside</a></div>` + ); +}); + test("edit and save a html field", async () => { onRpc("web_save", ({ args }) => { expect(args[1]).toEqual({ diff --git a/addons/html_editor/static/tests/html_viewer.test.js b/addons/html_editor/static/tests/html_viewer.test.js new file mode 100644 index 00000000000..088b5de5b2e --- /dev/null +++ b/addons/html_editor/static/tests/html_viewer.test.js @@ -0,0 +1,24 @@ +import { HtmlViewer } from "@html_editor/fields/html_viewer"; +import { expect, test } from "@odoo/hoot"; +import { animationFrame } from "@odoo/hoot-mock"; +import { markup } from "@odoo/owl"; +import { mountWithCleanup } from "@web/../tests/web_test_helpers"; +import { registry } from "@web/core/registry"; +import { WebClient } from "@web/webclient/webclient"; + +test(`XML-like self-closing elements are fixed in a standalone HtmlViewer`, async () => { + await mountWithCleanup(WebClient); + + registry.category("main_components").add("mycomponent", { + Component: HtmlViewer, + props: { + config: { + value: markup(`<a href="#"/>outside<a href="#">inside</a>`), + }, + }, + }); + await animationFrame(); + expect(".o_readonly").toHaveInnerHTML( + `<a href="#" target="_blank" rel="noreferrer"></a>outside<a href="#" target="_blank" rel="noreferrer">inside</a>` + ); +}); |
