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
19 changes: 19 additions & 0 deletions packages/constants/src/file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,22 @@ export const ACCEPTED_COVER_IMAGE_MIME_TYPES_FOR_REACT_DROPZONE = {
"image/png": [],
"image/webp": [],
};

/**
* Dangerous file extensions that should be blocked
*/
export const DANGEROUS_EXTENSIONS = [
"exe",
"bat",
"cmd",
"sh",
"php",
"asp",
"aspx",
"jsp",
"cgi",
"dll",
"vbs",
"jar",
"ps1",
];
61 changes: 51 additions & 10 deletions packages/services/src/file/helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,46 @@
import { fileTypeFromBuffer } from "file-type";
// plane imports
import type { TFileMetaDataLite, TFileSignedURLResponse } from "@plane/types";
import { DANGEROUS_EXTENSIONS } from "@plane/constants";

/**
* @description Filename validation - checks for double extensions and dangerous patterns
* @param {string} filename
* @returns {string | null} Error message if invalid, null if valid
*/
const validateFilename = (filename: string): string | null => {
if (!filename || filename.trim().length === 0) {
return "Filename cannot be empty";
}

// Check for dot files (e.g., .htaccess, .env)
if (filename.startsWith(".")) {
return "Hidden files (starting with dot) are not allowed";
}

// Check for path separators
if (filename.includes("/") || filename.includes("\\")) {
return "Filename cannot contain path separators";
}

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 if the actual extension is dangerous
const extension = parts[parts.length - 1]?.toLowerCase() || "";
if (DANGEROUS_EXTENSIONS.includes(extension)) {
return `File extension '${extension}' is not allowed`;
}

return null;
};

/**
* @description from the provided signed URL response, generate a payload to be used to upload the file
Expand Down Expand Up @@ -36,28 +76,29 @@ const detectMimeTypeFromSignature = async (file: File): Promise<string> => {
};

/**
* @description Determine the MIME type of a file using multiple detection methods
* @description Validate and detect the MIME type of a file using signature detection
* Also performs basic security checks on filename
* @param {File} file
* @returns {Promise<string>} detected MIME type
* @returns {Promise<string>} validated and detected MIME type
*/
const detectFileType = async (file: File): Promise<string> => {
// check if the file has a MIME type
if (file.type && file.type.trim() !== "") {
return file.type;
const validateAndDetectFileType = async (file: File): Promise<string> => {
// Basic filename validation
const filenameError = validateFilename(file.name);
if (filenameError) {
console.warn(`File validation warning: ${filenameError}`);
}

// detect from file signature using file-type library
try {
const signatureType = await detectMimeTypeFromSignature(file);
if (signatureType) {
return signatureType;
}
} catch (_error) {
console.error("Error detecting file type from signature:", _error);
console.warn("Error detecting file type from signature:", _error);
}

// fallback for unknown files
return "application/octet-stream";
return "";
};

/**
Expand All @@ -66,7 +107,7 @@ const detectFileType = async (file: File): Promise<string> => {
* @returns {Promise<TFileMetaDataLite>} payload with file info
*/
export const getFileMetaDataForUpload = async (file: File): Promise<TFileMetaDataLite> => {
const fileType = await detectFileType(file);
const fileType = await validateAndDetectFileType(file);
return {
name: file.name,
size: file.size,
Expand Down
Loading