Add mimetype checks

Add checks to validate the advertised mimetype and file extension of stickers, videos and images are coherent.
pull/28427/head
David Langley 2024-11-06 23:14:38 +00:00
parent 15984455af
commit 6134cfd9c4
6 changed files with 265 additions and 72 deletions

View File

@ -38,7 +38,7 @@ const config: Config = {
"recorderWorkletFactory": "<rootDir>/__mocks__/empty.js", "recorderWorkletFactory": "<rootDir>/__mocks__/empty.js",
"^fetch-mock$": "<rootDir>/node_modules/fetch-mock", "^fetch-mock$": "<rootDir>/node_modules/fetch-mock",
}, },
transformIgnorePatterns: ["/node_modules/(?!matrix-js-sdk).+$"], transformIgnorePatterns: ["/node_modules/(?!(mime|matrix-js-sdk)).+$"],
collectCoverageFrom: [ collectCoverageFrom: [
"<rootDir>/src/**/*.{js,ts,tsx}", "<rootDir>/src/**/*.{js,ts,tsx}",
// getSessionLock is piped into a different JS context via stringification, and the coverage functionality is // getSessionLock is piped into a different JS context via stringification, and the coverage functionality is

View File

@ -129,6 +129,7 @@
"matrix-js-sdk": "34.10.0", "matrix-js-sdk": "34.10.0",
"matrix-widget-api": "^1.9.0", "matrix-widget-api": "^1.9.0",
"memoize-one": "^6.0.0", "memoize-one": "^6.0.0",
"mime": "^4.0.4",
"oidc-client-ts": "^3.0.1", "oidc-client-ts": "^3.0.1",
"opus-recorder": "^8.0.3", "opus-recorder": "^8.0.3",
"pako": "^2.0.3", "pako": "^2.0.3",

View File

@ -6,32 +6,48 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details. Please see LICENSE files in the repository root for full details.
*/ */
import * as fs from "node:fs";
import type { Page } from "@playwright/test"; import type { Page } from "@playwright/test";
import { test, expect } from "../../element-web-test"; import { test, expect } from "../../element-web-test";
import { ElementAppPage } from "../../pages/ElementAppPage"; import { ElementAppPage } from "../../pages/ElementAppPage";
import { Credentials } from "../../plugins/homeserver";
const STICKER_PICKER_WIDGET_ID = "fake-sticker-picker"; const STICKER_PICKER_WIDGET_ID = "fake-sticker-picker";
const STICKER_PICKER_WIDGET_NAME = "Fake Stickers"; const STICKER_PICKER_WIDGET_NAME = "Fake Stickers";
const STICKER_NAME = "Test Sticker"; const STICKER_NAME = "Test Sticker";
const ROOM_NAME_1 = "Sticker Test"; const ROOM_NAME_1 = "Sticker Test";
const ROOM_NAME_2 = "Sticker Test Two"; const ROOM_NAME_2 = "Sticker Test Two";
const STICKER_MESSAGE = JSON.stringify({ const STICKER_IMAGE = fs.readFileSync("playwright/sample-files/riot.png");
action: "m.sticker",
api: "fromWidget", function getStickerMessage(contentUri: string, mimetype: string): string {
data: { return JSON.stringify({
name: "teststicker", action: "m.sticker",
description: STICKER_NAME, api: "fromWidget",
file: "test.png", data: {
content: { name: "teststicker",
body: STICKER_NAME, description: STICKER_NAME,
msgtype: "m.sticker", file: "test.png",
url: "mxc://localhost/somewhere", content: {
body: STICKER_NAME,
info: {
h: 480,
mimetype: mimetype,
size: 13818,
w: 480,
},
msgtype: "m.sticker",
url: contentUri,
},
}, },
}, requestId: "1",
requestId: "1", widgetId: STICKER_PICKER_WIDGET_ID,
widgetId: STICKER_PICKER_WIDGET_ID, });
}); }
const WIDGET_HTML = `
function getWidgetHtml(contentUri: string, mimetype: string) {
const stickerMessage = getStickerMessage(contentUri, mimetype);
return `
<html lang="en"> <html lang="en">
<head> <head>
<title>Fake Sticker Picker</title> <title>Fake Sticker Picker</title>
@ -51,13 +67,13 @@ const WIDGET_HTML = `
<button name="Send" id="sendsticker">Press for sticker</button> <button name="Send" id="sendsticker">Press for sticker</button>
<script> <script>
document.getElementById('sendsticker').onclick = () => { document.getElementById('sendsticker').onclick = () => {
window.parent.postMessage(${STICKER_MESSAGE}, '*') window.parent.postMessage(${stickerMessage}, '*')
}; };
</script> </script>
</body> </body>
</html> </html>
`; `;
}
async function openStickerPicker(app: ElementAppPage) { async function openStickerPicker(app: ElementAppPage) {
const options = await app.openMessageComposerOptions(); const options = await app.openMessageComposerOptions();
await options.getByRole("menuitem", { name: "Sticker" }).click(); await options.getByRole("menuitem", { name: "Sticker" }).click();
@ -71,7 +87,8 @@ async function sendStickerFromPicker(page: Page) {
await expect(page.locator(".mx_AppTileFullWidth#stickers")).not.toBeVisible(); await expect(page.locator(".mx_AppTileFullWidth#stickers")).not.toBeVisible();
} }
async function expectTimelineSticker(page: Page, roomId: string) { async function expectTimelineSticker(page: Page, roomId: string, contentUri: string) {
const contentId = contentUri.split("/").slice(-1)[0];
// Make sure it's in the right room // Make sure it's in the right room
await expect(page.locator(".mx_EventTile_sticker > a")).toHaveAttribute("href", new RegExp(`/${roomId}/`)); await expect(page.locator(".mx_EventTile_sticker > a")).toHaveAttribute("href", new RegExp(`/${roomId}/`));
@ -80,13 +97,43 @@ async function expectTimelineSticker(page: Page, roomId: string) {
// download URL. // download URL.
await expect(page.locator(`img[alt="${STICKER_NAME}"]`)).toHaveAttribute( await expect(page.locator(`img[alt="${STICKER_NAME}"]`)).toHaveAttribute(
"src", "src",
new RegExp("/download/localhost/somewhere"), new RegExp(`/localhost/${contentId}`),
); );
} }
async function expectFileTile(page: Page, roomId: string, contentUri: string) {
await expect(page.locator(".mx_MFileBody_info_filename")).toContainText(STICKER_NAME);
}
async function setWidgetAccountData(
app: ElementAppPage,
user: Credentials,
stickerPickerUrl: string,
provideCreatorUserId: boolean = true,
) {
await app.client.setAccountData("m.widgets", {
[STICKER_PICKER_WIDGET_ID]: {
content: {
type: "m.stickerpicker",
name: STICKER_PICKER_WIDGET_NAME,
url: stickerPickerUrl,
creatorUserId: provideCreatorUserId ? user.userId : undefined,
},
sender: user.userId,
state_key: STICKER_PICKER_WIDGET_ID,
type: "m.widget",
id: STICKER_PICKER_WIDGET_ID,
},
});
}
test.describe("Stickers", () => { test.describe("Stickers", () => {
test.use({ test.use({
displayName: "Sally", displayName: "Sally",
room: async ({ app }, use) => {
const roomId = await app.client.createRoom({ name: ROOM_NAME_1 });
await use({ roomId });
},
}); });
// We spin up a web server for the sticker picker so that we're not testing to see if // We spin up a web server for the sticker picker so that we're not testing to see if
@ -96,34 +143,19 @@ test.describe("Stickers", () => {
// //
// See sendStickerFromPicker() for more detail on iframe comms. // See sendStickerFromPicker() for more detail on iframe comms.
let stickerPickerUrl: string; let stickerPickerUrl: string;
test.beforeEach(async ({ webserver }) => {
stickerPickerUrl = webserver.start(WIDGET_HTML);
});
test("should send a sticker to multiple rooms", async ({ page, app, user }) => { test("should send a sticker to multiple rooms", async ({ webserver, page, app, user, room }) => {
const roomId1 = await app.client.createRoom({ name: ROOM_NAME_1 });
const roomId2 = await app.client.createRoom({ name: ROOM_NAME_2 }); const roomId2 = await app.client.createRoom({ name: ROOM_NAME_2 });
const { content_uri: contentUri } = await app.client.uploadContent(STICKER_IMAGE, { type: "image/png" });
await app.client.setAccountData("m.widgets", { const widgetHtml = getWidgetHtml(contentUri, "image/png");
[STICKER_PICKER_WIDGET_ID]: { stickerPickerUrl = webserver.start(widgetHtml);
content: { setWidgetAccountData(app, user, stickerPickerUrl);
type: "m.stickerpicker",
name: STICKER_PICKER_WIDGET_NAME,
url: stickerPickerUrl,
creatorUserId: user.userId,
},
sender: user.userId,
state_key: STICKER_PICKER_WIDGET_ID,
type: "m.widget",
id: STICKER_PICKER_WIDGET_ID,
},
});
await app.viewRoomByName(ROOM_NAME_1); await app.viewRoomByName(ROOM_NAME_1);
await expect(page).toHaveURL(`/#/room/${roomId1}`); await expect(page).toHaveURL(`/#/room/${room.roomId}`);
await openStickerPicker(app); await openStickerPicker(app);
await sendStickerFromPicker(page); await sendStickerFromPicker(page);
await expectTimelineSticker(page, roomId1); await expectTimelineSticker(page, room.roomId, contentUri);
// Ensure that when we switch to a different room that the sticker // Ensure that when we switch to a different room that the sticker
// goes to the right place // goes to the right place
@ -131,31 +163,40 @@ test.describe("Stickers", () => {
await expect(page).toHaveURL(`/#/room/${roomId2}`); await expect(page).toHaveURL(`/#/room/${roomId2}`);
await openStickerPicker(app); await openStickerPicker(app);
await sendStickerFromPicker(page); await sendStickerFromPicker(page);
await expectTimelineSticker(page, roomId2); await expectTimelineSticker(page, roomId2, contentUri);
}); });
test("should handle a sticker picker widget missing creatorUserId", async ({ page, app, user }) => { test("should handle a sticker picker widget missing creatorUserId", async ({
const roomId1 = await app.client.createRoom({ name: ROOM_NAME_1 }); webserver,
page,
await app.client.setAccountData("m.widgets", { app,
[STICKER_PICKER_WIDGET_ID]: { user,
content: { room,
type: "m.stickerpicker", }) => {
name: STICKER_PICKER_WIDGET_NAME, const { content_uri: contentUri } = await app.client.uploadContent(STICKER_IMAGE, { type: "image/png" });
url: stickerPickerUrl, const widgetHtml = getWidgetHtml(contentUri, "image/png");
// No creatorUserId stickerPickerUrl = webserver.start(widgetHtml);
}, setWidgetAccountData(app, user, stickerPickerUrl, false);
sender: user.userId,
state_key: STICKER_PICKER_WIDGET_ID,
type: "m.widget",
id: STICKER_PICKER_WIDGET_ID,
},
});
await app.viewRoomByName(ROOM_NAME_1); await app.viewRoomByName(ROOM_NAME_1);
await expect(page).toHaveURL(`/#/room/${roomId1}`); await expect(page).toHaveURL(`/#/room/${room.roomId}`);
await openStickerPicker(app); await openStickerPicker(app);
await sendStickerFromPicker(page); await sendStickerFromPicker(page);
await expectTimelineSticker(page, roomId1); await expectTimelineSticker(page, room.roomId, contentUri);
});
test("should render invalid mimetype as a file", async ({ webserver, page, app, user, room }) => {
const { content_uri: contentUri } = await app.client.uploadContent(STICKER_IMAGE, {
type: "application/octet-stream",
});
const widgetHtml = getWidgetHtml(contentUri, "application/octet-stream");
stickerPickerUrl = webserver.start(widgetHtml);
setWidgetAccountData(app, user, stickerPickerUrl);
await app.viewRoomByName(ROOM_NAME_1);
await expect(page).toHaveURL(`/#/room/${room.roomId}`);
await openStickerPicker(app);
await sendStickerFromPicker(page);
await expectFileTile(page, room.roomId, contentUri);
}); });
}); });

View File

@ -6,7 +6,9 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details. Please see LICENSE files in the repository root for full details.
*/ */
import mime from "mime";
import React, { createRef } from "react"; import React, { createRef } from "react";
import { logger } from "matrix-js-sdk/src/logger";
import { import {
EventType, EventType,
MsgType, MsgType,
@ -15,6 +17,7 @@ import {
M_LOCATION, M_LOCATION,
M_POLL_END, M_POLL_END,
M_POLL_START, M_POLL_START,
IContent,
} from "matrix-js-sdk/src/matrix"; } from "matrix-js-sdk/src/matrix";
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
@ -144,6 +147,98 @@ export default class MessageEvent extends React.Component<IProps> implements IMe
this.forceUpdate(); this.forceUpdate();
}; };
/**
* Validates that the filename extension and advertised mimetype
* of the supplied image/file message content are not null, match and are actuallly video/image content.
* For image/video messages with a thumbnail it also validates the mimetype is an image.
* @param content The mxEvent content of the message
* @returns
*/
private validateImageOrVideoMimetype = (content: IContent): boolean => {
// As per the spec if filename is not present the body represents the filename
const filename = content.filename ?? content.body;
if (!filename) {
logger.log("Failed to validate image/video content, filename null");
return false;
}
// Validate mimetype of the thumbnail is valid
const thumbnailResult = this.validateThumbnailMimeType(content);
if (!thumbnailResult) {
logger.log("Failed to validate file/image thumbnail");
return false;
}
const typeFromExtension = mime.getType(filename);
const majorContentTypeFromExtension = typeFromExtension?.split("/")[0];
const allowedMajorContentTypes = ["image", "video"];
// Validate mimetype of the extension is valid
const result =
!!majorContentTypeFromExtension && allowedMajorContentTypes.includes(majorContentTypeFromExtension);
if (!result) {
logger.log("Failed to validate image/video content, invalid or missing extension");
}
// Validate content mimetype is valid if it is set
const contentMimetype = content.info?.mimetype;
if (contentMimetype) {
const majorContentTypeFromContent = contentMimetype?.split("/")[0];
const result =
!!majorContentTypeFromContent &&
allowedMajorContentTypes.includes(majorContentTypeFromContent) &&
majorContentTypeFromExtension == majorContentTypeFromContent;
if (!result) {
logger.log("Failed to validate image/video content, invalid or missing mimetype");
return false;
}
}
return true;
};
/**
* Validates that the advertised mimetype of the supplied sticker content
* is not null and is an image.
* For stickers with a thumbnail it also validates the mimetype is an image.
* @param content The mxEvent content of the message
* @returns
*/
private validateStickerMimetype = (content: IContent): boolean => {
// Validate mimetype of the thumbnail is valid
const thumbnailResult = this.validateThumbnailMimeType(content);
if (!thumbnailResult) {
logger.log("Failed to validate sticker thumbnail");
return false;
}
const contentMimetype = content.info?.mimetype;
if (contentMimetype) {
// Validate mimetype of the content is valid
const majorContentTypeFromContent = contentMimetype?.split("/")[0];
const result = majorContentTypeFromContent === "image";
if (!result) {
logger.log("Failed to validate image/video content, invalid or missing mimetype/extensions");
return false;
}
}
return true;
};
/**
* Validates the thumbnail assocaited with an image/video message or sticker
* is has an image mimetype.
* @param content The mxEvent content of the message
* @returns
*/
private validateThumbnailMimeType = (content: IContent): boolean => {
const thumbnailInfo = content.info?.thumbnail_info;
if (thumbnailInfo) {
const majorContentTypeFromThumbnail = thumbnailInfo.mimetype?.split("/")[0];
if (!majorContentTypeFromThumbnail || majorContentTypeFromThumbnail !== "image") {
logger.log("Failed to validate image/video content, thumbnail mimetype is not an image");
return false;
}
}
return true;
};
public render(): React.ReactNode { public render(): React.ReactNode {
const content = this.props.mxEvent.getContent(); const content = this.props.mxEvent.getContent();
const type = this.props.mxEvent.getType(); const type = this.props.mxEvent.getType();
@ -154,9 +249,20 @@ export default class MessageEvent extends React.Component<IProps> implements IMe
if (this.props.mxEvent.isDecryptionFailure()) { if (this.props.mxEvent.isDecryptionFailure()) {
BodyType = DecryptionFailureBody; BodyType = DecryptionFailureBody;
} else if (type && this.evTypes.has(type)) { } else if (type && this.evTypes.has(type)) {
BodyType = this.evTypes.get(type)!; if (type == EventType.Sticker && !this.validateStickerMimetype(content)) {
BodyType = this.bodyTypes.get(MsgType.File)!;
} else {
BodyType = this.evTypes.get(type)!;
}
} else if (msgtype && this.bodyTypes.has(msgtype)) { } else if (msgtype && this.bodyTypes.has(msgtype)) {
BodyType = this.bodyTypes.get(msgtype)!; if (
(msgtype == MsgType.Image || msgtype == MsgType.Video) &&
!this.validateImageOrVideoMimetype(content)
) {
BodyType = this.bodyTypes.get(MsgType.File)!;
} else {
BodyType = this.bodyTypes.get(msgtype)!;
}
} else if (content.url) { } else if (content.url) {
// Fallback to MFileBody if there's a content URL // Fallback to MFileBody if there's a content URL
BodyType = this.bodyTypes.get(MsgType.File)!; BodyType = this.bodyTypes.get(MsgType.File)!;

View File

@ -33,6 +33,16 @@ jest.mock("../../../../../src/components/views/messages/MImageBody", () => ({
default: () => <div data-testid="image-body" />, default: () => <div data-testid="image-body" />,
})); }));
jest.mock("../../../../../src/components/views/messages/MVideoBody", () => ({
__esModule: true,
default: () => <div data-testid="video-body" />,
}));
jest.mock("../../../../../src/components/views/messages/MFileBody", () => ({
__esModule: true,
default: () => <div data-testid="file-body" />,
}));
jest.mock("../../../../../src/components/views/messages/MImageReplyBody", () => ({ jest.mock("../../../../../src/components/views/messages/MImageReplyBody", () => ({
__esModule: true, __esModule: true,
default: () => <div data-testid="image-reply-body" />, default: () => <div data-testid="image-reply-body" />,
@ -95,8 +105,8 @@ describe("MessageEvent", () => {
describe("when an image with a caption is sent", () => { describe("when an image with a caption is sent", () => {
let result: RenderResult; let result: RenderResult;
beforeEach(() => { function createEvent(mimetype: string, filename: string, msgtype: string) {
event = mkEvent({ return mkEvent({
event: true, event: true,
type: EventType.RoomMessage, type: EventType.RoomMessage,
user: client.getUserId()!, user: client.getUserId()!,
@ -105,19 +115,19 @@ describe("MessageEvent", () => {
body: "caption for a test image", body: "caption for a test image",
format: "org.matrix.custom.html", format: "org.matrix.custom.html",
formatted_body: "<strong>caption for a test image</strong>", formatted_body: "<strong>caption for a test image</strong>",
msgtype: MsgType.Image, msgtype: msgtype,
filename: "image.webp", filename: filename,
info: { info: {
w: 40, w: 40,
h: 50, h: 50,
mimetype: mimetype,
}, },
url: "mxc://server/image", url: "mxc://server/image",
}, },
}); });
result = renderMessageEvent(); }
});
it("should render a TextualBody and an ImageBody", () => { function mockMedia() {
fetchMock.getOnce( fetchMock.getOnce(
"https://server/_matrix/media/v3/download/server/image", "https://server/_matrix/media/v3/download/server/image",
{ {
@ -125,8 +135,38 @@ describe("MessageEvent", () => {
}, },
{ sendAsJson: false }, { sendAsJson: false },
); );
}
it("should render a TextualBody and an ImageBody", () => {
event = createEvent("image/webp", "image.webp", MsgType.Image);
result = renderMessageEvent();
mockMedia();
result.getByTestId("image-body"); result.getByTestId("image-body");
result.getByTestId("textual-body"); result.getByTestId("textual-body");
}); });
it("should render a TextualBody and a FileBody for mismatched extension", () => {
event = createEvent("image/webp", "image.exe", MsgType.Image);
result = renderMessageEvent();
mockMedia();
result.getByTestId("file-body");
result.getByTestId("textual-body");
});
it("should render a TextualBody and an VideoBody", () => {
event = createEvent("video/mp4", "video.mp4", MsgType.Video);
result = renderMessageEvent();
mockMedia();
result.getByTestId("video-body");
result.getByTestId("textual-body");
});
it("should render a TextualBody and a FileBody for non-video mimetype", () => {
event = createEvent("application/octet-stream", "video.mp4", MsgType.Video);
result = renderMessageEvent();
mockMedia();
result.getByTestId("file-body");
result.getByTestId("textual-body");
});
}); });
}); });

View File

@ -8411,6 +8411,11 @@ mime@1.6.0:
resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1"
integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==
mime@^4.0.4:
version "4.0.4"
resolved "https://registry.yarnpkg.com/mime/-/mime-4.0.4.tgz#9f851b0fc3c289d063b20a7a8055b3014b25664b"
integrity sha512-v8yqInVjhXyqP6+Kw4fV3ZzeMRqEW6FotRsKXjRS5VMTNIuXsdRoAvklpoRgSqXm6o9VNH4/C0mgedko9DdLsQ==
mimic-fn@^2.1.0: mimic-fn@^2.1.0:
version "2.1.0" version "2.1.0"
resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b"