From 7949980a7e3c7e397d7afe899ef1b0563c417b0e Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Mon, 15 Jun 2026 13:58:56 +0100 Subject: [PATCH] Apply html utils sanitiser to embedded page (#33842) * Apply html utils sanitiser to embedded page * Write tests --- apps/web/src/HtmlUtils.tsx | 13 ++++++++--- .../components/structures/EmbeddedPage.tsx | 13 ++++++++++- apps/web/test/unit-tests/HtmlUtils-test.tsx | 13 +++++++++-- .../__snapshots__/HtmlUtils-test.tsx.snap | 17 ++++++++++++++ .../views/context_menus/EmbeddedPage-test.tsx | 9 ++++++++ .../__snapshots__/EmbeddedPage-test.tsx.snap | 23 +++++++++++++++++++ 6 files changed, 82 insertions(+), 6 deletions(-) diff --git a/apps/web/src/HtmlUtils.tsx b/apps/web/src/HtmlUtils.tsx index 3fefbba418..abf092cbb8 100644 --- a/apps/web/src/HtmlUtils.tsx +++ b/apps/web/src/HtmlUtils.tsx @@ -89,11 +89,18 @@ export function unicodeToShortcode(char: string): string { /* * Given an untrusted HTML string, return a React node with an sanitized version * of that HTML. + * @param insaneHtml - the input to sanitize + * @param className - an optional class name to apply to the element + * @param sanitizeParams - the params to use for sanitization */ -export function sanitizedHtmlNode(insaneHtml: string): ReactNode { - const saneHtml = sanitizeHtml(insaneHtml, sanitizeHtmlParams); +export function sanitizedHtmlNode( + insaneHtml: string, + className?: string, + sanitizeParams = sanitizeHtmlParams, +): ReactNode { + const saneHtml = sanitizeHtml(insaneHtml, sanitizeParams); - return
; + return
; } export function getHtmlText(insaneHtml: string): string { diff --git a/apps/web/src/components/structures/EmbeddedPage.tsx b/apps/web/src/components/structures/EmbeddedPage.tsx index a363dc7b5d..4f2bd2f6a6 100644 --- a/apps/web/src/components/structures/EmbeddedPage.tsx +++ b/apps/web/src/components/structures/EmbeddedPage.tsx @@ -19,6 +19,9 @@ import { MatrixClientPeg } from "../../MatrixClientPeg"; import MatrixClientContext from "../../contexts/MatrixClientContext"; import { type ActionPayload } from "../../dispatcher/payloads"; import { Action } from "../../dispatcher/actions.ts"; +import { sanitizedHtmlNode } from "../../HtmlUtils.tsx"; +import { sanitizeHtmlParams, transformTags } from "../../Linkify.ts"; +import { objectExcluding } from "../../utils/objects.ts"; interface IProps { // URL to request embedded page content from @@ -126,7 +129,15 @@ export default class EmbeddedPage extends React.PureComponent { [`${className}_loggedIn`]: !!client, }); - const content =
; + const content = sanitizedHtmlNode(this.state.page, `${className}_body`, { + ...sanitizeHtmlParams, + transformTags: objectExcluding(transformTags, [ + // Disable the transformer for `img` as it only allows mxc resources + "img", + // Disable the default transformer as it forbids inline styles + "*", + ]), + }); if (this.props.scrollbar) { return {content}; diff --git a/apps/web/test/unit-tests/HtmlUtils-test.tsx b/apps/web/test/unit-tests/HtmlUtils-test.tsx index e56d7203cc..3c71bdfa70 100644 --- a/apps/web/test/unit-tests/HtmlUtils-test.tsx +++ b/apps/web/test/unit-tests/HtmlUtils-test.tsx @@ -6,11 +6,11 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React from "react"; +import React, { type ReactElement } from "react"; import { render, screen } from "jest-matrix-react"; import parse from "html-react-parser"; -import { bodyToHtml, bodyToNode, formatEmojis, topicToHtml } from "../../src/HtmlUtils"; +import { bodyToHtml, bodyToNode, formatEmojis, sanitizedHtmlNode, topicToHtml } from "../../src/HtmlUtils"; import SettingsStore from "../../src/settings/SettingsStore"; import { getMockClientWithEventEmitter } from "../test-utils"; import { SettingLevel } from "../../src/settings/SettingLevel"; @@ -319,3 +319,12 @@ describe("bodyToNode", () => { jest.resetAllMocks(); }); }); + +describe("sanitizedHtmlNode", () => { + it("should respect className", () => { + const sanitized = sanitizedHtmlNode('Link', "testClass"); + const { asFragment, getByText } = render(sanitized as ReactElement); + expect(getByText("Link").parentNode).toHaveClass("testClass"); + expect(asFragment()).toMatchSnapshot(); + }); +}); diff --git a/apps/web/test/unit-tests/__snapshots__/HtmlUtils-test.tsx.snap b/apps/web/test/unit-tests/__snapshots__/HtmlUtils-test.tsx.snap index cca4c999cc..d4bdea290b 100644 --- a/apps/web/test/unit-tests/__snapshots__/HtmlUtils-test.tsx.snap +++ b/apps/web/test/unit-tests/__snapshots__/HtmlUtils-test.tsx.snap @@ -91,3 +91,20 @@ exports[`bodyToNode should handle inline media when mediaIsVisible is true 1`] = `; + +exports[`sanitizedHtmlNode should respect className 1`] = ` + + + +`; diff --git a/apps/web/test/unit-tests/components/views/context_menus/EmbeddedPage-test.tsx b/apps/web/test/unit-tests/components/views/context_menus/EmbeddedPage-test.tsx index 925928cf21..7f92ffa6a8 100644 --- a/apps/web/test/unit-tests/components/views/context_menus/EmbeddedPage-test.tsx +++ b/apps/web/test/unit-tests/components/views/context_menus/EmbeddedPage-test.tsx @@ -47,4 +47,13 @@ describe("", () => { const { asFragment } = render(); expect(asFragment()).toMatchSnapshot(); }); + + it("should sanitise input", async () => { + fetchMock.get("https://other.page", `

Foo