fix: harden markdown attachment uploads

This commit is contained in:
vihar
2026-08-04 21:35:48 +05:30
parent 5adf526995
commit 8eeb8d762a
10 changed files with 122 additions and 12 deletions

View File

@@ -473,7 +473,6 @@ ATTACHMENT_MIME_TYPES = [
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"text/plain",
"text/markdown",
"text/mdx",
"application/rtf",
"application/vnd.oasis.opendocument.spreadsheet",
"application/vnd.oasis.opendocument.text",
@@ -541,6 +540,8 @@ ATTACHMENT_MIME_TYPES = [
"application/x-sql",
# Gzip
"application/x-gzip",
# Markdown
"text/markdown",
]
# MIME types that browsers can execute as scripts when served inline.

View File

@@ -0,0 +1,24 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { describe, expect, it } from "vitest";
import { DefaultIcon, TxtIcon } from "@/components/icons/attachment";
import { getFileIcon } from "./attachment-icon";
describe("getFileIcon", () => {
it.each(["md", "markdown", "mdx", "MD", "MARKDOWN", "MDX"])(
"uses the text icon for the %s extension",
(extension) => {
expect(getFileIcon(extension).type).toBe(TxtIcon);
}
);
it("uses the default icon for an unknown extension", () => {
expect(getFileIcon("unknown").type).toBe(DefaultIcon);
});
});

View File

@@ -53,7 +53,7 @@ export const IssueAttachmentsDetail = observer(function IssueAttachmentsDetail(p
// derived values
const attachment = attachmentId ? getAttachmentById(attachmentId) : undefined;
const fileName = getFileName(attachment?.attributes.name ?? "");
const fileExtension = getFileExtension(attachment?.asset_url ?? "");
const fileExtension = getFileExtension(attachment?.attributes.name ?? "");
const fileIcon = getFileIcon(fileExtension, 28);
const fileURL = getFileURL(attachment?.asset_url ?? "");
// hooks

View File

@@ -7,6 +7,7 @@
"scripts": {
"dev": "react-router dev --port 3000",
"build": "react-router build",
"test": "vitest run",
"preview": "react-router build && serve -s build/client -l 3000",
"start": "serve -s build/client -l 3000",
"clean": "rm -rf .turbo && rm -rf .next && rm -rf .react-router && rm -rf node_modules && rm -rf dist && rm -rf build",
@@ -86,6 +87,7 @@
"dotenv": "catalog:",
"typescript": "catalog:",
"vite": "catalog:",
"vite-tsconfig-paths": "catalog:"
"vite-tsconfig-paths": "catalog:",
"vitest": "catalog:"
}
}

17
apps/web/vitest.config.ts Normal file
View File

@@ -0,0 +1,17 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { defineConfig } from "vitest/config";
export default defineConfig({
resolve: {
tsconfigPaths: true,
},
test: {
environment: "node",
include: ["core/**/*.test.{ts,tsx}"],
},
});

View File

@@ -33,7 +33,6 @@ export const ACCEPTED_ATTACHMENT_MIME_TYPES = [
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"text/plain",
"text/markdown",
"text/mdx",
"application/rtf",
"audio/mpeg",
"audio/wav",

View File

@@ -14,6 +14,7 @@
"scripts": {
"build": "tsdown",
"dev": "tsdown --watch --no-clean",
"test": "vitest run",
"check:lint": "oxlint --max-warnings=6 .",
"check:types": "tsc --noEmit",
"check:format": "oxfmt --check .",
@@ -30,6 +31,7 @@
"devDependencies": {
"@plane/typescript-config": "workspace:*",
"tsdown": "catalog:",
"typescript": "catalog:"
"typescript": "catalog:",
"vitest": "catalog:"
}
}

View File

@@ -0,0 +1,60 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import { getFileMetaDataForUpload } from "./helper";
const createFile = (name: string, contents: BlobPart[] = ["# Markdown"]): File =>
new File(contents, name, { type: "" });
describe("getFileMetaDataForUpload", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it.each(["notes.md", "notes.markdown", "notes.mdx", "NOTES.MD", "NOTES.MDX"])(
"detects %s as Markdown from its extension",
async (filename) => {
const metadata = await getFileMetaDataForUpload(createFile(filename));
expect(metadata.type).toBe("text/markdown");
}
);
it.each([
"",
".notes.md",
"folder/notes.md",
"folder\\notes.mdx",
"notes.exe",
"notes.exe.md",
"notes.EXE.mdx",
"notes.exe.safe.md",
])("rejects unsafe filename %s", async (filename) => {
vi.spyOn(console, "warn").mockImplementation(() => undefined);
const metadata = await getFileMetaDataForUpload(createFile(filename));
expect(metadata.type).toBe("");
});
it("returns an empty type for an unsupported extension without a detectable signature", async () => {
const metadata = await getFileMetaDataForUpload(createFile("notes.bin"));
expect(metadata.type).toBe("");
});
it("prefers a detected signature over the filename extension", async () => {
const pngHeader = new Uint8Array([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00,
0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89,
]);
const metadata = await getFileMetaDataForUpload(createFile("image.md", [pngHeader]));
expect(metadata.type).toBe("image/png");
});
});

View File

@@ -17,7 +17,7 @@ import { DANGEROUS_EXTENSIONS } from "@plane/constants";
const EXTENSION_MIME_TYPE_MAP: Record<string, string> = {
md: "text/markdown",
markdown: "text/markdown",
mdx: "text/mdx",
mdx: "text/markdown",
};
/**
@@ -54,12 +54,11 @@ const validateFilename = (filename: string): string | null => {
const parts = filename.split(".");
// Check for double extensions with dangerous patterns
if (parts.length >= 3) {
const secondLastExt = parts[parts.length - 2]?.toLowerCase() || "";
if (DANGEROUS_EXTENSIONS.includes(secondLastExt)) {
return "File has suspicious double extension";
}
// Check for dangerous extensions anywhere before the final extension.
// This catches both double and longer disguised chains (e.g. file.exe.safe.md).
const intermediateExtensions = parts.slice(1, -1).map((part) => part.toLowerCase());
if (intermediateExtensions.some((extension) => DANGEROUS_EXTENSIONS.includes(extension))) {
return "File has suspicious extension chain";
}
// Check if the actual extension is dangerous

6
pnpm-lock.yaml generated
View File

@@ -1219,6 +1219,9 @@ importers:
vite-tsconfig-paths:
specifier: 'catalog:'
version: 5.1.4(typescript@5.8.3)(vite@8.0.16(@types/node@22.12.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.43.1)(tsx@4.20.6)(yaml@2.8.3))
vitest:
specifier: 'catalog:'
version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@22.12.0)(@vitest/coverage-v8@4.1.8)(vite@8.0.16(@types/node@22.12.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.43.1)(tsx@4.20.6)(yaml@2.8.3))
packages/codemods:
devDependencies:
@@ -1664,6 +1667,9 @@ importers:
typescript:
specifier: 5.8.3
version: 5.8.3
vitest:
specifier: 'catalog:'
version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@22.12.0)(@vitest/coverage-v8@4.1.8)(vite@8.0.16(@types/node@22.12.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.43.1)(tsx@4.20.6)(yaml@2.8.3))
packages/shared-state:
dependencies: