diff --git a/apps/web/components/icons/attachment/attachment-icon.test.tsx b/apps/web/components/icons/attachment/attachment-icon.test.tsx
new file mode 100644
index 000000000000..ab83b0b186fa
--- /dev/null
+++ b/apps/web/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/components/icons/attachment/attachment-icon.tsx b/apps/web/components/icons/attachment/attachment-icon.tsx
index dcbf652cd80f..9e4f36923138 100644
--- a/apps/web/components/icons/attachment/attachment-icon.tsx
+++ b/apps/web/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":
@@ -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/apps/web/components/issues/attachment/attachment-detail.tsx b/apps/web/components/issues/attachment/attachment-detail.tsx
index 87abec23d7cc..c898170ea692 100644
--- a/apps/web/components/issues/attachment/attachment-detail.tsx
+++ b/apps/web/components/issues/attachment/attachment-detail.tsx
@@ -52,7 +52,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 80ce9c9373a6..d67fec7936c2 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",
@@ -82,6 +83,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..7bf72a3a5e22
--- /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: ["**/*.test.{ts,tsx}"],
+ },
+});
diff --git a/packages/services/src/file/helper.test.ts b/packages/services/src/file/helper.test.ts
new file mode 100644
index 000000000000..193c5b3c73b2
--- /dev/null
+++ b/packages/services/src/file/helper.test.ts
@@ -0,0 +1,72 @@
+/**
+ * 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: "" });
+
+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();
+ });
+
+ 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.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("");
+ }
+ );
+
+ it("prefers a detected signature over the filename extension", async () => {
+ 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 b8e96283986f..7f2773afecf3 100644
--- a/packages/services/src/file/helper.ts
+++ b/packages/services/src/file/helper.ts
@@ -10,6 +10,29 @@ 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/markdown",
+};
+
+/**
+ * @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() || "";
+ // 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] : "";
+};
+
/**
* @description Filename validation - checks for double extensions and dangerous patterns
* @param {string} filename
@@ -32,12 +55,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
@@ -103,6 +125,16 @@ 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.
+ // 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;
+ }
+
// fallback for unknown files
return "";
};
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 5c7cd494021b..5820ea7b4c7e 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1143,6 +1143,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.11(@opentelemetry/api@1.9.1)(@types/node@22.12.0)(@vitest/coverage-v8@4.1.11)(jsdom@23.2.0)(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/blocks:
dependencies: