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: