summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorabd-msyukyu-odoo <abd@odoo.com>2025-02-04 14:38:02 +0100
committerabd-msyukyu-odoo <abd@odoo.com>2025-02-06 16:44:09 +0000
commitc7a60568d3ef77dc628e0534f5066157ffda2ba5 (patch)
treef4216800d4398179d16f7b9f3fda430229074b31
parent2797d34bfd7773eb2b6aa615009b4fb8687a0853 (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.js21
-rw-r--r--addons/html_editor/static/src/fields/html_viewer.js12
-rw-r--r--addons/html_editor/static/src/utils/html.js4
-rw-r--r--addons/html_editor/static/src/utils/sanitize.js22
-rw-r--r--addons/html_editor/static/tests/html_field.test.js48
-rw-r--r--addons/html_editor/static/tests/html_viewer.test.js24
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>`
+ );
+});