cetirizine

An experimental matrix client written in reactjs utilizing tailwind and storybook
git clone git://archive.git.mtrnord.blog/MTRNord/cetirizine.git
Log | Files | Refs | README | LICENSE

commit 919bb2e111e97b693ac566c7ad1afb0c280ccf38
parent a686f497a0fd8fb13d5f4876a8c2da2e5058633f
Author: MTRNord <mtrnord1@gmail.com>
Date:   Sat,  6 May 2023 16:32:07 +0200

Decrypt images

Diffstat:
Mpackage-lock.json | 9+++++++++
Mpackage.json | 1+
Msrc/components/events/messageEvent.tsx | 130+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Msrc/pages/MainPage.tsx | 4++--
4 files changed, 132 insertions(+), 12 deletions(-)

diff --git a/package-lock.json b/package-lock.json @@ -23,6 +23,7 @@ "linkify-react": "^4.1.1", "linkifyjs": "^4.1.1", "lucide-react": "^0.171.0", + "matrix-encrypt-attachment": "^1.0.3", "react": "^18.2.0", "react-dom": "^18.2.0", "react-intersection-observer": "^9.4.3", @@ -10975,6 +10976,14 @@ "react": ">= 0.14.0" } }, + "node_modules/matrix-encrypt-attachment": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/matrix-encrypt-attachment/-/matrix-encrypt-attachment-1.0.3.tgz", + "integrity": "sha512-NwfoDY/yHL9Zo8KrY5GP8ymAoZJpEFKEK+IgJKdWf5xtsIFf7KIU2pbw8+Eq592j//nSDOC+/Ff4Fk8gVvDZpw==", + "engines": { + "node": ">=12.0" + } + }, "node_modules/mdast-util-definitions": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/mdast-util-definitions/-/mdast-util-definitions-4.0.0.tgz", diff --git a/package.json b/package.json @@ -19,6 +19,7 @@ "linkify-react": "^4.1.1", "linkifyjs": "^4.1.1", "lucide-react": "^0.171.0", + "matrix-encrypt-attachment": "^1.0.3", "react": "^18.2.0", "react-dom": "^18.2.0", "react-intersection-observer": "^9.4.3", diff --git a/src/components/events/messageEvent.tsx b/src/components/events/messageEvent.tsx @@ -6,6 +6,7 @@ import { MatrixContext, useRoom } from "../../app/sdk/client"; import Linkify from "linkify-react"; import DOMPurify from "dompurify"; import { UndecryptableEvent } from "./unknownEvent"; +import { decryptAttachment } from "matrix-encrypt-attachment"; type MessageEventProps = { /** @@ -35,7 +36,6 @@ const linkifyOptions = { const MessageEvent: FC<MessageEventProps> = memo(({ event, roomID, hasPreviousEvent }) => { const room = useRoom(roomID); - const client = useContext(MatrixContext); const renderCorrectMessage = (event: IRoomEvent) => { if (isRoomMessageTextEvent(event)) { @@ -116,24 +116,66 @@ const MessageEvent: FC<MessageEventProps> = memo(({ event, roomID, hasPreviousEv ) } } else if (isRoomMessageImageEvent(event)) { + const client = useContext(MatrixContext); const [url, setUrl] = useState<string | undefined>(undefined); const [unableToDecrypt, setUnableToDecrypt] = useState<boolean>(event.content.file !== undefined); - useEffect(() => { - if (event.content.url) { - setUrl(client.convertMXC(event.content.url)); - } else { - // Image is encrypted and we need to download and decrypt it + const decryptImage = (event: IRoomEvent) => { + console.log("Downloading image:", event.event_id); + fetch(client.convertMXC(event.content.file.url), { + headers: { + Authorization: `Bearer ${client.accessToken}` + } + }).then((response) => { + if (!response.ok) { + // TODO: display error? + console.log("Unable to decrypt image:", response.text()); + return; + } + console.log("Downloaded image:", event.event_id); + response.arrayBuffer().then((responseData) => { + // Decrypt the array buffer using the information taken from the event content. + decryptAttachment(responseData, event.content.file).then((dataArray) => { + // Turn the array into a Blob and give it the correct MIME-type. + + // IMPORTANT: we must not allow scriptable mime-types into Blobs otherwise + // they introduce XSS attacks if the Blob URI is viewed directly in the + // browser (e.g. by copying the URI into a new tab or window.) + // See warning at top of file. + let mimetype = event.content.info?.mimetype ? event.content.info.mimetype.split(";")[0].trim() : ""; + mimetype = getBlobSafeMimeType(mimetype); + + const blob = new Blob([dataArray], { type: mimetype }); + setUrl(URL.createObjectURL(blob)); + setUnableToDecrypt(false); + console.log("Decrypted image:", event.event_id); + }).catch((e: any) => { + console.log("Unable to decrypt image due to decryption error:", e); + setUnableToDecrypt(true); + }); + }); + }); + } + useEffect(() => { + if (isRoomMessageImageEvent(event)) { + if (event.content.url) { + setUrl(client.convertMXC(event.content.url)); + } else { + // Image is encrypted and we need to download and decrypt it + if (event.content.file) { + decryptImage(event); + } + } } - }, [event.content.url]) + }, [event]) if (unableToDecrypt) { return (<UndecryptableEvent event={event} roomID={roomID} hasPreviousEvent={hasPreviousEvent} />) } return ( - <div className={!hasPreviousEvent ? "flex flex-row gap-4 p-2 pb-1 hover:bg-gray-200 rounded-md duration-200 ease-in-out items-start" : "flex flex-row p-2 pb-1 pt-0 hover:bg-gray-200 rounded-md duration-200 ease-in-out"}> + <div className={!hasPreviousEvent ? "flex flex-row gap-4 p-2 pb-1 hover:bg-gray-200 rounded-md duration-200 ease-in-out items-start" : "flex flex-row p-2 pb-1 pt-1 hover:bg-gray-200 rounded-md duration-200 ease-in-out"}> {!hasPreviousEvent && <Avatar displayname={room?.getMemberName(event.sender) || ""} avatarUrl={room?.getMemberAvatar(event.sender)} @@ -173,4 +215,72 @@ const MessageEvent: FC<MessageEventProps> = memo(({ event, roomID, hasPreviousEv return renderCorrectMessage(event); }); -export default MessageEvent; -\ No newline at end of file +export default MessageEvent; + + +// WARNING: We have to be very careful about what mime-types we allow into blobs, +// as for performance reasons these are now rendered via URL.createObjectURL() +// rather than by converting into data: URIs. +// +// This means that the content is rendered using the origin of the script which +// called createObjectURL(), and so if the content contains any scripting then it +// will pose a XSS vulnerability when the browser renders it. This is particularly +// bad if the user right-clicks the URI and pastes it into a new window or tab, +// as the blob will then execute with access to Element's full JS environment(!) +// +// See https://github.com/matrix-org/matrix-react-sdk/pull/1820#issuecomment-385210647 +// for details. +// +// We mitigate this by only allowing mime-types into blobs which we know don't +// contain any scripting, and instantiate all others as application/octet-stream +// regardless of what mime-type the event claimed. Even if the payload itself +// is some malicious HTML, the fact we instantiate it with a media mimetype or +// application/octet-stream means the browser doesn't try to render it as such. +// +// One interesting edge case is image/svg+xml, which empirically *is* rendered +// correctly if the blob is set to the src attribute of an img tag (for thumbnails) +// *even if the mimetype is application/octet-stream*. However, empirically JS +// in the SVG isn't executed in this scenario, so we seem to be okay. +// +// Tested on Chrome 65 and Firefox 60 +// +// The list below is taken mainly from +// https://developer.mozilla.org/en-US/docs/Web/HTML/Supported_media_formats +// N.B. Matrix doesn't currently specify which mimetypes are valid in given +// events, so we pick the ones which HTML5 browsers should be able to display +// +// For the record, mime-types which must NEVER enter this list below include: +// text/html, text/xhtml, image/svg, image/svg+xml, image/pdf, and similar. + +const ALLOWED_BLOB_MIMETYPES = [ + "image/jpeg", + "image/gif", + "image/png", + "image/apng", + "image/webp", + "image/avif", + + "video/mp4", + "video/webm", + "video/ogg", + "video/quicktime", + + "audio/mp4", + "audio/webm", + "audio/aac", + "audio/mpeg", + "audio/ogg", + "audio/wave", + "audio/wav", + "audio/x-wav", + "audio/x-pn-wav", + "audio/flac", + "audio/x-flac", +]; + +export function getBlobSafeMimeType(mimetype: string): string { + if (!ALLOWED_BLOB_MIMETYPES.includes(mimetype)) { + return "application/octet-stream"; + } + return mimetype; +} +\ No newline at end of file diff --git a/src/pages/MainPage.tsx b/src/pages/MainPage.tsx @@ -86,11 +86,11 @@ const ChatView: FC<ChatViewProps> = memo(({ roomID, scrollRef }) => { event.content.format = event.content["m.new_content"].format; } } else { - return (<UndecryptableEvent event={event} hasPreviousEvent={previousEventIsFromSameSender} roomID={roomID}></UndecryptableEvent>) + return (<UndecryptableEvent key={event.event_id} event={event} hasPreviousEvent={previousEventIsFromSameSender} roomID={roomID}></UndecryptableEvent>) } } catch (e: any) { console.error(e); - return (<UndecryptableEvent event={event} hasPreviousEvent={previousEventIsFromSameSender} roomID={roomID}></UndecryptableEvent>) + return (<UndecryptableEvent key={event.event_id} event={event} hasPreviousEvent={previousEventIsFromSameSender} roomID={roomID}></UndecryptableEvent>) } }