Skip to content
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/npm-publish.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ on:
options:
- playwright-common
- shared-components
- shared-utils
- module-api

concurrency: release
Expand Down
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"dependencies": {
"@babel/runtime": "^7.12.5",
"@element-hq/element-web-module-api": "workspace:*",
"@element-hq/element-web-shared-utils": "workspace:*",
"@element-hq/web-shared-components": "workspace:*",
"@fontsource/fira-code": "^5",
"@fontsource/inter": "catalog:",
Expand Down
72 changes: 45 additions & 27 deletions apps/web/src/HtmlUtils.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,21 @@ describe("bodyToHtml", () => {
expect(html).toMatchInlineSnapshot(`"<span class="mx_EventTile_searchHighlight">test</span> foo bar"`);
});

it("preserves source links when rendering composer quotes", () => {
const html = bodyToHtml(
{
body: "Example",
msgtype: "m.text",
formatted_body: '<a href="https://example.org" target="_self">Example</a>',
format: "org.matrix.custom.html",
},
[],
{ forComposerQuote: true },
);

expect(html).toBe('<a href="https://example.org" target="_self">Example</a>');
});

it("should not respect HTML tags in plaintext message highlighting", () => {
const html = bodyToHtml(
{
Expand Down Expand Up @@ -287,36 +302,39 @@ describe("bodyToNode", () => {
expect(asFragment()).toMatchSnapshot();
});

it.each([[true], [false]])("should handle inline media when mediaIsVisible is %s", (mediaIsVisible) => {
const cli = getMockClientWithEventEmitter({
mxcUrlToHttp: vi.fn().mockReturnValue("https://example.org/img"),
});
const { className, formattedBody } = bodyToNode(
{
"body": "![foo](mxc://going/knowwhere) Hello there",
"format": "org.matrix.custom.html",
"formatted_body": `<img src="mxc://going/knowwhere">foo</img> Hello there`,
"m.relates_to": {
"m.in_reply_to": {
event_id: "$eventId",
it.each([[true], [false]])(
"uses the app-specific MXC image transform when mediaIsVisible is %s",
(mediaIsVisible) => {
const cli = getMockClientWithEventEmitter({
mxcUrlToHttp: vi.fn().mockReturnValue("https://example.org/img"),
});
const { className, formattedBody } = bodyToNode(
{
"body": "![foo](mxc://going/knowwhere) Hello there",
"format": "org.matrix.custom.html",
"formatted_body": `<img src="mxc://going/knowwhere">foo</img> Hello there`,
"m.relates_to": {
"m.in_reply_to": {
event_id: "$eventId",
},
},
"msgtype": "m.text",
},
"msgtype": "m.text",
},
[],
{
mediaIsVisible,
},
);
[],
{
mediaIsVisible,
},
);

const { asFragment } = render(
<span className={className} dir="auto" dangerouslySetInnerHTML={{ __html: formattedBody! }} />,
);
expect(asFragment()).toMatchSnapshot();
// We do not want to download untrusted media.
// eslint-disable-next-line no-restricted-properties
expect(cli.mxcUrlToHttp).toHaveBeenCalledTimes(mediaIsVisible ? 1 : 0);
});
const { asFragment } = render(
<span className={className} dir="auto" dangerouslySetInnerHTML={{ __html: formattedBody! }} />,
);
expect(asFragment()).toMatchSnapshot();
// We do not want to download untrusted media.
// eslint-disable-next-line no-restricted-properties
expect(cli.mxcUrlToHttp).toHaveBeenCalledTimes(mediaIsVisible ? 1 : 0);
},
);

afterEach(() => {
vi.resetAllMocks();
Expand Down
74 changes: 33 additions & 41 deletions apps/web/src/HtmlUtils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,19 @@
*/

import React, { type JSX, type Key, type LegacyRef, type ReactNode } from "react";
import sanitizeHtml, { type IOptions } from "sanitize-html";
import {
sanitizeHtmlText,
isUrlPermitted,
sanitizeHtml,
type HtmlSanitizeOptions,
} from "@element-hq/element-web-shared-utils";
import classNames from "classnames";
import katex from "katex";
import { decode } from "html-entities";
import { type IContent } from "matrix-js-sdk/src/matrix";
import escapeHtml from "escape-html";
import { getEmojiFromUnicode } from "@matrix-org/emojibase-bindings";
import { PERMITTED_URL_SCHEMES, LINKIFIED_DATA_ATTRIBUTE } from "@element-hq/web-shared-components";
import { LINKIFIED_DATA_ATTRIBUTE } from "@element-hq/web-shared-components";

import SettingsStore from "./settings/SettingsStore";
import { stripHTMLReply, stripPlainReply } from "./utils/Reply";
Expand Down Expand Up @@ -96,51 +101,31 @@
export function sanitizedHtmlNode(
insaneHtml: string,
className?: string,
sanitizeParams = sanitizeHtmlParams,
sanitizeParams: HtmlSanitizeOptions = sanitizeHtmlParams,
): ReactNode {
const saneHtml = sanitizeHtml(insaneHtml, sanitizeParams);

return <div dangerouslySetInnerHTML={{ __html: saneHtml }} dir="auto" className={className} />;
}

export function getHtmlText(insaneHtml: string): string {
return sanitizeHtml(insaneHtml, {
allowedTags: [],
allowedAttributes: {},
selfClosing: [],
allowedSchemes: [],
disallowedTagsMode: "discard",
});
}

/**
* Tests if a URL from an untrusted source may be safely put into the DOM
* The biggest threat here is javascript: URIs.
* Note that the HTML sanitiser library has its own internal logic for
* doing this, to which we pass the same list of schemes. This is used in
* other places we need to sanitise URLs.
* @returns true if permitted, otherwise false
*/
export function isUrlPermitted(inputUrl: string): boolean {
try {
// URL parser protocol includes the trailing colon
return PERMITTED_URL_SCHEMES.includes(new URL(inputUrl).protocol.slice(0, -1));
} catch {
return false;
}
}
// Keep the app-facing export stable for settings and context-menu consumers.
export { isUrlPermitted, sanitizeHtmlText };

Check warning on line 112 in apps/web/src/HtmlUtils.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use `export…from` to re-export `sanitizeHtmlText`.

See more on https://sonarcloud.io/project/issues?id=element-web&issues=AaBsAoQcy_01LXV_oS1J&open=AaBsAoQcy_01LXV_oS1J&pullRequest=34933

Check warning on line 112 in apps/web/src/HtmlUtils.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use `export…from` to re-export `isUrlPermitted`.

See more on https://sonarcloud.io/project/issues?id=element-web&issues=AaBsAoQcy_01LXV_oS1I&open=AaBsAoQcy_01LXV_oS1I&pullRequest=34933

// this is the same as the above except with less rewriting
const composerSanitizeHtmlParams: IOptions = {
const composerSanitizeHtmlParams: HtmlSanitizeOptions = {
...sanitizeHtmlParams,
transformTags: {
// Composer quotes intentionally preserve the source link/media
// presentation while shared URL validation still runs.
"a": (tagName, attribs) => ({ tagName, attribs }),
"img": (tagName, attribs) => ({ tagName, attribs }),

Check warning on line 121 in apps/web/src/HtmlUtils.tsx

View workflow job for this annotation

GitHub Actions / Tests

Uncovered Line

Line 121 is not covered by tests

Check warning on line 121 in apps/web/src/HtmlUtils.tsx

View workflow job for this annotation

GitHub Actions / Tests

Uncovered Line

Line 121 is not covered by tests
"code": transformTags["code"],
"*": transformTags["*"],
},
};

// reduced set of allowed tags to avoid turning topics into Myspace
const topicSanitizeHtmlParams: IOptions = {
const topicSanitizeHtmlParams: HtmlSanitizeOptions = {
...sanitizeHtmlParams,
allowedTags: [
"font", // custom to matrix for IRC-style font coloring
Expand Down Expand Up @@ -308,9 +293,15 @@
}

function analyseEvent(content: IContent, highlights?: string[], opts: EventRenderOpts = {}): EventAnalysis {
let sanitizeParams = sanitizeHtmlParams;
let sanitizeParams: HtmlSanitizeOptions = {
...sanitizeHtmlParams,
transformTags: { ...sanitizeHtmlParams.transformTags },
};
if (opts.forComposerQuote) {
sanitizeParams = composerSanitizeHtmlParams;
sanitizeParams = {
...composerSanitizeHtmlParams,
transformTags: { ...composerSanitizeHtmlParams.transformTags },
};
}

if (opts.mediaIsVisible === false && sanitizeParams.transformTags?.["img"]) {
Expand All @@ -329,13 +320,14 @@

if (opts.linkify) {
// Prevent mutating the source of sanitizeParams.
sanitizeParams = { ...sanitizeParams };
if (typeof sanitizeParams.allowedAttributes === "object") {
const attribs = { ...sanitizeParams.allowedAttributes };
// We allow data-linkified because TextualBody uses it to passthrough links.
attribs["a"] = [...sanitizeParams.allowedAttributes["a"], `data-${LINKIFIED_DATA_ATTRIBUTE}`];
sanitizeParams.allowedAttributes = attribs;
} // else: No attibutes are are allowed for "a"
sanitizeParams = {
...sanitizeParams,
additionalAllowedAttributes: {
...sanitizeParams.additionalAllowedAttributes,
// We allow data-linkified because TextualBody uses it to passthrough links.
a: [...(sanitizeParams.additionalAllowedAttributes?.a ?? []), `data-${LINKIFIED_DATA_ATTRIBUTE}`],
},
};
}

try {
Expand Down Expand Up @@ -513,7 +505,7 @@
topicHasEmoji = mightContainEmoji(isFormattedTopic ? htmlTopic! : topic);

if (isFormattedTopic) {
safeTopic = sanitizeHtml(htmlTopic!, allowExtendedHtml ? sanitizeHtmlParams : topicSanitizeHtmlParams);
safeTopic = sanitizeHtml(htmlTopic, allowExtendedHtml ? sanitizeHtmlParams : topicSanitizeHtmlParams);
if (topicHasEmoji) {
safeTopic = formatEmojis(safeTopic, true).join("");
}
Expand Down
17 changes: 16 additions & 1 deletion apps/web/src/Linkify.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@ Please see LICENSE files in the repository root for full details.

import { describe, it, expect, vi } from "vitest";

import { roomAliasEventListeners, userIdEventListeners } from "./Linkify";
import { roomAliasEventListeners, transformTags, userIdEventListeners } from "./Linkify";
import dispatcher from "./dispatcher/dispatcher";
import { Action } from "./dispatcher/actions";
import * as permalinkUtils from "./utils/permalinks/Permalinks";

describe("linkify-matrix", () => {
describe("roomalias plugin", () => {
Expand Down Expand Up @@ -53,4 +54,18 @@ describe("linkify-matrix", () => {
);
});
});

it("keeps application permalinks local", () => {
const permalinkSpy = vi
.spyOn(permalinkUtils, "tryTransformPermalinkToLocalHref")
.mockReturnValue("#/room/!room:server");

const result = transformTags.a("a", { href: "https://matrix.to/#/!room:server" });

expect(result.attribs).toEqual({
href: "https://matrix.to/#/!room:server",
rel: "noreferrer noopener",
});
permalinkSpy.mockRestore();
});
});
Loading
Loading