diff --git a/docker/.env.example b/docker/.env.example index e134e35c0d5..bfc2ac46cee 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -199,6 +199,7 @@ JWT_REFRESH_TOKEN_EXPIRY_IN_MINUTES=43200 # CUSTOM_MCP_PROTOCOL=sse #(stdio | sse) 'stdio' can run arbitrary commands on your server, enable only if you trust all users # CUSTOM_MCP_ALLOWED_ENV_VARS= #(comma-separated list of env var names a Custom MCP stdio config may set, e.g. BRAVE_API_KEY,GITHUB_TOKEN. Empty = none allowed) # CUSTOM_MCP_ALLOWED_COMMANDS= #(comma-separated list of commands a Custom MCP stdio config may run. Empty = none allowed. Set only the commands you need from: node|npx|python|python3|docker) +# CUSTOM_MCP_ALLOWED_ABSOLUTE_SCRIPT_PATHS= #WARNING: Only add scripts YOU control - misconfiguration allows arbitrary code execution. (comma-separated list of absolute file paths that Custom MCP stdio configs may execute. Empty = none allowed.Example: /usr/local/lib/server.js,/app/scripts/brave-search.js) # TRUST_PROXY=true #(true | false | 1 | loopback| linklocal | uniquelocal | IP addresses | loopback, IP addresses) # OAUTH2_SECURITY_CHECK=true # OAUTH2_ALLOWED_TOKEN_DOMAINS= #(comma-separated list of additional OAuth2 provider domains to allow, e.g. keycloak.mycompany.com,auth.custom-idp.com) diff --git a/docker/docker-compose-queue-prebuilt.yml b/docker/docker-compose-queue-prebuilt.yml index ac4df92aeb5..d5bf6a0af3d 100644 --- a/docker/docker-compose-queue-prebuilt.yml +++ b/docker/docker-compose-queue-prebuilt.yml @@ -156,6 +156,7 @@ services: - CUSTOM_MCP_PROTOCOL=${CUSTOM_MCP_PROTOCOL} - CUSTOM_MCP_ALLOWED_ENV_VARS=${CUSTOM_MCP_ALLOWED_ENV_VARS} - CUSTOM_MCP_ALLOWED_COMMANDS=${CUSTOM_MCP_ALLOWED_COMMANDS} + - CUSTOM_MCP_ALLOWED_ABSOLUTE_SCRIPT_PATHS=${CUSTOM_MCP_ALLOWED_ABSOLUTE_SCRIPT_PATHS} - HTTP_DENY_LIST=${HTTP_DENY_LIST} - HTTP_SECURITY_CHECK=${HTTP_SECURITY_CHECK} - PATH_TRAVERSAL_SAFETY=${PATH_TRAVERSAL_SAFETY} @@ -313,6 +314,7 @@ services: - CUSTOM_MCP_PROTOCOL=${CUSTOM_MCP_PROTOCOL} - CUSTOM_MCP_ALLOWED_ENV_VARS=${CUSTOM_MCP_ALLOWED_ENV_VARS} - CUSTOM_MCP_ALLOWED_COMMANDS=${CUSTOM_MCP_ALLOWED_COMMANDS} + - CUSTOM_MCP_ALLOWED_ABSOLUTE_SCRIPT_PATHS=${CUSTOM_MCP_ALLOWED_ABSOLUTE_SCRIPT_PATHS} - HTTP_DENY_LIST=${HTTP_DENY_LIST} - HTTP_SECURITY_CHECK=${HTTP_SECURITY_CHECK} - PATH_TRAVERSAL_SAFETY=${PATH_TRAVERSAL_SAFETY} diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index cea031df421..d6b9f3f4305 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -141,6 +141,7 @@ services: - CUSTOM_MCP_PROTOCOL=${CUSTOM_MCP_PROTOCOL} - CUSTOM_MCP_ALLOWED_ENV_VARS=${CUSTOM_MCP_ALLOWED_ENV_VARS} - CUSTOM_MCP_ALLOWED_COMMANDS=${CUSTOM_MCP_ALLOWED_COMMANDS} + - CUSTOM_MCP_ALLOWED_ABSOLUTE_SCRIPT_PATHS=${CUSTOM_MCP_ALLOWED_ABSOLUTE_SCRIPT_PATHS} - HTTP_DENY_LIST=${HTTP_DENY_LIST} - HTTP_SECURITY_CHECK=${HTTP_SECURITY_CHECK} - PATH_TRAVERSAL_SAFETY=${PATH_TRAVERSAL_SAFETY} diff --git a/docker/worker/.env.example b/docker/worker/.env.example index f8096b138a7..15c28c68145 100644 --- a/docker/worker/.env.example +++ b/docker/worker/.env.example @@ -198,6 +198,7 @@ JWT_REFRESH_TOKEN_EXPIRY_IN_MINUTES=43200 # CUSTOM_MCP_PROTOCOL=sse #(stdio | sse) 'stdio' can run arbitrary commands on your server, enable only if you trust all users # CUSTOM_MCP_ALLOWED_ENV_VARS= #(comma-separated list of env var names a Custom MCP stdio config may set, e.g. BRAVE_API_KEY,GITHUB_TOKEN. Empty = none allowed) # CUSTOM_MCP_ALLOWED_COMMANDS= #(comma-separated list of commands a Custom MCP stdio config may run. Empty = none allowed. Set only the commands you need from: node|npx|python|python3|docker) +# CUSTOM_MCP_ALLOWED_ABSOLUTE_SCRIPT_PATHS= #WARNING: Only add scripts YOU control - misconfiguration allows arbitrary code execution. (comma-separated list of absolute file paths that Custom MCP stdio configs may execute. Empty = none allowed.Example: /usr/local/lib/server.js,/app/scripts/brave-search.js) # TRUST_PROXY=true #(true | false | 1 | loopback| linklocal | uniquelocal | IP addresses | loopback, IP addresses) # OAUTH2_SECURITY_CHECK=true # OAUTH2_ALLOWED_TOKEN_DOMAINS= #(comma-separated list of additional OAuth2 provider domains to allow, e.g. keycloak.mycompany.com,auth.custom-idp.com) diff --git a/docker/worker/docker-compose.yml b/docker/worker/docker-compose.yml index 9f9de83c889..ec3d45dd0c8 100644 --- a/docker/worker/docker-compose.yml +++ b/docker/worker/docker-compose.yml @@ -140,6 +140,7 @@ services: - CUSTOM_MCP_PROTOCOL=${CUSTOM_MCP_PROTOCOL} - CUSTOM_MCP_ALLOWED_ENV_VARS=${CUSTOM_MCP_ALLOWED_ENV_VARS} - CUSTOM_MCP_ALLOWED_COMMANDS=${CUSTOM_MCP_ALLOWED_COMMANDS} + - CUSTOM_MCP_ALLOWED_ABSOLUTE_SCRIPT_PATHS=${CUSTOM_MCP_ALLOWED_ABSOLUTE_SCRIPT_PATHS} - HTTP_DENY_LIST=${HTTP_DENY_LIST} - HTTP_SECURITY_CHECK=${HTTP_SECURITY_CHECK} - PATH_TRAVERSAL_SAFETY=${PATH_TRAVERSAL_SAFETY} diff --git a/packages/components/nodes/tools/MCP/core.test.ts b/packages/components/nodes/tools/MCP/core.test.ts index c1b5daf0b5a..2997c26f064 100644 --- a/packages/components/nodes/tools/MCP/core.test.ts +++ b/packages/components/nodes/tools/MCP/core.test.ts @@ -363,52 +363,38 @@ describe('MCP Security Validations', () => { }) describe('validateArgsForLocalFileAccess', () => { - it('should block absolute paths', () => { - expect(() => { - validateArgsForLocalFileAccess(['/etc/passwd']) - }).toThrow('Argument contains potential local file access') - - expect(() => { - validateArgsForLocalFileAccess(['C:\\Windows\\System32']) - }).toThrow('Argument contains potential local file access') - }) + const ORIGINAL_ENV = process.env.CUSTOM_MCP_ALLOWED_ABSOLUTE_SCRIPT_PATHS - it('should block double-slash absolute paths', () => { - expect(() => { - validateArgsForLocalFileAccess(['//etc/passwd']) - }).toThrow('Argument contains potential local file access') + afterEach(() => { + if (ORIGINAL_ENV === undefined) { + delete process.env.CUSTOM_MCP_ALLOWED_ABSOLUTE_SCRIPT_PATHS + } else { + process.env.CUSTOM_MCP_ALLOWED_ABSOLUTE_SCRIPT_PATHS = ORIGINAL_ENV + } }) - it('should block path traversal', () => { - expect(() => { - validateArgsForLocalFileAccess(['../../../etc/passwd']) - }).toThrow('Argument contains potential local file access') + it('should block when not configured', () => { + delete process.env.CUSTOM_MCP_ALLOWED_ABSOLUTE_SCRIPT_PATHS expect(() => { - validateArgsForLocalFileAccess(['..\\..\\Windows']) - }).toThrow('Argument contains potential local file access') + validateArgsForLocalFileAccess(['/usr/local/lib/server.js']) + }).toThrow('Configure CUSTOM_MCP_ALLOWED_ABSOLUTE_SCRIPT_PATHS') }) - it('should block dangerous file extensions', () => { - expect(() => { - validateArgsForLocalFileAccess(['malware.exe']) - }).toThrow('Argument contains potential local file access') + it('should allow script in allow-list', () => { + process.env.CUSTOM_MCP_ALLOWED_ABSOLUTE_SCRIPT_PATHS = '/usr/local/lib/server.js' expect(() => { - validateArgsForLocalFileAccess(['script.sh']) - }).toThrow('Argument contains potential local file access') + validateArgsForLocalFileAccess(['/usr/local/lib/server.js']) + }).not.toThrow() }) - it('should block null bytes', () => { - expect(() => { - validateArgsForLocalFileAccess(['file\0.txt']) - }).toThrow('Argument contains null byte') - }) + it('should block script not in allow-list', () => { + process.env.CUSTOM_MCP_ALLOWED_ABSOLUTE_SCRIPT_PATHS = '/usr/local/lib/server.js' - it('should allow safe arguments', () => { expect(() => { - validateArgsForLocalFileAccess(['@modelcontextprotocol/server-github', 'safe-arg']) - }).not.toThrow() + validateArgsForLocalFileAccess(['root/.flowise/malicious.js']) + }).toThrow('not in allowed list') }) }) @@ -462,11 +448,15 @@ describe('MCP Security Validations', () => { describe('validateMCPServerConfig', () => { const originalAllowedCommands = process.env.CUSTOM_MCP_ALLOWED_COMMANDS + const originalAllowedScriptPaths = process.env.CUSTOM_MCP_ALLOWED_ABSOLUTE_SCRIPT_PATHS beforeEach(() => { // These tests assume the operator has permitted the common interpreters. // The default (empty) allow-list is exercised separately below. process.env.CUSTOM_MCP_ALLOWED_COMMANDS = 'node,npx,python,python3,docker' + // Allow common script paths for tests + process.env.CUSTOM_MCP_ALLOWED_ABSOLUTE_SCRIPT_PATHS = + '@modelcontextprotocol/server-filesystem,workspace,safe-arg,mcp-server.js,server.js,/usr/local/lib/server.js' }) afterEach(() => { @@ -475,6 +465,12 @@ describe('MCP Security Validations', () => { } else { process.env.CUSTOM_MCP_ALLOWED_COMMANDS = originalAllowedCommands } + + if (originalAllowedScriptPaths === undefined) { + delete process.env.CUSTOM_MCP_ALLOWED_ABSOLUTE_SCRIPT_PATHS + } else { + process.env.CUSTOM_MCP_ALLOWED_ABSOLUTE_SCRIPT_PATHS = originalAllowedScriptPaths + } }) it('should validate complete server configuration', () => { @@ -498,30 +494,21 @@ describe('MCP Security Validations', () => { it('should block dangerous command flags', () => { expect(() => { validateMCPServerConfig({ - command: 'npx', - args: ['-c', 'malicious command'] + command: 'node', + args: ['server.js', '-e', 'malicious code'] }) - }).toThrow("Argument '-c' is not allowed for command 'npx'") + }).toThrow("Argument '-e' is not allowed for command 'node'") }) it('should block command injection in args', () => { expect(() => { validateMCPServerConfig({ - command: 'npx', - args: ['arg1; malicious'] + command: 'node', + args: ['server.js', 'arg1; malicious'] }) }).toThrow('Argument contains potentially dangerous characters') }) - it('should block path traversal in args', () => { - expect(() => { - validateMCPServerConfig({ - command: 'npx', - args: ['../../../etc/passwd'] - }) - }).toThrow('Argument contains potential local file access') - }) - it('should block environment variables that are not on the allow-list', () => { expect(() => { validateMCPServerConfig({ diff --git a/packages/components/nodes/tools/MCP/core.ts b/packages/components/nodes/tools/MCP/core.ts index 8ba771f243a..a363ecd18f8 100644 --- a/packages/components/nodes/tools/MCP/core.ts +++ b/packages/components/nodes/tools/MCP/core.ts @@ -179,49 +179,17 @@ export async function MCPTool({ } export const validateArgsForLocalFileAccess = (args: string[]): void => { - const dangerousPatterns = [ - // Absolute paths - /^\//, // Unix absolute paths starting with / - /^[a-zA-Z]:\\/, // Windows absolute paths like C:\ - - // Relative paths that could escape current directory - /\.\.\//, // Parent directory traversal with ../ - /\.\.\\/, // Parent directory traversal with ..\ - /^\.\./, // Starting with .. - - // Local file access patterns - /^\.\//, // Current directory with ./ - /^~\//, // Home directory with ~/ - /^file:\/\//, // File protocol - - // Common file extensions that shouldn't be accessed - /\.(exe|bat|cmd|sh|ps1|vbs|scr|com|pif|dll|sys)$/i, - - // File flags and options that could access local files - /^--?(?:file|input|output|config|load|save|import|export|read|write)=/i, - /^--?(?:file|input|output|config|load|save|import|export|read|write)$/i - ] + const allowedScriptPaths = (process.env.CUSTOM_MCP_ALLOWED_ABSOLUTE_SCRIPT_PATHS ?? '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean) - for (const arg of args) { - if (typeof arg !== 'string') continue + const scriptArg = args[0] - // Check for dangerous patterns - for (const pattern of dangerousPatterns) { - if (pattern.test(arg)) { - throw new Error(`Argument contains potential local file access: "${arg}"`) - } - } + if (allowedScriptPaths.length === 0) + throw new Error('Custom MCP script execution disabled. Configure CUSTOM_MCP_ALLOWED_ABSOLUTE_SCRIPT_PATHS environment variable.') - // Check for null bytes - if (arg.includes('\0')) { - throw new Error(`Argument contains null byte: "${arg}"`) - } - - // Check for very long paths that might be used for buffer overflow attacks - if (arg.length > 1000) { - throw new Error(`Argument is suspiciously long (${arg.length} characters): "${arg.substring(0, 100)}..."`) - } - } + if (!allowedScriptPaths.includes(scriptArg)) throw new Error('Custom MCP script path not in allowed list.') } export const validateCommandInjection = (args: string[]): void => { diff --git a/packages/server/.env.example b/packages/server/.env.example index 28a89414dd4..0fa07e9ba6e 100644 --- a/packages/server/.env.example +++ b/packages/server/.env.example @@ -198,6 +198,7 @@ JWT_REFRESH_TOKEN_EXPIRY_IN_MINUTES=43200 # CUSTOM_MCP_PROTOCOL=sse #(stdio | sse) 'stdio' can run arbitrary commands on your server, enable only if you trust all users # CUSTOM_MCP_ALLOWED_ENV_VARS= #(comma-separated list of env var names a Custom MCP stdio config may set, e.g. BRAVE_API_KEY,GITHUB_TOKEN. Empty = none allowed) # CUSTOM_MCP_ALLOWED_COMMANDS= #(comma-separated list of commands a Custom MCP stdio config may run. Empty = none allowed. Set only the commands you need from: node|npx|python|python3|docker) +# CUSTOM_MCP_ALLOWED_ABSOLUTE_SCRIPT_PATHS= #WARNING: Only add scripts YOU control - misconfiguration allows arbitrary code execution. (comma-separated list of absolute file paths that Custom MCP stdio configs may execute. Empty = none allowed.Example: /usr/local/lib/server.js,/app/scripts/brave-search.js) # TRUST_PROXY=true #(true | false | 1 | loopback| linklocal | uniquelocal | IP addresses | loopback, IP addresses) # OAUTH2_SECURITY_CHECK=true # OAUTH2_ALLOWED_TOKEN_DOMAINS= #(comma-separated list of additional OAuth2 provider domains to allow, e.g. keycloak.mycompany.com,auth.custom-idp.com) diff --git a/packages/server/src/commands/base.ts b/packages/server/src/commands/base.ts index 5ff23b04cc2..5ef5092ad06 100644 --- a/packages/server/src/commands/base.ts +++ b/packages/server/src/commands/base.ts @@ -108,6 +108,8 @@ export abstract class BaseCommand extends Command { CUSTOM_MCP_SECURITY_CHECK: Flags.string(), CUSTOM_MCP_PROTOCOL: Flags.string(), CUSTOM_MCP_ALLOWED_ENV_VARS: Flags.string(), + CUSTOM_MCP_ALLOWED_COMMANDS: Flags.string(), + CUSTOM_MCP_ALLOWED_ABSOLUTE_SCRIPT_PATHS: Flags.string(), HTTP_DENY_LIST: Flags.string(), HTTP_SECURITY_CHECK: Flags.string(), PATH_TRAVERSAL_SAFETY: Flags.string(),