fix: substitution of content when sending an image to a profile (#3869)

* fix: Limit public uploads to basic image types

* test
This commit is contained in:
Tom Moor
2022-07-26 20:10:00 +01:00
committed by GitHub
parent 086c3ec2d8
commit 8fdd5bf734
9 changed files with 82 additions and 34 deletions

View File

@@ -8,12 +8,10 @@ import { Optional } from "utility-types";
import insertFiles from "@shared/editor/commands/insertFiles";
import embeds from "@shared/editor/embeds";
import { Heading } from "@shared/editor/lib/getHeadings";
import {
getDataTransferFiles,
supportedImageMimeTypes,
} from "@shared/utils/files";
import { getDataTransferFiles } from "@shared/utils/files";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import { isInternalUrl } from "@shared/utils/urls";
import { AttachmentValidation } from "@shared/validations";
import Document from "~/models/Document";
import ClickablePadding from "~/components/ClickablePadding";
import ErrorBoundary from "~/components/ErrorBoundary";
@@ -212,7 +210,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
// Insert all files as attachments if any of the files are not images.
const isAttachment = files.some(
(file) => !supportedImageMimeTypes.includes(file.type)
(file) => !AttachmentValidation.imageContentTypes.includes(file.type)
);
insertFiles(view, event, pos, files, {

View File

@@ -11,7 +11,8 @@ import { CommandFactory } from "@shared/editor/lib/Extension";
import filterExcessSeparators from "@shared/editor/lib/filterExcessSeparators";
import { EmbedDescriptor, MenuItem } from "@shared/editor/types";
import { depths } from "@shared/styles";
import { supportedImageMimeTypes, getEventFiles } from "@shared/utils/files";
import { getEventFiles } from "@shared/utils/files";
import { AttachmentValidation } from "@shared/validations";
import Scrollable from "~/components/Scrollable";
import { Dictionary } from "~/hooks/useDictionary";
import Input from "./Input";
@@ -181,7 +182,9 @@ class CommandMenu<T = MenuItem> extends React.Component<Props<T>, State> {
insertItem = (item: any) => {
switch (item.name) {
case "image":
return this.triggerFilePick(supportedImageMimeTypes.join(", "));
return this.triggerFilePick(
AttachmentValidation.imageContentTypes.join(", ")
);
case "attachment":
return this.triggerFilePick("*");
case "embed":

View File

@@ -5,6 +5,7 @@ import * as React from "react";
import AvatarEditor from "react-avatar-editor";
import Dropzone from "react-dropzone";
import styled from "styled-components";
import { AttachmentValidation } from "@shared/validations";
import RootStore from "~/stores/RootStore";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
@@ -134,7 +135,7 @@ class ImageUpload extends React.Component<RootStore & Props> {
return (
<Dropzone
accept="image/png, image/jpeg"
accept={AttachmentValidation.avatarContentTypes.join(", ")}
onDropAccepted={this.onDropAccepted}
>
{({ getRootProps, getInputProps }) => (

View File

@@ -13,9 +13,46 @@ import { flushdb } from "@server/test/support";
const app = webService();
const server = new TestServer(app.callback());
jest.mock("@server/utils/s3");
beforeEach(() => flushdb());
afterAll(() => server.close());
describe("#attachments.create", () => {
it("should require authentication", async () => {
const res = await server.post("/api/attachments.create");
expect(res.status).toEqual(401);
});
it("should allow simple image upload for public attachments", async () => {
const user = await buildUser();
const res = await server.post("/api/attachments.create", {
body: {
name: "test.png",
contentType: "image/png",
size: 1000,
public: true,
token: user.getJwtToken(),
},
});
expect(res.status).toEqual(200);
});
it("should not allow file upload for public attachments", async () => {
const user = await buildUser();
const res = await server.post("/api/attachments.create", {
body: {
name: "test.pdf",
contentType: "application/pdf",
size: 1000,
public: true,
token: user.getJwtToken(),
},
});
expect(res.status).toEqual(400);
});
});
describe("#attachments.delete", () => {
it("should require authentication", async () => {
const res = await server.post("/api/attachments.delete");

View File

@@ -1,6 +1,7 @@
import Router from "koa-router";
import { v4 as uuidv4 } from "uuid";
import { bytesToHumanReadable } from "@shared/utils/files";
import { AttachmentValidation } from "@shared/validations";
import { sequelize } from "@server/database/sequelize";
import { AuthorizationError, ValidationError } from "@server/errors";
import auth from "@server/middlewares/authentication";
@@ -11,12 +12,13 @@ import {
publicS3Endpoint,
getSignedUrl,
} from "@server/utils/s3";
import { assertPresent, assertUuid } from "@server/validation";
import { assertIn, assertPresent, assertUuid } from "@server/validation";
const router = new Router();
const AWS_S3_ACL = process.env.AWS_S3_ACL || "private";
router.post("attachments.create", auth(), async (ctx) => {
const isPublic = ctx.body.public;
const {
name,
documentId,
@@ -25,9 +27,15 @@ router.post("attachments.create", auth(), async (ctx) => {
} = ctx.body;
assertPresent(name, "name is required");
assertPresent(size, "size is required");
const { user } = ctx.state;
authorize(user, "createAttachment", user.team);
// Public attachments are only used for avatars, so this is loosely coupled.
if (isPublic) {
assertIn(contentType, AttachmentValidation.avatarContentTypes);
}
if (
process.env.AWS_S3_UPLOAD_MAX_SIZE &&
size > process.env.AWS_S3_UPLOAD_MAX_SIZE
@@ -39,7 +47,6 @@ router.post("attachments.create", auth(), async (ctx) => {
);
}
const isPublic = ctx.body.public;
const s3Key = uuidv4();
const acl =
isPublic === undefined ? AWS_S3_ACL : isPublic ? "public-read" : "private";

View File

@@ -5,3 +5,5 @@ export const publicS3Endpoint = jest.fn().mockReturnValue("http://mock");
export const getSignedUrl = jest.fn().mockReturnValue("http://s3mock");
export const getSignedUrlPromise = jest.fn().mockResolvedValue("http://s3mock");
export const getPresignedPost = jest.fn().mockReturnValue({});

View File

@@ -11,11 +11,8 @@ import {
import * as React from "react";
import ImageZoom from "react-medium-image-zoom";
import styled from "styled-components";
import {
getDataTransferFiles,
supportedImageMimeTypes,
getEventFiles,
} from "../../utils/files";
import { getDataTransferFiles, getEventFiles } from "../../utils/files";
import { AttachmentValidation } from "../../validations";
import insertFiles, { Options } from "../commands/insertFiles";
import { MarkdownSerializerState } from "../lib/markdown/serializer";
import uploadPlaceholderPlugin from "../lib/uploadPlaceholder";
@@ -413,7 +410,7 @@ export default class Image extends Node {
// create an input element and click to trigger picker
const inputElement = document.createElement("input");
inputElement.type = "file";
inputElement.accept = supportedImageMimeTypes.join(", ");
inputElement.accept = AttachmentValidation.imageContentTypes.join(", ");
inputElement.onchange = (event) => {
const files = getEventFiles(event);
insertFiles(view, event, state.selection.from, files, {

View File

@@ -83,21 +83,3 @@ export function getEventFiles(
? Array.prototype.slice.call(event.target.files)
: [];
}
/**
* An array of image mimetypes commonly supported by modern browsers
*/
export const supportedImageMimeTypes = [
"image/jpg",
"image/jpeg",
"image/pjpeg",
"image/png",
"image/apng",
"image/avif",
"image/gif",
"image/webp",
"image/svg",
"image/svg+xml",
"image/bmp",
"image/tiff",
];

View File

@@ -1,3 +1,24 @@
export const AttachmentValidation = {
/** The limited allowable mime-types for user and team avatars */
avatarContentTypes: ["image/jpg", "image/jpeg", "image/png"],
/** Image mime-types commonly supported by modern browsers */
imageContentTypes: [
"image/jpg",
"image/jpeg",
"image/pjpeg",
"image/png",
"image/apng",
"image/avif",
"image/gif",
"image/webp",
"image/svg",
"image/svg+xml",
"image/bmp",
"image/tiff",
],
};
export const CollectionValidation = {
/** The maximum length of the collection description */
maxDescriptionLength: 1000,