@juspay/neurolink
Version:
Universal AI Development Platform with working MCP integration, multi-provider support, voice (TTS/STT/realtime), and professional CLI. 58+ external MCP servers discoverable, multimodal file processing, RAG pipelines. Build, test, and deploy AI applicatio
366 lines (365 loc) • 11.1 kB
JavaScript
/**
* Filename and Display Name Sanitization Utilities
* Prevents path traversal attacks and filesystem issues
*
* This module provides:
* - Filename sanitization for safe filesystem storage
* - Display name sanitization for user-facing content
* - Path traversal prevention
*
* @see https://cheatsheetseries.owasp.org/cheatsheets/Input_Validation_Cheat_Sheet.html
*/
/**
* Characters that are invalid in filenames on various operating systems.
* Windows is the most restrictive, so we use its rules as the baseline.
* Matches: < > : " / \ | ? * and control characters (ASCII 0-31)
*/
const INVALID_FILENAME_PATTERN = '[<>:"/\\\\|?*]';
/**
* Check if a character code is a control character (0-31)
*/
function isControlChar(charCode) {
return charCode >= 0 && charCode <= 31;
}
/**
* Remove control characters from a string
*/
function removeControlChars(str) {
let result = "";
for (let i = 0; i < str.length; i++) {
const charCode = str.charCodeAt(i);
if (!isControlChar(charCode) && charCode !== 127) {
result += str[i];
}
}
return result;
}
/**
* Reserved filenames on Windows that cannot be used.
*/
const WINDOWS_RESERVED_NAMES = new Set([
"CON",
"PRN",
"AUX",
"NUL",
"COM1",
"COM2",
"COM3",
"COM4",
"COM5",
"COM6",
"COM7",
"COM8",
"COM9",
"LPT1",
"LPT2",
"LPT3",
"LPT4",
"LPT5",
"LPT6",
"LPT7",
"LPT8",
"LPT9",
]);
/**
* Dangerous file extensions that should be blocked.
*/
const DANGEROUS_EXTENSIONS = new Set([
".exe",
".dll",
".bat",
".cmd",
".sh",
".ps1",
".vbs",
".vbe",
".js",
".jse",
".ws",
".wsf",
".wsc",
".wsh",
".msc",
".scr",
".pif",
".com",
".hta",
".cpl",
".msi",
".msp",
".jar",
]);
/**
* Sanitize a filename for safe filesystem storage.
* Removes characters that are invalid on various operating systems.
*
* @param filename - Raw filename to sanitize
* @param options - Sanitization options
* @returns Safe filename
* @throws Error if filename is empty after sanitization
*
* @example
* sanitizeFileName('my:file<name>.txt');
* // Returns: 'my_file_name_.txt'
*
* @example
* sanitizeFileName('../../../etc/passwd');
* // Returns: '______etc_passwd'
*
* @example
* sanitizeFileName('malware.exe', { blockDangerousExtensions: true });
* // Throws: Error - dangerous extension
*/
export function sanitizeFileName(filename, options = {}) {
const { maxLength = 255, replacement = "_", blockDangerousExtensions = true, allowHiddenFiles = false, } = options;
if (!filename || typeof filename !== "string") {
throw new Error("Filename is required and must be a string");
}
let sanitized = filename.trim();
// Block path traversal attempts
if (sanitized.includes("..")) {
sanitized = sanitized.replace(/\.\./g, replacement + replacement);
}
// Remove path separators
sanitized = sanitized.replace(/[/\\]/g, replacement);
// Replace invalid characters and remove control characters
sanitized = sanitized.replace(new RegExp(INVALID_FILENAME_PATTERN, "g"), replacement);
sanitized = removeControlChars(sanitized);
// Collapse multiple replacement characters
const escapedReplacement = replacement.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
sanitized = sanitized.replace(new RegExp(`${escapedReplacement}+`, "g"), replacement);
// Collapse multiple dots
sanitized = sanitized.replace(/\.{2,}/g, ".");
// Handle hidden files (files starting with dot)
if (!allowHiddenFiles && sanitized.startsWith(".")) {
sanitized = replacement + sanitized.substring(1);
}
// Don't end with a dot or space (Windows limitation)
sanitized = sanitized.replace(/[. ]+$/, "");
// Check for Windows reserved names
const nameWithoutExt = sanitized.split(".")[0].toUpperCase();
if (WINDOWS_RESERVED_NAMES.has(nameWithoutExt)) {
sanitized = replacement + sanitized;
}
// Check for dangerous extensions
if (blockDangerousExtensions) {
const lowerFilename = sanitized.toLowerCase();
const dangerousExtArray = Array.from(DANGEROUS_EXTENSIONS);
for (let i = 0; i < dangerousExtArray.length; i++) {
const ext = dangerousExtArray[i];
if (lowerFilename.endsWith(ext)) {
throw new Error(`Filename has dangerous extension: ${ext}`);
}
}
}
// Limit length
if (sanitized.length > maxLength) {
// Try to preserve extension
const lastDot = sanitized.lastIndexOf(".");
if (lastDot > 0 && lastDot > sanitized.length - 10) {
const ext = sanitized.substring(lastDot);
const name = sanitized.substring(0, maxLength - ext.length);
sanitized = name + ext;
}
else {
sanitized = sanitized.substring(0, maxLength);
}
}
// Ensure we have a valid filename
if (!sanitized || sanitized === replacement) {
throw new Error("Filename is empty after sanitization");
}
return sanitized;
}
/**
* Sanitize a display name for safe user-facing display.
* Removes control characters and limits length.
*
* @param name - Raw display name to sanitize
* @param options - Sanitization options
* @returns Safe display name
*
* @example
* sanitizeDisplayName(' John\x00Doe ');
* // Returns: 'John Doe'
*
* @example
* sanitizeDisplayName('User<script>alert(1)</script>');
* // Returns: 'User'
*/
export function sanitizeDisplayName(name, options = {}) {
const { maxLength = 100, allowUnicode = true } = options;
if (!name || typeof name !== "string") {
return "";
}
let sanitized = name;
// Remove control characters (ASCII 0-31 and 127)
sanitized = removeControlChars(sanitized);
// Remove HTML tags iteratively to prevent nested tag bypass
// e.g., "<scr<script>ipt>" after one pass becomes "<script>"
let previousSanitized;
do {
previousSanitized = sanitized;
sanitized = sanitized.replace(/<[^>]*>/g, "");
} while (sanitized !== previousSanitized);
// If not allowing unicode, remove non-ASCII characters
if (!allowUnicode) {
// Keep only printable ASCII (space through tilde)
sanitized = sanitized
.split("")
.filter((char) => {
const code = char.charCodeAt(0);
return code >= 32 && code <= 126;
})
.join("");
}
// Normalize whitespace
sanitized = sanitized.replace(/\s+/g, " ").trim();
// Limit length
if (sanitized.length > maxLength) {
sanitized = sanitized.substring(0, maxLength).trim();
}
return sanitized;
}
/**
* Validate a display name strictly.
* Only allows alphanumeric, spaces, and basic punctuation.
*
* @param name - Display name to validate
* @returns true if valid, false otherwise
*
* @example
* isValidDisplayName('John Doe'); // true
* isValidDisplayName('John<Doe'); // false
*/
export function isValidDisplayName(name) {
if (!name || typeof name !== "string") {
return false;
}
const trimmed = name.trim();
// Allow: letters, numbers, spaces, periods, hyphens, underscores, apostrophes
return /^[a-zA-Z0-9 ._'-]{1,100}$/.test(trimmed);
}
/**
* Validate a filename strictly.
* Only allows alphanumeric, dash, underscore, and period.
*
* @param filename - Filename to validate
* @returns true if valid, false otherwise
*
* @example
* isValidFileName('my-file.txt'); // true
* isValidFileName('../passwd'); // false
*/
export function isValidFileName(filename) {
if (!filename || typeof filename !== "string") {
return false;
}
const trimmed = filename.trim();
// Block path traversal
if (trimmed.includes("..") ||
trimmed.includes("/") ||
trimmed.includes("\\")) {
return false;
}
// Allow only safe characters
if (!/^[a-zA-Z0-9._-]{1,255}$/.test(trimmed)) {
return false;
}
// Block dangerous extensions
const lowerFilename = trimmed.toLowerCase();
const dangerousExtArray = Array.from(DANGEROUS_EXTENSIONS);
for (let i = 0; i < dangerousExtArray.length; i++) {
if (lowerFilename.endsWith(dangerousExtArray[i])) {
return false;
}
}
return true;
}
/**
* Extract and sanitize the extension from a filename.
*
* @param filename - Filename to extract extension from
* @returns Lowercase extension including the dot, or empty string
*
* @example
* getFileExtension('document.PDF'); // '.pdf'
* getFileExtension('noextension'); // ''
*/
export function getFileExtension(filename) {
if (!filename || typeof filename !== "string") {
return "";
}
const lastDot = filename.lastIndexOf(".");
if (lastDot < 1 || lastDot === filename.length - 1) {
return "";
}
const ext = filename.substring(lastDot).toLowerCase();
// Validate extension contains only alphanumeric
if (!/^\.[a-z0-9]+$/.test(ext)) {
return "";
}
return ext;
}
/**
* Check if a file extension is considered dangerous.
*
* @param extension - File extension to check (with or without leading dot)
* @returns true if extension is dangerous
*
* @example
* isDangerousExtension('.exe'); // true
* isDangerousExtension('pdf'); // false
*/
export function isDangerousExtension(extension) {
if (!extension || typeof extension !== "string") {
return false;
}
const normalized = extension.startsWith(".")
? extension.toLowerCase()
: `.${extension.toLowerCase()}`;
return DANGEROUS_EXTENSIONS.has(normalized);
}
/**
* Generate a safe filename from arbitrary input.
* Creates a valid filename even from completely invalid input.
*
* @param input - Any string input
* @param defaultName - Default name if input sanitizes to empty (default: 'file')
* @param extension - Optional extension to append
* @returns Safe filename
*
* @example
* generateSafeFileName('My Document!@#$'); // 'My_Document_'
* generateSafeFileName('', 'untitled', '.txt'); // 'untitled.txt'
*/
export function generateSafeFileName(input, defaultName = "file", extension) {
let sanitized;
try {
sanitized = sanitizeFileName(input || defaultName, {
blockDangerousExtensions: false,
});
}
catch {
sanitized = defaultName;
}
if (extension) {
const normalizedExt = extension.startsWith(".")
? extension.toLowerCase()
: `.${extension.toLowerCase()}`;
// Remove existing extension if present
const lastDot = sanitized.lastIndexOf(".");
if (lastDot > 0) {
sanitized = sanitized.substring(0, lastDot);
}
sanitized += normalizedExt;
}
return sanitized;
}
/**
* Get the list of dangerous file extensions.
* Useful for validation UI or documentation.
*/
export function getDangerousExtensions() {
return Array.from(DANGEROUS_EXTENSIONS);
}