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:
Henry Heng
2025-10-08 10:56:01 +01:00
committed by GitHub
parent a0dca552a2
commit 1fb12cd931
8 changed files with 392 additions and 22 deletions
+1
View File
@@ -134,6 +134,7 @@ export interface INodeProperties {
documentation?: string
color?: string
hint?: string
warning?: string
}
export interface INode extends INodeProperties {
+167
View File
@@ -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
})
}
}
+61
View File
@@ -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
}
}