From 5731858db13b22d8ff03c7e5bb65e04151ecebf0 Mon Sep 17 00:00:00 2001 From: Henry Date: Tue, 23 Sep 2025 15:51:46 +0100 Subject: [PATCH 1/2] add tools warning --- .../nodes/tools/ReadFile/ReadFile.ts | 2 ++ .../nodes/tools/WriteFile/WriteFile.ts | 2 ++ packages/components/src/Interface.ts | 1 + .../src/views/agentflowsv2/AgentFlowNode.jsx | 2 ++ .../ui/src/views/agentflowsv2/ConfigInput.jsx | 21 +++++++++++++++---- packages/ui/src/views/canvas/CanvasNode.jsx | 2 ++ 6 files changed, 26 insertions(+), 4 deletions(-) diff --git a/packages/components/nodes/tools/ReadFile/ReadFile.ts b/packages/components/nodes/tools/ReadFile/ReadFile.ts index 6fa4f72ac79..3f76bf6498e 100644 --- a/packages/components/nodes/tools/ReadFile/ReadFile.ts +++ b/packages/components/nodes/tools/ReadFile/ReadFile.ts @@ -20,6 +20,7 @@ class ReadFile_Tools implements INode { category: string baseClasses: string[] inputs: INodeParams[] + warning: string constructor() { this.label = 'Read File' @@ -28,6 +29,7 @@ class ReadFile_Tools implements INode { this.type = 'ReadFile' this.icon = 'readfile.svg' this.category = 'Tools' + this.warning = 'This tool can be used to read files from the disk. It is recommended to use this tool with caution.' this.description = 'Read file from disk' this.baseClasses = [this.type, 'Tool', ...getBaseClasses(ReadFileTool)] this.inputs = [ diff --git a/packages/components/nodes/tools/WriteFile/WriteFile.ts b/packages/components/nodes/tools/WriteFile/WriteFile.ts index bcb372f86e8..b4fe8ceaf69 100644 --- a/packages/components/nodes/tools/WriteFile/WriteFile.ts +++ b/packages/components/nodes/tools/WriteFile/WriteFile.ts @@ -20,6 +20,7 @@ class WriteFile_Tools implements INode { category: string baseClasses: string[] inputs: INodeParams[] + warning: string constructor() { this.label = 'Write File' @@ -28,6 +29,7 @@ class WriteFile_Tools implements INode { this.type = 'WriteFile' this.icon = 'writefile.svg' this.category = 'Tools' + this.warning = 'This tool can be used to write files to the disk. It is recommended to use this tool with caution.' this.description = 'Write file to disk' this.baseClasses = [this.type, 'Tool', ...getBaseClasses(WriteFileTool)] this.inputs = [ diff --git a/packages/components/src/Interface.ts b/packages/components/src/Interface.ts index 5e2ee383c07..7a170eaa003 100644 --- a/packages/components/src/Interface.ts +++ b/packages/components/src/Interface.ts @@ -134,6 +134,7 @@ export interface INodeProperties { documentation?: string color?: string hint?: string + warning?: string } export interface INode extends INodeProperties { diff --git a/packages/ui/src/views/agentflowsv2/AgentFlowNode.jsx b/packages/ui/src/views/agentflowsv2/AgentFlowNode.jsx index 7941d69b919..8bfafd9b2c7 100644 --- a/packages/ui/src/views/agentflowsv2/AgentFlowNode.jsx +++ b/packages/ui/src/views/agentflowsv2/AgentFlowNode.jsx @@ -179,6 +179,8 @@ const AgentFlowNode = ({ data }) => { componentNode?.deprecateMessage ?? 'This node will be deprecated in the next release. Change to a new node tagged with NEW' ) + } else if (componentNode.warning) { + setWarningMessage(componentNode.warning) } else { setWarningMessage('') } diff --git a/packages/ui/src/views/agentflowsv2/ConfigInput.jsx b/packages/ui/src/views/agentflowsv2/ConfigInput.jsx index 94b02235675..6ce7aac9195 100644 --- a/packages/ui/src/views/agentflowsv2/ConfigInput.jsx +++ b/packages/ui/src/views/agentflowsv2/ConfigInput.jsx @@ -3,10 +3,10 @@ import PropTypes from 'prop-types' import { cloneDeep } from 'lodash' // Material -import { Accordion, AccordionSummary, AccordionDetails, Box, Typography } from '@mui/material' +import { Accordion, AccordionSummary, AccordionDetails, Box, Typography, Tooltip, IconButton } from '@mui/material' import { useTheme } from '@mui/material/styles' import ExpandMoreIcon from '@mui/icons-material/ExpandMore' -import { IconSettings } from '@tabler/icons-react' +import { IconSettings, IconAlertTriangle } from '@tabler/icons-react' // Project imports import NodeInputHandler from '../canvas/NodeInputHandler' @@ -292,8 +292,21 @@ export const ConfigInput = ({ data, inputParam, disabled = false, arrayIndex = n > } sx={{ background: 'transparent' }}> - - {selectedComponentNodeData?.label} Parameters +
+ + {selectedComponentNodeData?.label} Parameters +
+ {selectedComponentNodeData?.warning && ( + {selectedComponentNodeData.warning}} + placement='top' + > + + + + + )} +
{(selectedComponentNodeData.inputParams ?? []) diff --git a/packages/ui/src/views/canvas/CanvasNode.jsx b/packages/ui/src/views/canvas/CanvasNode.jsx index 471b4d07960..03d9e51ee2c 100644 --- a/packages/ui/src/views/canvas/CanvasNode.jsx +++ b/packages/ui/src/views/canvas/CanvasNode.jsx @@ -82,6 +82,8 @@ const CanvasNode = ({ data }) => { componentNode?.deprecateMessage ?? 'This node will be deprecated in the next release. Change to a new node tagged with NEW' ) + } else if (componentNode.warning) { + setWarningMessage(componentNode.warning) } else { setWarningMessage('') } From d42e5f0701165e771c585508761b85629adc8a97 Mon Sep 17 00:00:00 2001 From: Henry Date: Wed, 1 Oct 2025 09:54:23 +0100 Subject: [PATCH 2/2] Enhance file handling tools with security features - Introduced new input parameters: workspacePath, enforceWorkspaceBoundaries, maxFileSize, and allowedExtensions for better control over file operations. - Added validation for file paths and sizes to prevent unsafe operations. - Implemented workspace boundary checks to restrict file access based on user-defined settings. --- .../nodes/tools/ReadFile/ReadFile.ts | 76 +++++++- .../nodes/tools/WriteFile/WriteFile.ts | 80 +++++++-- packages/components/src/SecureFileStore.ts | 167 ++++++++++++++++++ packages/components/src/validator.ts | 61 +++++++ 4 files changed, 366 insertions(+), 18 deletions(-) create mode 100644 packages/components/src/SecureFileStore.ts diff --git a/packages/components/nodes/tools/ReadFile/ReadFile.ts b/packages/components/nodes/tools/ReadFile/ReadFile.ts index 3f76bf6498e..eb703a1de90 100644 --- a/packages/components/nodes/tools/ReadFile/ReadFile.ts +++ b/packages/components/nodes/tools/ReadFile/ReadFile.ts @@ -1,9 +1,10 @@ import { z } from 'zod' +import path from 'path' import { StructuredTool, ToolParams } from '@langchain/core/tools' import { Serializable } from '@langchain/core/load/serializable' -import { NodeFileStore } from 'langchain/stores/file/node' import { INode, INodeData, INodeParams } from '../../../src/Interface' -import { getBaseClasses } from '../../../src/utils' +import { getBaseClasses, getUserHome } from '../../../src/utils' +import { SecureFileStore, FileSecurityConfig } from '../../../src/SecureFileStore' abstract class BaseFileStore extends Serializable { abstract readFile(path: string): Promise @@ -25,7 +26,7 @@ class ReadFile_Tools implements INode { constructor() { this.label = 'Read File' this.name = 'readFile' - this.version = 1.0 + this.version = 2.0 this.type = 'ReadFile' this.icon = 'readfile.svg' this.category = 'Tools' @@ -34,18 +35,77 @@ class ReadFile_Tools implements INode { this.baseClasses = [this.type, 'Tool', ...getBaseClasses(ReadFileTool)] this.inputs = [ { - label: 'Base Path', - name: 'basePath', - placeholder: `C:\\Users\\User\\Desktop`, + label: 'Workspace Path', + name: 'workspacePath', + placeholder: `C:\\Users\\User\\MyProject`, type: 'string', + description: 'Base workspace directory for file operations. All file paths will be relative to this directory.', + optional: true + }, + { + label: 'Enforce Workspace Boundaries', + name: 'enforceWorkspaceBoundaries', + type: 'boolean', + description: 'When enabled, restricts file access to the workspace directory for security. Recommended: true', + default: true, + optional: true + }, + { + label: 'Max File Size (MB)', + name: 'maxFileSize', + type: 'number', + description: 'Maximum file size in megabytes that can be read', + default: 10, + optional: true + }, + { + label: 'Allowed Extensions', + name: 'allowedExtensions', + type: 'string', + description: 'Comma-separated list of allowed file extensions (e.g., .txt,.json,.md). Leave empty to allow all.', + placeholder: '.txt,.json,.md,.py,.js', optional: true } ] } async init(nodeData: INodeData): Promise { - const basePath = nodeData.inputs?.basePath as string - const store = basePath ? new NodeFileStore(basePath) : new NodeFileStore() + const workspacePath = nodeData.inputs?.workspacePath as string + const enforceWorkspaceBoundaries = nodeData.inputs?.enforceWorkspaceBoundaries !== false // Default to true + const maxFileSize = nodeData.inputs?.maxFileSize as number + const allowedExtensions = nodeData.inputs?.allowedExtensions as string + + // Parse allowed extensions + const allowedExtensionsList = allowedExtensions ? allowedExtensions.split(',').map((ext) => ext.trim().toLowerCase()) : [] + + let store: BaseFileStore + + if (workspacePath) { + // Create secure file store with workspace boundaries + const config: FileSecurityConfig = { + workspacePath, + enforceWorkspaceBoundaries, + maxFileSize: maxFileSize ? maxFileSize * 1024 * 1024 : undefined, // Convert MB to bytes + allowedExtensions: allowedExtensionsList.length > 0 ? allowedExtensionsList : undefined + } + store = new SecureFileStore(config) + } else { + // Fallback to current working directory with security warnings + if (enforceWorkspaceBoundaries) { + const fallbackWorkspacePath = path.join(getUserHome(), '.flowise') + console.warn(`[ReadFile] No workspace path specified, using ${fallbackWorkspacePath} with security restrictions`) + store = new SecureFileStore({ + workspacePath: fallbackWorkspacePath, + enforceWorkspaceBoundaries: true, + maxFileSize: maxFileSize ? maxFileSize * 1024 * 1024 : undefined, + allowedExtensions: allowedExtensionsList.length > 0 ? allowedExtensionsList : undefined + }) + } else { + console.warn('[ReadFile] SECURITY WARNING: Workspace boundaries disabled - unrestricted file access enabled') + store = SecureFileStore.createUnsecure() + } + } + return new ReadFileTool({ store }) } } diff --git a/packages/components/nodes/tools/WriteFile/WriteFile.ts b/packages/components/nodes/tools/WriteFile/WriteFile.ts index b4fe8ceaf69..bc3609bebb5 100644 --- a/packages/components/nodes/tools/WriteFile/WriteFile.ts +++ b/packages/components/nodes/tools/WriteFile/WriteFile.ts @@ -1,9 +1,10 @@ import { z } from 'zod' +import path from 'path' import { StructuredTool, ToolParams } from '@langchain/core/tools' import { Serializable } from '@langchain/core/load/serializable' -import { NodeFileStore } from 'langchain/stores/file/node' import { INode, INodeData, INodeParams } from '../../../src/Interface' -import { getBaseClasses } from '../../../src/utils' +import { getBaseClasses, getUserHome } from '../../../src/utils' +import { SecureFileStore, FileSecurityConfig } from '../../../src/SecureFileStore' abstract class BaseFileStore extends Serializable { abstract readFile(path: string): Promise @@ -25,7 +26,7 @@ class WriteFile_Tools implements INode { constructor() { this.label = 'Write File' this.name = 'writeFile' - this.version = 1.0 + this.version = 2.0 this.type = 'WriteFile' this.icon = 'writefile.svg' this.category = 'Tools' @@ -34,18 +35,77 @@ class WriteFile_Tools implements INode { this.baseClasses = [this.type, 'Tool', ...getBaseClasses(WriteFileTool)] this.inputs = [ { - label: 'Base Path', - name: 'basePath', - placeholder: `C:\\Users\\User\\Desktop`, + label: 'Workspace Path', + name: 'workspacePath', + placeholder: `C:\\Users\\User\\MyProject`, type: 'string', + description: 'Base workspace directory for file operations. All file paths will be relative to this directory.', + optional: true + }, + { + label: 'Enforce Workspace Boundaries', + name: 'enforceWorkspaceBoundaries', + type: 'boolean', + description: 'When enabled, restricts file access to the workspace directory for security. Recommended: true', + default: true, + optional: true + }, + { + label: 'Max File Size (MB)', + name: 'maxFileSize', + type: 'number', + description: 'Maximum file size in megabytes that can be written', + default: 10, + optional: true + }, + { + label: 'Allowed Extensions', + name: 'allowedExtensions', + type: 'string', + description: 'Comma-separated list of allowed file extensions (e.g., .txt,.json,.md). Leave empty to allow all.', + placeholder: '.txt,.json,.md,.py,.js', optional: true } ] } async init(nodeData: INodeData): Promise { - const basePath = nodeData.inputs?.basePath as string - const store = basePath ? new NodeFileStore(basePath) : new NodeFileStore() + const workspacePath = nodeData.inputs?.workspacePath as string + const enforceWorkspaceBoundaries = nodeData.inputs?.enforceWorkspaceBoundaries !== false // Default to true + const maxFileSize = nodeData.inputs?.maxFileSize as number + const allowedExtensions = nodeData.inputs?.allowedExtensions as string + + // Parse allowed extensions + const allowedExtensionsList = allowedExtensions ? allowedExtensions.split(',').map((ext) => ext.trim().toLowerCase()) : [] + + let store: BaseFileStore + + if (workspacePath) { + // Create secure file store with workspace boundaries + const config: FileSecurityConfig = { + workspacePath, + enforceWorkspaceBoundaries, + maxFileSize: maxFileSize ? maxFileSize * 1024 * 1024 : undefined, // Convert MB to bytes + allowedExtensions: allowedExtensionsList.length > 0 ? allowedExtensionsList : undefined + } + store = new SecureFileStore(config) + } else { + // Fallback to current working directory with security warnings + if (enforceWorkspaceBoundaries) { + const fallbackWorkspacePath = path.join(getUserHome(), '.flowise') + console.warn(`[WriteFile] No workspace path specified, using ${fallbackWorkspacePath} with security restrictions`) + store = new SecureFileStore({ + workspacePath: fallbackWorkspacePath, + enforceWorkspaceBoundaries: true, + maxFileSize: maxFileSize ? maxFileSize * 1024 * 1024 : undefined, + allowedExtensions: allowedExtensionsList.length > 0 ? allowedExtensionsList : undefined + }) + } else { + console.warn('[WriteFile] SECURITY WARNING: Workspace boundaries disabled - unrestricted file access enabled') + store = SecureFileStore.createUnsecure() + } + } + return new WriteFileTool({ store }) } } @@ -70,7 +130,7 @@ export class WriteFileTool extends StructuredTool { name = 'write_file' - description = 'Write file from disk' + description = 'Write file to disk' store: BaseFileStore @@ -82,7 +142,7 @@ export class WriteFileTool extends StructuredTool { async _call({ file_path, text }: z.infer) { await this.store.writeFile(file_path, text) - return 'File written to successfully.' + return `File written to ${file_path} successfully.` } } diff --git a/packages/components/src/SecureFileStore.ts b/packages/components/src/SecureFileStore.ts new file mode 100644 index 00000000000..598d6c77250 --- /dev/null +++ b/packages/components/src/SecureFileStore.ts @@ -0,0 +1,167 @@ +import { Serializable } from '@langchain/core/load/serializable' +import { NodeFileStore } from 'langchain/stores/file/node' +import { isUnsafeFilePath, isWithinWorkspace } from './validator' +import * as path from 'path' +import * as fs from 'fs' + +/** + * Security configuration for file operations + */ +export interface FileSecurityConfig { + /** Base workspace path - all file operations are restricted to this directory */ + workspacePath: string + /** Whether to enforce workspace boundaries (default: true) */ + enforceWorkspaceBoundaries?: boolean + /** Maximum file size in bytes (default: 10MB) */ + maxFileSize?: number + /** Allowed file extensions (if empty, all extensions allowed) */ + allowedExtensions?: string[] + /** Blocked file extensions */ + blockedExtensions?: string[] +} + +/** + * Secure file store that enforces workspace boundaries and validates file operations + */ +export class SecureFileStore extends Serializable { + lc_namespace = ['flowise', 'components', 'stores', 'file'] + + private config: Required + private nodeFileStore: NodeFileStore + + constructor(config: FileSecurityConfig) { + super() + + // Set default configuration + this.config = { + workspacePath: config.workspacePath, + enforceWorkspaceBoundaries: config.enforceWorkspaceBoundaries ?? true, + maxFileSize: config.maxFileSize ?? 10 * 1024 * 1024, // 10MB default + allowedExtensions: config.allowedExtensions ?? [], + blockedExtensions: config.blockedExtensions ?? [ + '.exe', + '.bat', + '.cmd', + '.sh', + '.ps1', + '.vbs', + '.scr', + '.com', + '.pif', + '.dll', + '.sys', + '.msi', + '.jar' + ] + } + + // Validate workspace path + if (!this.config.workspacePath || !path.isAbsolute(this.config.workspacePath)) { + throw new Error('Workspace path must be an absolute path') + } + + // Ensure workspace directory exists + if (!fs.existsSync(this.config.workspacePath)) { + throw new Error(`Workspace directory does not exist: ${this.config.workspacePath}`) + } + + // Initialize the underlying NodeFileStore with workspace path + this.nodeFileStore = new NodeFileStore(this.config.workspacePath) + } + + /** + * Validates a file path against security policies + */ + private validateFilePath(filePath: string): void { + // Check for unsafe path patterns + if (isUnsafeFilePath(filePath)) { + throw new Error(`Unsafe file path detected: ${filePath}`) + } + + // Enforce workspace boundaries if enabled + if (this.config.enforceWorkspaceBoundaries) { + if (!isWithinWorkspace(filePath, this.config.workspacePath)) { + throw new Error(`File path outside workspace boundaries: ${filePath}`) + } + } + + // Check file extension + const ext = path.extname(filePath).toLowerCase() + + // Check blocked extensions + if (this.config.blockedExtensions.includes(ext)) { + throw new Error(`File extension not allowed: ${ext}`) + } + + // Check allowed extensions (if specified) + if (this.config.allowedExtensions.length > 0 && !this.config.allowedExtensions.includes(ext)) { + throw new Error(`File extension not in allowed list: ${ext}`) + } + } + + /** + * Validates file size + */ + private validateFileSize(content: string): void { + const sizeInBytes = Buffer.byteLength(content, 'utf8') + if (sizeInBytes > this.config.maxFileSize) { + throw new Error(`File size exceeds maximum allowed size: ${sizeInBytes} > ${this.config.maxFileSize}`) + } + } + + /** + * Reads a file with security validation + */ + async readFile(filePath: string): Promise { + this.validateFilePath(filePath) + + try { + return await this.nodeFileStore.readFile(filePath) + } catch (error) { + // Provide generic error message to avoid information leakage + throw new Error(`Failed to read file: ${path.basename(filePath)}`) + } + } + + /** + * Writes a file with security validation + */ + async writeFile(filePath: string, contents: string): Promise { + this.validateFilePath(filePath) + this.validateFileSize(contents) + + try { + // Ensure the directory exists + const dir = path.dirname(path.resolve(this.config.workspacePath, filePath)) + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }) + } + + await this.nodeFileStore.writeFile(filePath, contents) + } catch (error) { + // Provide generic error message to avoid information leakage + throw new Error(`Failed to write file: ${path.basename(filePath)}`) + } + } + + /** + * Gets the workspace configuration + */ + getConfig(): Readonly> { + return { ...this.config } + } + + /** + * Creates a secure file store with workspace enforcement disabled (for backward compatibility) + * WARNING: This should only be used when absolutely necessary and with proper user consent + */ + static createUnsecure(basePath?: string): SecureFileStore { + const workspacePath = basePath || process.cwd() + return new SecureFileStore({ + workspacePath, + enforceWorkspaceBoundaries: false, + maxFileSize: 50 * 1024 * 1024, // 50MB for unsecure mode + blockedExtensions: [] // No extension restrictions in unsecure mode + }) + } +} diff --git a/packages/components/src/validator.ts b/packages/components/src/validator.ts index c2ca53845bf..26cad65d935 100644 --- a/packages/components/src/validator.ts +++ b/packages/components/src/validator.ts @@ -41,3 +41,64 @@ export const isPathTraversal = (path: string): boolean => { return dangerousPatterns.some((pattern) => path.toLowerCase().includes(pattern)) } + +/** + * Enhanced path validation for workspace-scoped file operations + * @param {string} filePath The file path to validate + * @returns {boolean} True if path traversal detected, false otherwise + */ +export const isUnsafeFilePath = (filePath: string): boolean => { + if (!filePath || typeof filePath !== 'string') { + return true + } + + // Check for path traversal patterns + const dangerousPatterns = [ + /\.\./, // Directory traversal (..) + /%2e%2e/i, // URL encoded .. + /%2f/i, // URL encoded / + /%5c/i, // URL encoded \ + /\0/, // Null bytes + // eslint-disable-next-line no-control-regex + /[\x00-\x1f]/, // Control characters + /^\/[^/]/, // Absolute Unix paths (starting with /) + /^[a-zA-Z]:\\/, // Absolute Windows paths (C:\) + /^\\\\[^\\]/, // UNC paths (\\server\) + /^\\\\\?\\/ // Extended-length paths (\\?\) + ] + + return dangerousPatterns.some((pattern) => pattern.test(filePath)) +} + +/** + * Validates if a file path is within the allowed workspace boundaries + * @param {string} filePath The file path to validate + * @param {string} workspacePath The workspace base path + * @returns {boolean} True if path is within workspace, false otherwise + */ +export const isWithinWorkspace = (filePath: string, workspacePath: string): boolean => { + if (!filePath || !workspacePath) { + return false + } + + try { + const path = require('path') + + // Resolve both paths to absolute paths + const resolvedFilePath = path.resolve(workspacePath, filePath) + const resolvedWorkspacePath = path.resolve(workspacePath) + + // Normalize paths to handle different separators + const normalizedFilePath = path.normalize(resolvedFilePath) + const normalizedWorkspacePath = path.normalize(resolvedWorkspacePath) + + // Check if the file path starts with the workspace path + const relativePath = path.relative(normalizedWorkspacePath, normalizedFilePath) + + // If relative path starts with '..' or is absolute, it's outside workspace + return !relativePath.startsWith('..') && !path.isAbsolute(relativePath) + } catch (error) { + // If any error occurs during path resolution, deny access + return false + } +}