Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docker/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,8 @@ JWT_REFRESH_TOKEN_EXPIRY_IN_MINUTES=43200
# HTTP_SECURITY_CHECK=true
# PATH_TRAVERSAL_SAFETY=true
# CUSTOM_MCP_SECURITY_CHECK=true
# CUSTOM_MCP_PROTOCOL=sse #(stdio | sse)
# 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)
# 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)
Expand Down
2 changes: 2 additions & 0 deletions docker/docker-compose-queue-prebuilt.yml
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ services:
# SECURITY
- CUSTOM_MCP_SECURITY_CHECK=${CUSTOM_MCP_SECURITY_CHECK}
- CUSTOM_MCP_PROTOCOL=${CUSTOM_MCP_PROTOCOL}
- CUSTOM_MCP_ALLOWED_ENV_VARS=${CUSTOM_MCP_ALLOWED_ENV_VARS}
- HTTP_DENY_LIST=${HTTP_DENY_LIST}
- HTTP_SECURITY_CHECK=${HTTP_SECURITY_CHECK}
- PATH_TRAVERSAL_SAFETY=${PATH_TRAVERSAL_SAFETY}
Expand Down Expand Up @@ -309,6 +310,7 @@ services:
# SECURITY
- CUSTOM_MCP_SECURITY_CHECK=${CUSTOM_MCP_SECURITY_CHECK}
- CUSTOM_MCP_PROTOCOL=${CUSTOM_MCP_PROTOCOL}
- CUSTOM_MCP_ALLOWED_ENV_VARS=${CUSTOM_MCP_ALLOWED_ENV_VARS}
- HTTP_DENY_LIST=${HTTP_DENY_LIST}
- HTTP_SECURITY_CHECK=${HTTP_SECURITY_CHECK}
- PATH_TRAVERSAL_SAFETY=${PATH_TRAVERSAL_SAFETY}
Expand Down
1 change: 1 addition & 0 deletions docker/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ services:
# SECURITY
- CUSTOM_MCP_SECURITY_CHECK=${CUSTOM_MCP_SECURITY_CHECK}
- CUSTOM_MCP_PROTOCOL=${CUSTOM_MCP_PROTOCOL}
- CUSTOM_MCP_ALLOWED_ENV_VARS=${CUSTOM_MCP_ALLOWED_ENV_VARS}
- HTTP_DENY_LIST=${HTTP_DENY_LIST}
- HTTP_SECURITY_CHECK=${HTTP_SECURITY_CHECK}
- PATH_TRAVERSAL_SAFETY=${PATH_TRAVERSAL_SAFETY}
Expand Down
3 changes: 2 additions & 1 deletion docker/worker/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,8 @@ JWT_REFRESH_TOKEN_EXPIRY_IN_MINUTES=43200
# HTTP_SECURITY_CHECK=true
# PATH_TRAVERSAL_SAFETY=true
# CUSTOM_MCP_SECURITY_CHECK=true
# CUSTOM_MCP_PROTOCOL=sse #(stdio | sse)
# 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)
# 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)
Expand Down
1 change: 1 addition & 0 deletions docker/worker/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ services:
# SECURITY
- CUSTOM_MCP_SECURITY_CHECK=${CUSTOM_MCP_SECURITY_CHECK}
- CUSTOM_MCP_PROTOCOL=${CUSTOM_MCP_PROTOCOL}
- CUSTOM_MCP_ALLOWED_ENV_VARS=${CUSTOM_MCP_ALLOWED_ENV_VARS}
- HTTP_DENY_LIST=${HTTP_DENY_LIST}
- HTTP_SECURITY_CHECK=${HTTP_SECURITY_CHECK}
- PATH_TRAVERSAL_SAFETY=${PATH_TRAVERSAL_SAFETY}
Expand Down
15 changes: 5 additions & 10 deletions packages/components/nodes/tools/MCP/CustomMCP/CustomMCP.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { Tool } from '@langchain/core/tools'
import hash from 'object-hash'
import { DataSource } from 'typeorm'
import { ICommonObject, IDatabaseEntity, INode, INodeData, INodeOptionsValue, INodeParams } from '../../../../src/Interface'
import { getVars, parseJsonBody, prepareSandboxVars } from '../../../../src/utils'
import { MCPToolkit, validateMCPServerConfig } from '../core'
import { getVars, prepareSandboxVars, parseJsonBody } from '../../../../src/utils'
import { DataSource } from 'typeorm'
import hash from 'object-hash'

const mcpServerConfig = `{
"command": "npx",
Expand Down Expand Up @@ -183,13 +183,8 @@ class Custom_MCP implements INode {

// Compatible with stdio and SSE
let toolkit: MCPToolkit
if (process.env.CUSTOM_MCP_PROTOCOL === 'sse') {
toolkit = new MCPToolkit(serverParams, 'sse')
} else if (serverParams?.command === undefined) {
toolkit = new MCPToolkit(serverParams, 'sse')
} else {
toolkit = new MCPToolkit(serverParams, 'stdio')
}
if (process.env.CUSTOM_MCP_PROTOCOL === 'stdio' && serverParams!.command) toolkit = new MCPToolkit(serverParams, 'stdio')
Comment thread
yau-wd marked this conversation as resolved.
else toolkit = new MCPToolkit(serverParams, 'sse')

await toolkit.initialize()

Expand Down
61 changes: 53 additions & 8 deletions packages/components/nodes/tools/MCP/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -413,23 +413,47 @@ describe('MCP Security Validations', () => {
})

describe('validateEnvironmentVariables', () => {
it('should block dangerous environment variables', () => {
const originalAllowList = process.env.CUSTOM_MCP_ALLOWED_ENV_VARS

afterEach(() => {
if (originalAllowList === undefined) {
delete process.env.CUSTOM_MCP_ALLOWED_ENV_VARS
} else {
process.env.CUSTOM_MCP_ALLOWED_ENV_VARS = originalAllowList
}
})

it('should block all environment variables when the allow-list is empty', () => {
delete process.env.CUSTOM_MCP_ALLOWED_ENV_VARS

expect(() => {
validateEnvironmentVariables({ API_KEY: 'key123' })
}).toThrow("Environment variable 'API_KEY' is not allowed")
})

it('should block variables that are not on the allow-list', () => {
process.env.CUSTOM_MCP_ALLOWED_ENV_VARS = 'API_KEY'

expect(() => {
validateEnvironmentVariables({ PATH: '/malicious/path' })
}).toThrow("Environment variable 'PATH' modification is not allowed")
}).toThrow("Environment variable 'PATH' is not allowed")

expect(() => {
validateEnvironmentVariables({ NODE_OPTIONS: '--inspect' })
}).toThrow("Environment variable 'NODE_OPTIONS' modification is not allowed")
validateEnvironmentVariables({ PYTHONWARNINGS: 'module::antigravity.' })
}).toThrow("Environment variable 'PYTHONWARNINGS' is not allowed")
})

it('should block null bytes in values', () => {
it('should block null bytes in values of allow-listed variables', () => {
process.env.CUSTOM_MCP_ALLOWED_ENV_VARS = 'CUSTOM_VAR'

expect(() => {
validateEnvironmentVariables({ CUSTOM_VAR: 'value\0malicious' })
}).toThrow("Environment variable 'CUSTOM_VAR' contains null byte")
})

it('should allow safe environment variables', () => {
it('should allow variables that are on the allow-list', () => {
process.env.CUSTOM_MCP_ALLOWED_ENV_VARS = 'CUSTOM_VAR,API_KEY'

expect(() => {
validateEnvironmentVariables({ CUSTOM_VAR: 'safe-value', API_KEY: 'key123' })
}).not.toThrow()
Expand Down Expand Up @@ -482,14 +506,35 @@ describe('MCP Security Validations', () => {
}).toThrow('Argument contains potential local file access')
})

it('should block dangerous environment variables', () => {
it('should block environment variables that are not on the allow-list', () => {
expect(() => {
validateMCPServerConfig({
command: 'npx',
args: ['safe-arg'],
env: { PATH: '/malicious' }
})
}).toThrow("Environment variable 'PATH' modification is not allowed")
}).toThrow("Environment variable 'PATH' is not allowed")
})

it('should allow environment variables that are on the allow-list', () => {
const original = process.env.CUSTOM_MCP_ALLOWED_ENV_VARS
process.env.CUSTOM_MCP_ALLOWED_ENV_VARS = 'API_TOKEN'

try {
expect(() => {
validateMCPServerConfig({
command: 'npx',
args: ['safe-arg'],
env: { API_TOKEN: 'secret123' }
})
}).not.toThrow()
} finally {
if (original === undefined) {
delete process.env.CUSTOM_MCP_ALLOWED_ENV_VARS
} else {
process.env.CUSTOM_MCP_ALLOWED_ENV_VARS = original
}
}
})

it('should reject invalid server params', () => {
Expand Down
21 changes: 15 additions & 6 deletions packages/components/nodes/tools/MCP/core.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { CallToolRequest, CallToolResultSchema, ListToolsResult, ListToolsResultSchema } from '@modelcontextprotocol/sdk/types.js'
import { BaseToolkit, tool, Tool } from '@langchain/core/tools'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'
import { StdioClientTransport, StdioServerParameters } from '@modelcontextprotocol/sdk/client/stdio.js'
import { BaseToolkit, tool, Tool } from '@langchain/core/tools'
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'
import { CallToolRequest, CallToolResultSchema, ListToolsResult, ListToolsResultSchema } from '@modelcontextprotocol/sdk/types.js'
import { checkDenyList, secureFetch } from '../../../src/httpSecurity'

export class MCPToolkit extends BaseToolkit {
Expand Down Expand Up @@ -249,12 +249,21 @@ export const validateCommandInjection = (args: string[]): void => {
}
}

/**
* Validates user-supplied env vars against the operator-controlled allow-list in
* `CUSTOM_MCP_ALLOWED_ENV_VARS` (comma-separated names). Empty = none allowed.
*/
export const validateEnvironmentVariables = (env: Record<string, any>): void => {
const dangerousEnvVars = ['PATH', 'LD_LIBRARY_PATH', 'DYLD_LIBRARY_PATH', 'NODE_OPTIONS']
const allowedEnvVars = new Set(
(process.env.CUSTOM_MCP_ALLOWED_ENV_VARS ?? '')
.split(',')
.map((s) => s.trim())
.filter(Boolean)
)

for (const [key, value] of Object.entries(env)) {
if (dangerousEnvVars.includes(key)) {
throw new Error(`Environment variable '${key}' modification is not allowed`)
if (!allowedEnvVars.has(key)) {
throw new Error(`Environment variable '${key}' is not allowed. Permitted: ${[...allowedEnvVars].join(', ') || '(none)'}`)
}

if (typeof value === 'string' && value.includes('\0')) {
Expand Down
3 changes: 2 additions & 1 deletion packages/server/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,8 @@ JWT_REFRESH_TOKEN_EXPIRY_IN_MINUTES=43200
# HTTP_SECURITY_CHECK=true
# PATH_TRAVERSAL_SAFETY=true
# CUSTOM_MCP_SECURITY_CHECK=true
# CUSTOM_MCP_PROTOCOL=sse #(stdio | sse)
# 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)
# 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)
Expand Down
1 change: 1 addition & 0 deletions packages/server/src/commands/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ export abstract class BaseCommand extends Command {
// Security
CUSTOM_MCP_SECURITY_CHECK: Flags.string(),
CUSTOM_MCP_PROTOCOL: Flags.string(),
CUSTOM_MCP_ALLOWED_ENV_VARS: Flags.string(),
HTTP_DENY_LIST: Flags.string(),
HTTP_SECURITY_CHECK: Flags.string(),
PATH_TRAVERSAL_SAFETY: Flags.string(),
Expand Down
Loading