Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions apps/web/components/icons/attachment/attachment-icon.test.tsx
Original file line number Diff line number Diff line change
@@ -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);
});
});
5 changes: 4 additions & 1 deletion apps/web/components/icons/attachment/attachment-icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <PdfIcon height={size} width={size} />;
case "csv":
Expand All @@ -47,6 +47,9 @@ export const getFileIcon = (fileType: string, size: number = 28) => {
case "js":
return <JavaScriptIcon height={size} width={size} />;
case "txt":
case "md":
case "markdown":
case "mdx":
Comment thread
vihar marked this conversation as resolved.
return <TxtIcon height={size} width={size} />;
case "svg":
return <SvgIcon height={size} width={size} />;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -82,6 +83,7 @@
"dotenv": "catalog:",
"typescript": "catalog:",
"vite": "catalog:",
"vite-tsconfig-paths": "catalog:"
"vite-tsconfig-paths": "catalog:",
"vitest": "catalog:"
}
}
17 changes: 17 additions & 0 deletions apps/web/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -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}"],
},
});
72 changes: 72 additions & 0 deletions packages/services/src/file/helper.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
44 changes: 38 additions & 6 deletions packages/services/src/file/helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
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
Expand All @@ -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
Expand Down Expand Up @@ -103,6 +125,16 @@ const validateAndDetectFileType = async (file: File): Promise<string> => {
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 "";
};
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading