undefined } as any}
+ replyEventId="$reply:example.com"
+ />
+ );
+
+ expect(container.querySelector('img[src^="mxc://"]')).toBeNull();
+ expect(warnSpy).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/app/components/message/Reply.tsx b/src/app/components/message/Reply.tsx
index a7e10729b..cf1786ca8 100644
--- a/src/app/components/message/Reply.tsx
+++ b/src/app/components/message/Reply.tsx
@@ -8,6 +8,7 @@ import { useAtomValue } from 'jotai';
import { getMemberDisplayName, trimReplyFromBody, trimReplyFromFormattedBody } from '$utils/room';
import { getMxIdLocalPart } from '$utils/matrix';
import { randomNumberBetween } from '$utils/common';
+import { sanitizeCustomHtml } from '$utils/sanitize';
import {
getReactCustomHtmlParser,
scaleSystemEmoji,
@@ -25,7 +26,6 @@ import { useMatrixClient } from '$hooks/useMatrixClient';
import { useMemberEventParser } from '$hooks/useMemberEventParser';
import { StateEvent, MessageEvent } from '$types/matrix/room';
import { useMentionClickHandler } from '$hooks/useMentionClickHandler';
-import { sanitizeCustomHtml } from '$utils/sanitize';
import { useTranslation } from 'react-i18next';
import * as customHtmlCss from '$styles/CustomHtml.css';
import {
@@ -93,7 +93,8 @@ type ReplyProps = {
};
export const sanitizeReplyFormattedPreview = (formattedBody: string): string => {
- const strippedHtml = trimReplyFromFormattedBody(formattedBody)
+ const safeFormattedBody = sanitizeCustomHtml(formattedBody);
+ const strippedHtml = trimReplyFromFormattedBody(safeFormattedBody)
.replaceAll(/
/gi, ' ')
.replaceAll(/<\/p>\s*]*>/gi, ' ')
.replaceAll(/<\/?p[^>]*>/gi, '')
@@ -101,7 +102,7 @@ export const sanitizeReplyFormattedPreview = (formattedBody: string): string =>
.replaceAll(/<\/?(ul|ol|li|blockquote|h[1-6]|pre|div)[^>]*>/gi, '')
.replaceAll(/(?:\r\n|\r|\n)/g, ' ');
- return sanitizeCustomHtml(strippedHtml);
+ return strippedHtml;
};
export const Reply = as<'div', ReplyProps>(
@@ -138,6 +139,9 @@ export const Reply = as<'div', ReplyProps>(
const badEncryption = replyEvent?.getContent().msgtype === 'm.bad.encrypted';
const mentionClickHandler = useMentionClickHandler(room.roomId);
+ const isFormattedReply =
+ format === 'org.matrix.custom.html' && typeof formattedBody === 'string';
+ const hasPlainTextReply = typeof body === 'string' && body !== '';
// An encrypted event that hasn't been decrypted yet (keys pending) has an
// empty result from getClearContent(). Treat it as still-loading rather
@@ -170,7 +174,7 @@ export const Reply = as<'div', ReplyProps>(
[mx, room.roomId, mentionClickHandler, nicknames]
);
- if (format === 'org.matrix.custom.html' && formattedBody) {
+ if (isFormattedReply && formattedBody !== '') {
const sanitizedHtml = sanitizeReplyFormattedPreview(formattedBody);
const parserOpts = getReactCustomHtmlParser(mx, room.roomId, {
linkifyOpts: replyLinkifyOpts,
@@ -179,7 +183,7 @@ export const Reply = as<'div', ReplyProps>(
handleMentionClick: mentionClickHandler,
});
bodyJSX = parse(sanitizedHtml, parserOpts) as JSX.Element;
- } else if (body) {
+ } else if (hasPlainTextReply) {
const strippedBody = trimReplyFromBody(body).replaceAll(/(?:\r\n|\r|\n)/g, ' ');
bodyJSX = scaleSystemEmoji(strippedBody);
} else if (eventType === StateEvent.RoomMember && !!replyEvent) {
@@ -237,6 +241,13 @@ export const Reply = as<'div', ReplyProps>(
>
);
}
+ let replyContent = bodyJSX;
+ if (isBlockedSender) {
+ replyContent = ;
+ } else if (badEncryption) {
+ replyContent = ;
+ }
+
return (
{threadRootId && (
@@ -261,11 +272,7 @@ export const Reply = as<'div', ReplyProps>(
>
{replyEvent !== undefined && !isPendingDecrypt ? (
- {(() => {
- if (isBlockedSender) return ;
- if (badEncryption) return ;
- return bodyJSX;
- })()}
+ {replyContent}
) : (
(isRedacted && ) || (
diff --git a/src/app/components/message/modals/MessageForward.tsx b/src/app/components/message/modals/MessageForward.tsx
index 7e29f3d49..133dc940e 100644
--- a/src/app/components/message/modals/MessageForward.tsx
+++ b/src/app/components/message/modals/MessageForward.tsx
@@ -23,7 +23,7 @@ import { allRoomsAtom } from '$state/room-list/roomList';
import { useAllJoinedRoomsSet, useGetRoom } from '$hooks/useGetRoom';
import { factoryRoomIdByActivity } from '$utils/sort';
import * as css from '$features/room/message/styles.css';
-import { sanitizeCustomHtml } from '$utils/sanitize';
+import { sanitizeCustomHtml, sanitizeText } from '$utils/sanitize';
import { getStateEvents } from '$utils/room';
import { StateEvent } from '$types/matrix/room';
import { createDebugLogger } from '$utils/debugLogger';
@@ -206,12 +206,12 @@ export function MessageForwardInternal({
const safeHtml =
originalFormattedBody === undefined
- ? sanitizeCustomHtml(originalBody).replaceAll('\n', '
')
+ ? sanitizeText(originalBody).replaceAll('\n', '
')
: sanitizeCustomHtml(originalFormattedBody);
newBodyHtml =
`` +
- `
${sanitizeCustomHtml(bodyModifText)}
` +
+ `
${sanitizeText(bodyModifText)}
` +
`
${safeHtml}
` +
`
`;
}
diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx
index 1f924a909..70eca4dc2 100644
--- a/src/app/features/room/RoomInput.tsx
+++ b/src/app/features/room/RoomInput.tsx
@@ -144,7 +144,7 @@ import {
} from '$hooks/usePerMessageProfile';
import { Microphone, Stop } from '@phosphor-icons/react';
import { getSupportedAudioExtension } from '$plugins/voice-recorder-kit/supportedCodec';
-import { sanitizeCustomHtml } from '$utils/sanitize';
+import { sanitizeText } from '$utils/sanitize';
import { PKitCommandMessageHandler } from '$plugins/pluralkit-handler/PKitCommandMessageHandler';
import { PKitProxyMessageHandler } from '$plugins/pluralkit-handler/PKitProxyMessageHandler';
import { SchedulePickerDialog } from './schedule-send';
@@ -812,7 +812,7 @@ export const RoomInput = forwardRef(
/**
* html escaped version of the display name
*/
- const escapedName = sanitizeCustomHtml(perMessageProfile.name);
+ const escapedName = sanitizeText(perMessageProfile.name);
const htmlPrefix = `${escapedName}: `;
diff --git a/src/app/features/room/message/MessageEditor.tsx b/src/app/features/room/message/MessageEditor.tsx
index 22c63ce26..ad25ba45f 100644
--- a/src/app/features/room/message/MessageEditor.tsx
+++ b/src/app/features/room/message/MessageEditor.tsx
@@ -72,7 +72,7 @@ import { HTMLReactParserOptions } from 'html-react-parser';
import { useMediaAuthentication } from '$hooks/useMediaAuthentication';
import { Opts as LinkifyOpts } from 'linkifyjs';
import { GetContentCallback } from '$types/matrix/room';
-import { sanitizeCustomHtml } from '$utils/sanitize';
+import { sanitizeText } from '$utils/sanitize';
type MessageEditorProps = {
roomId: string;
@@ -204,7 +204,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
plainText = bodyPrefix + plainText;
}
- const escapedName = sanitizeCustomHtml(pmpDisplayname);
+ const escapedName = sanitizeText(pmpDisplayname);
const htmlPrefix = `${escapedName}: `;
if (!customHtml.startsWith(htmlPrefix)) {
customHtml = htmlPrefix + customHtml;
diff --git a/src/app/plugins/react-custom-html-parser.test.tsx b/src/app/plugins/react-custom-html-parser.test.tsx
new file mode 100644
index 000000000..a5015c1b2
--- /dev/null
+++ b/src/app/plugins/react-custom-html-parser.test.tsx
@@ -0,0 +1,85 @@
+import { render, screen } from '@testing-library/react';
+import parse from 'html-react-parser';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { sanitizeCustomHtml } from '$utils/sanitize';
+import { getReactCustomHtmlParser, LINKIFY_OPTS } from './react-custom-html-parser';
+
+function createMatrixClient() {
+ return {
+ getRoom: () => undefined,
+ getUserId: () => '@me:example.com',
+ mxcUrlToHttp: () => null,
+ } as any;
+}
+
+function renderParsedHtml(
+ html: string,
+ options: {
+ sanitize?: boolean;
+ mx?: any;
+ } = {}
+) {
+ const { sanitize = true, mx = createMatrixClient() } = options;
+ const parserOptions = getReactCustomHtmlParser(mx, '!room:example.com', {
+ linkifyOpts: LINKIFY_OPTS,
+ });
+
+ return render({parse(sanitize ? sanitizeCustomHtml(html) : html, parserOptions)}
);
+}
+
+describe('getReactCustomHtmlParser', () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('translates Matrix color data attributes into rendered styles', () => {
+ renderParsedHtml('colored');
+
+ expect(screen.getByText('colored')).toHaveStyle({
+ color: 'rgb(255, 0, 0)',
+ backgroundColor: 'rgb(0, 255, 0)',
+ });
+ });
+
+ it('drops incoming style attributes even if unsanitized html reaches the parser', () => {
+ renderParsedHtml(
+ 'styled',
+ { sanitize: false }
+ );
+
+ const styled = screen.getByText('styled');
+
+ expect(styled).toHaveStyle({
+ color: 'rgb(255, 0, 0)',
+ });
+ expect(styled).not.toHaveStyle({
+ backgroundColor: 'rgb(0, 255, 0)',
+ });
+ });
+
+ it('renders a readable fallback for unresolved legacy emote MXC images', () => {
+ const { container } = renderParsedHtml(
+ '
'
+ );
+
+ expect(screen.getByText(':blobcat:')).toBeInTheDocument();
+ expect(container.querySelector('img[src^="mxc://"]')).toBeNull();
+ });
+
+ it('renders a readable fallback for unresolved non-emote MXC images', () => {
+ const { container } = renderParsedHtml(
+ '
'
+ );
+
+ expect(screen.getByText('media')).toBeInTheDocument();
+ expect(container.querySelector('img[src^="mxc://"]')).toBeNull();
+ });
+
+ it('renders unresolved MXC fallbacks without emitting debug logs', () => {
+ const logSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
+
+ renderParsedHtml('
');
+
+ expect(logSpy).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/app/plugins/react-custom-html-parser.tsx b/src/app/plugins/react-custom-html-parser.tsx
index 968850480..b1cf5e636 100644
--- a/src/app/plugins/react-custom-html-parser.tsx
+++ b/src/app/plugins/react-custom-html-parser.tsx
@@ -1,5 +1,6 @@
/* eslint-disable jsx-a11y/alt-text */
import {
+ CSSProperties,
ComponentPropsWithoutRef,
lazy,
ReactEventHandler,
@@ -35,6 +36,7 @@ import { EMOJI_PATTERN, sanitizeForRegex, URL_NEG_LB } from '$utils/regex';
import { findAndReplace } from '$utils/findAndReplace';
import { onEnterOrSpace } from '$utils/keyboard';
import { copyToClipboard } from '$utils/dom';
+import { isMatrixHexColor } from '$utils/matrixHtml';
import { useTimeoutToggle } from '$hooks/useTimeoutToggle';
import { ClientSideHoverFreeze } from '$components/ClientSideHoverFreeze';
import {
@@ -68,6 +70,42 @@ export const safeDecodeUrl = (url: string) => {
}
};
+const getMatrixColorStyle = (attribs: Record): CSSProperties | undefined => {
+ const color = attribs['data-mx-color'];
+ const backgroundColor = attribs['data-mx-bg-color'];
+
+ const style: CSSProperties = {};
+
+ if (typeof color === 'string' && isMatrixHexColor(color)) {
+ style.color = color;
+ }
+
+ if (typeof backgroundColor === 'string' && isMatrixHexColor(backgroundColor)) {
+ style.backgroundColor = backgroundColor;
+ }
+
+ return Object.keys(style).length > 0 ? style : undefined;
+};
+
+const stripIncomingStyle = (
+ attribs: Record
+): Omit, 'style'> => {
+ const { style, ...props } = attributesToProps(attribs);
+
+ return props;
+};
+
+const ensureNoopenerRel = (rel: unknown): string => {
+ if (typeof rel !== 'string') return 'noopener';
+
+ const parts = rel.split(/\s+/).filter(Boolean);
+ if (!parts.includes('noopener')) {
+ parts.push('noopener');
+ }
+
+ return parts.join(' ');
+};
+
export const makeMentionCustomProps = (
handleMentionClick?: ReactEventHandler,
content?: string
@@ -363,7 +401,8 @@ export const getReactCustomHtmlParser = (
if (domNode instanceof Element && 'name' in domNode) {
const { name, attribs, children, parent } = domNode;
const renderChildren = () => domToReact(children as any, opts);
- const props = attributesToProps(attribs);
+ const props = stripIncomingStyle(attribs);
+ const matrixColorStyle = getMatrixColorStyle(attribs);
if (name === 'h1') {
return (
@@ -494,8 +533,13 @@ export const getReactCustomHtmlParser = (
if (name === 'a' && typeof props.href === 'string') {
const encodedHref = props.href;
const decodedHref = encodedHref && safeDecodeUrl(encodedHref);
+ const anchorProps = {
+ ...props,
+ rel: ensureNoopenerRel(props.rel),
+ };
+
if (!decodedHref || !testMatrixTo(decodedHref)) {
- return undefined;
+ return {renderChildren()};
}
const content = children.find((child) => !(child instanceof DOMText))
@@ -523,13 +567,21 @@ export const getReactCustomHtmlParser = (
onClick={params.handleSpoilerClick}
className={css.Spoiler()}
aria-pressed
- style={{ cursor: 'pointer' }}
+ style={{ ...matrixColorStyle, cursor: 'pointer' }}
>
{renderChildren()}
);
}
+ if (name === 'span' && matrixColorStyle) {
+ return (
+
+ {renderChildren()}
+
+ );
+ }
+
if (name === 'img') {
// Guard: img without a src survives sanitisation (fix for crash #1731)
// but we can't convert it — skip rendering rather than passing
@@ -537,6 +589,8 @@ export const getReactCustomHtmlParser = (
if (!props.src) return null;
const htmlSrc = mxcUrlToHttp(mx, props.src, params.useAuthentication) ?? undefined;
+ const fallbackLabel = props.alt || props.title || '[media]';
+ const failedToResolveMxc = props.src.startsWith('mxc://') && !htmlSrc;
// Non-mxc images were already converted to links by the sanitiser,
// but handle the edge case defensively here too.
@@ -632,6 +686,14 @@ export const getReactCustomHtmlParser = (
);
}
+ if (failedToResolveMxc) {
+ return (
+
+ {fallbackLabel}
+
+ );
+ }
+
if (htmlSrc)
return (
{
- it('passes through permitted tags', () => {
- expect(sanitizeCustomHtml('bold')).toBe('bold');
- expect(sanitizeCustomHtml('italic')).toBe('italic');
- expect(sanitizeCustomHtml('snippet')).toBe('snippet');
- });
+describe('sanitizeCustomHtml', () => {
+ it('keeps permitted Matrix v1.18 tags', () => {
+ const result = sanitizeCustomHtml(
+ 'Summary
'
+ );
- it('strips disallowed tags but keeps their text content', () => {
- const result = sanitizeCustomHtml('');
- expect(result).not.toContain('