mirror of
https://github.com/farcasclaudiu/Flowise.git
synced 2026-06-28 23:01:09 +03:00
Chore/read write tools update (#5275)
* add tools warning * 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.
This commit is contained in:
@@ -134,6 +134,7 @@ export interface INodeProperties {
|
||||
documentation?: string
|
||||
color?: string
|
||||
hint?: string
|
||||
warning?: string
|
||||
}
|
||||
|
||||
export interface INode extends INodeProperties {
|
||||
|
||||
@@ -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<FileSecurityConfig>
|
||||
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<string> {
|
||||
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<void> {
|
||||
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<Required<FileSecurityConfig>> {
|
||||
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
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user