From ddb11a8012e9bb063a4520130e9359456890bafe Mon Sep 17 00:00:00 2001 From: sriram veeraghanta Date: Sun, 3 May 2026 00:01:24 +0530 Subject: [PATCH 1/4] feat: support .md and .mdx file uploads Plain-text files have no magic bytes, so the file-type signature detector returned an empty MIME type and the backend rejected them. Add an extension-based fallback for markdown/mdx, allow text/mdx in the API and editor allow-lists, and pick a file icon for these extensions. --- apps/api/plane/settings/common.py | 3 +- .../icons/attachment/attachment-icon.tsx | 3 ++ packages/editor/src/core/constants/config.ts | 1 + packages/services/src/file/helper.ts | 28 +++++++++++++++++++ 4 files changed, 33 insertions(+), 2 deletions(-) diff --git a/apps/api/plane/settings/common.py b/apps/api/plane/settings/common.py index 25a212e76395..68ea06dbdc7d 100644 --- a/apps/api/plane/settings/common.py +++ b/apps/api/plane/settings/common.py @@ -473,6 +473,7 @@ def _retention_days(env_var, default): "application/vnd.openxmlformats-officedocument.presentationml.presentation", "text/plain", "text/markdown", + "text/mdx", "application/rtf", "application/vnd.oasis.opendocument.spreadsheet", "application/vnd.oasis.opendocument.text", @@ -540,8 +541,6 @@ def _retention_days(env_var, default): "application/x-sql", # Gzip "application/x-gzip", - # Markdown - "text/markdown", ] # MIME types that browsers can execute as scripts when served inline. diff --git a/apps/web/core/components/icons/attachment/attachment-icon.tsx b/apps/web/core/components/icons/attachment/attachment-icon.tsx index dcbf652cd80f..1ac6c2a033bc 100644 --- a/apps/web/core/components/icons/attachment/attachment-icon.tsx +++ b/apps/web/core/components/icons/attachment/attachment-icon.tsx @@ -47,6 +47,9 @@ export const getFileIcon = (fileType: string, size: number = 28) => { case "js": return ; case "txt": + case "md": + case "markdown": + case "mdx": return ; case "svg": return ; diff --git a/packages/editor/src/core/constants/config.ts b/packages/editor/src/core/constants/config.ts index 64508e63e0a5..9e126fa2a7e8 100644 --- a/packages/editor/src/core/constants/config.ts +++ b/packages/editor/src/core/constants/config.ts @@ -33,6 +33,7 @@ export const ACCEPTED_ATTACHMENT_MIME_TYPES = [ "application/vnd.openxmlformats-officedocument.presentationml.presentation", "text/plain", "text/markdown", + "text/mdx", "application/rtf", "audio/mpeg", "audio/wav", diff --git a/packages/services/src/file/helper.ts b/packages/services/src/file/helper.ts index b8e96283986f..8f3f5b8ee975 100644 --- a/packages/services/src/file/helper.ts +++ b/packages/services/src/file/helper.ts @@ -10,6 +10,28 @@ import { fileTypeFromBuffer } from "file-type"; import type { TFileMetaDataLite, TFileSignedURLResponse } from "@plane/types"; import { DANGEROUS_EXTENSIONS } from "@plane/constants"; +/** + * @description Map of file extensions to MIME types for plain-text formats that + * file-type signature detection cannot identify (no magic bytes). + */ +const EXTENSION_MIME_TYPE_MAP: Record = { + md: "text/markdown", + markdown: "text/markdown", + mdx: "text/mdx", +}; + +/** + * @description Resolve a MIME type from the file extension for known text formats. + * @param {string} filename + * @returns {string} MIME type if extension is known, empty string otherwise + */ +const detectMimeTypeFromExtension = (filename: string): string => { + const parts = filename.split("."); + if (parts.length < 2) return ""; + const extension = parts[parts.length - 1]?.toLowerCase() || ""; + return EXTENSION_MIME_TYPE_MAP[extension] || ""; +}; + /** * @description Filename validation - checks for double extensions and dangerous patterns * @param {string} filename @@ -103,6 +125,12 @@ const validateAndDetectFileType = async (file: File): Promise => { console.warn("Error detecting file type from signature:", _error); } + // Plain-text formats (markdown, mdx, …) have no magic bytes — fall back to extension. + const extensionType = detectMimeTypeFromExtension(file.name); + if (extensionType) { + return extensionType; + } + // fallback for unknown files return ""; }; From 5adf526995532b6d136ceec137c4e5588a6ca7d4 Mon Sep 17 00:00:00 2001 From: sriram veeraghanta Date: Fri, 15 May 2026 01:54:23 +0530 Subject: [PATCH 2/4] fix: reject suspicious filenames and match file icons case-insensitively - Short-circuit validateAndDetectFileType when validateFilename flags a suspicious name so files like foo.exe.md cannot bypass the attachment allowlist via the new extension MIME fallback. - Lowercase fileType in getFileIcon so uppercase extensions (e.g. .MD, .PDF) resolve to the correct icon. --- apps/web/core/components/icons/attachment/attachment-icon.tsx | 2 +- packages/services/src/file/helper.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/core/components/icons/attachment/attachment-icon.tsx b/apps/web/core/components/icons/attachment/attachment-icon.tsx index 1ac6c2a033bc..9e4f36923138 100644 --- a/apps/web/core/components/icons/attachment/attachment-icon.tsx +++ b/apps/web/core/components/icons/attachment/attachment-icon.tsx @@ -25,7 +25,7 @@ import { } from "@/components/icons/attachment"; export const getFileIcon = (fileType: string, size: number = 28) => { - switch (fileType) { + switch (fileType.toLowerCase()) { case "pdf": return ; case "csv": diff --git a/packages/services/src/file/helper.ts b/packages/services/src/file/helper.ts index 8f3f5b8ee975..19635f06f47b 100644 --- a/packages/services/src/file/helper.ts +++ b/packages/services/src/file/helper.ts @@ -114,6 +114,7 @@ const validateAndDetectFileType = async (file: File): Promise => { const filenameError = validateFilename(file.name); if (filenameError) { console.warn(`File validation warning: ${filenameError}`); + return ""; } try { From 8eeb8d762ae135f54786c1ec1937e487d352f255 Mon Sep 17 00:00:00 2001 From: vihar Date: Tue, 4 Aug 2026 21:35:48 +0530 Subject: [PATCH 3/4] fix: harden markdown attachment uploads --- apps/api/plane/settings/common.py | 3 +- .../icons/attachment/attachment-icon.test.tsx | 24 ++++++++ .../issues/attachment/attachment-detail.tsx | 2 +- apps/web/package.json | 4 +- apps/web/vitest.config.ts | 17 ++++++ packages/editor/src/core/constants/config.ts | 1 - packages/services/package.json | 4 +- packages/services/src/file/helper.test.ts | 60 +++++++++++++++++++ packages/services/src/file/helper.ts | 13 ++-- pnpm-lock.yaml | 6 ++ 10 files changed, 122 insertions(+), 12 deletions(-) create mode 100644 apps/web/core/components/icons/attachment/attachment-icon.test.tsx create mode 100644 apps/web/vitest.config.ts create mode 100644 packages/services/src/file/helper.test.ts diff --git a/apps/api/plane/settings/common.py b/apps/api/plane/settings/common.py index 68ea06dbdc7d..25a212e76395 100644 --- a/apps/api/plane/settings/common.py +++ b/apps/api/plane/settings/common.py @@ -473,7 +473,6 @@ def _retention_days(env_var, default): "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 @@ def _retention_days(env_var, default): "application/x-sql", # Gzip "application/x-gzip", + # Markdown + "text/markdown", ] # MIME types that browsers can execute as scripts when served inline. diff --git a/apps/web/core/components/icons/attachment/attachment-icon.test.tsx b/apps/web/core/components/icons/attachment/attachment-icon.test.tsx new file mode 100644 index 000000000000..ab83b0b186fa --- /dev/null +++ b/apps/web/core/components/icons/attachment/attachment-icon.test.tsx @@ -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); + }); +}); diff --git a/apps/web/core/components/issues/attachment/attachment-detail.tsx b/apps/web/core/components/issues/attachment/attachment-detail.tsx index fb486b04b185..aa48959197a0 100644 --- a/apps/web/core/components/issues/attachment/attachment-detail.tsx +++ b/apps/web/core/components/issues/attachment/attachment-detail.tsx @@ -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 diff --git a/apps/web/package.json b/apps/web/package.json index 9fbf4caf9a1a..9e7a89f9d509 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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:" } } diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts new file mode 100644 index 000000000000..e3137f0672e6 --- /dev/null +++ b/apps/web/vitest.config.ts @@ -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}"], + }, +}); diff --git a/packages/editor/src/core/constants/config.ts b/packages/editor/src/core/constants/config.ts index 9e126fa2a7e8..64508e63e0a5 100644 --- a/packages/editor/src/core/constants/config.ts +++ b/packages/editor/src/core/constants/config.ts @@ -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", diff --git a/packages/services/package.json b/packages/services/package.json index 7f988cc0481b..b02e65fb41ca 100644 --- a/packages/services/package.json +++ b/packages/services/package.json @@ -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:" } } diff --git a/packages/services/src/file/helper.test.ts b/packages/services/src/file/helper.test.ts new file mode 100644 index 000000000000..85265f057f01 --- /dev/null +++ b/packages/services/src/file/helper.test.ts @@ -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"); + }); +}); diff --git a/packages/services/src/file/helper.ts b/packages/services/src/file/helper.ts index 19635f06f47b..8d3b590480ef 100644 --- a/packages/services/src/file/helper.ts +++ b/packages/services/src/file/helper.ts @@ -17,7 +17,7 @@ import { DANGEROUS_EXTENSIONS } from "@plane/constants"; const EXTENSION_MIME_TYPE_MAP: Record = { 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 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1320f0973725..a44b12236d1d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: From 09cc3f1da2414f6cfd96ebdd2d1636ecee428032 Mon Sep 17 00:00:00 2001 From: Sriram Veeraghanta Date: Thu, 8 Oct 2026 16:10:32 +0530 Subject: [PATCH 4/4] fix: limit filename rejection to the extension fallback Suspicious filenames now only block the extension-based MIME fallback, so files whose signature identifies them (e.g. deploy.sh.png) upload as before. The extension lookup also ignores inherited object properties such as constructor and __proto__. --- packages/services/src/file/helper.test.ts | 28 ++++++++++++++++------- packages/services/src/file/helper.ts | 8 +++++-- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/packages/services/src/file/helper.test.ts b/packages/services/src/file/helper.test.ts index 85265f057f01..193c5b3c73b2 100644 --- a/packages/services/src/file/helper.test.ts +++ b/packages/services/src/file/helper.test.ts @@ -11,6 +11,11 @@ import { getFileMetaDataForUpload } from "./helper"; const createFile = (name: string, contents: BlobPart[] = ["# Markdown"]): File => new File(contents, name, { type: "" }); +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, +]); + describe("getFileMetaDataForUpload", () => { afterEach(() => { vi.restoreAllMocks(); @@ -42,19 +47,26 @@ describe("getFileMetaDataForUpload", () => { 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")); + it.each(["notes.bin", "notes.constructor", "notes.__proto__"])( + "returns an empty type for the unsupported extension %s without a detectable signature", + async (filename) => { + const metadata = await getFileMetaDataForUpload(createFile(filename)); - expect(metadata.type).toBe(""); - }); + 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"); }); + + it("keeps the detected signature type when the filename only triggers a warning", async () => { + vi.spyOn(console, "warn").mockImplementation(() => undefined); + + const metadata = await getFileMetaDataForUpload(createFile("deploy.sh.png", [pngHeader])); + + expect(metadata.type).toBe("image/png"); + }); }); diff --git a/packages/services/src/file/helper.ts b/packages/services/src/file/helper.ts index 8d3b590480ef..7f2773afecf3 100644 --- a/packages/services/src/file/helper.ts +++ b/packages/services/src/file/helper.ts @@ -29,7 +29,8 @@ const detectMimeTypeFromExtension = (filename: string): string => { const parts = filename.split("."); if (parts.length < 2) return ""; const extension = parts[parts.length - 1]?.toLowerCase() || ""; - return EXTENSION_MIME_TYPE_MAP[extension] || ""; + // own-property check so names like `file.constructor` don't resolve to Object.prototype members + return Object.hasOwn(EXTENSION_MIME_TYPE_MAP, extension) ? EXTENSION_MIME_TYPE_MAP[extension] : ""; }; /** @@ -113,7 +114,6 @@ const validateAndDetectFileType = async (file: File): Promise => { const filenameError = validateFilename(file.name); if (filenameError) { console.warn(`File validation warning: ${filenameError}`); - return ""; } try { @@ -126,6 +126,10 @@ const validateAndDetectFileType = async (file: File): Promise => { } // Plain-text formats (markdown, mdx, …) have no magic bytes — fall back to extension. + // The filename is the only evidence here, so a suspicious one is rejected instead of trusted. + if (filenameError) { + return ""; + } const extensionType = detectMimeTypeFromExtension(file.name); if (extensionType) { return extensionType;