From 948e5d0f0f8792a5593616c496690434fc5ccc27 Mon Sep 17 00:00:00 2001 From: Laraib Waheed Date: Wed, 15 Jul 2026 08:02:25 +0500 Subject: [PATCH 1/2] Fix Flowise 6602: add HTTP_ALLOW_LIST to bypass SSRF deny list for trusted hosts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Custom MCP servers reachable via Docker Compose service names (e.g. mcp-server) resolve into 172.16.0.0/12 and are rejected by checkDenyList. The only prior escape hatch was HTTP_SECURITY_CHECK=false, which disables SSRF protection globally. Introduces HTTP_ALLOW_LIST — a comma-separated list of hostnames whose deny-list check is skipped. Entries are exact, case-insensitive hostname matches (no wildcards). Applies to both checkDenyList and the pinned-agent resolveAndValidate path used by secureFetch / secureAxiosRequest, so redirect chains remain safe: a hop off an allowed host to an unrelated private IP is still blocked. Adds 13 tests covering the allow list, whitespace/case normalization, DNS-skip behavior in checkDenyList, and the direct-IP + hostname paths in resolveAndValidate via secureFetch. Co-Authored-By: Claude Opus 4.6 --- CONTRIBUTING.md | 97 +++++++-------- packages/components/src/httpSecurity.test.ts | 123 ++++++++++++++++++- packages/components/src/httpSecurity.ts | 38 +++++- packages/server/.env.example | 1 + 4 files changed, 205 insertions(+), 54 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 421732219fe..ef5c636cd5e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -181,54 +181,55 @@ If in doubt, use `type: 'password'`. The only cost is that the field must be re- Flowise support different environment variables to configure your instance. You can specify the following variables in the `.env` file inside `packages/server` folder. Read [more](https://docs.flowiseai.com/configuration/environment-variables) -| Variable | Description | Type | Default | -| ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | ----------------------------------- | -| PORT | The HTTP port Flowise runs on | Number | 3000 | -| CORS_ALLOW_CREDENTIALS | Enables CORS `Access-Control-Allow-Credentials` when `true` | Boolean | false | -| CORS_ORIGINS | The allowed origins for all cross-origin HTTP calls | String | | -| MCP_CORS_ORIGINS | The allowed origins for MCP endpoint cross-origin calls. If unset, only non-browser (no Origin header) requests are allowed. Set to `*` to allow all origins. | String | | -| IFRAME_ORIGINS | The allowed origins for iframe src embedding | String | | -| FLOWISE_FILE_SIZE_LIMIT | Upload File Size Limit | String | 50mb | -| CUSTOM_MCP_TOOLS_MAX_BYTES | Maximum byte size of the JSON tools payload stored per Custom MCP Server row (after stringify). Rejects oversized payloads returned by remote MCP servers. Set to `0` to disable the check. | Number | 524288 (512 KB) | -| CUSTOM_MCP_AUTHORIZE_TIMEOUT_MS | Maximum time in milliseconds to wait for the MCP server handshake during authorize. Bounds the request so a slow/tarpit upstream cannot tie up the HTTP worker indefinitely. Minimum 1000. | Number | 15000 | -| DEBUG | Print logs from components | Boolean | | -| LOG_PATH | Location where log files are stored | String | `your-path/Flowise/logs` | -| LOG_LEVEL | Different levels of logs | Enum String: `error`, `info`, `verbose`, `debug` | `info` | -| LOG_JSON_SPACES | Spaces to beautify JSON logs | | 2 | -| TOOL_FUNCTION_BUILTIN_DEP | NodeJS built-in modules to be used for Custom Tool or Function | String | | -| TOOL_FUNCTION_EXTERNAL_DEP | External modules to be used for Custom Tool or Function | String | | -| ALLOW_BUILTIN_DEP | Allow project dependencies to be used for Custom Tool or Function | Boolean | false | -| DATABASE_TYPE | Type of database to store the flowise data | Enum String: `sqlite`, `mysql`, `postgres` | `sqlite` | -| DATABASE_PATH | Location where database is saved (When DATABASE_TYPE is sqlite) | String | `your-home-dir/.flowise` | -| DATABASE_HOST | Host URL or IP address (When DATABASE_TYPE is not sqlite) | String | | -| DATABASE_PORT | Database port (When DATABASE_TYPE is not sqlite) | String | | -| DATABASE_USER | Database username (When DATABASE_TYPE is not sqlite) | String | | -| DATABASE_PASSWORD | Database password (When DATABASE_TYPE is not sqlite) | String | | -| DATABASE_NAME | Database name (When DATABASE_TYPE is not sqlite) | String | | -| DATABASE_SSL_KEY_BASE64 | Database SSL client cert in base64 (takes priority over DATABASE_SSL) | Boolean | false | -| DATABASE_SSL | Database connection overssl (When DATABASE_TYPE is postgre) | Boolean | false | -| SECRETKEY_PATH | Location where encryption key (used to encrypt/decrypt credentials) is saved | String | `your-path/Flowise/packages/server` | -| FLOWISE_SECRETKEY_OVERWRITE | Encryption key to be used instead of the key stored in SECRETKEY_PATH | String | | -| MODEL_LIST_CONFIG_JSON | File path to load list of models from your local config file | String | `/your_model_list_config_file_path` | -| STORAGE_TYPE | Type of storage for uploaded files. default is `local` | Enum String: `s3`, `local`, `gcs` ,`azure` | `local` | -| BLOB_STORAGE_PATH | Local folder path where uploaded files are stored when `STORAGE_TYPE` is `local` | String | `your-home-dir/.flowise/storage` | -| S3_STORAGE_BUCKET_NAME | Bucket name to hold the uploaded files when `STORAGE_TYPE` is `s3` | String | | -| S3_STORAGE_ACCESS_KEY_ID | AWS Access Key | String | | -| S3_STORAGE_SECRET_ACCESS_KEY | AWS Secret Key | String | | -| S3_STORAGE_REGION | Region for S3 bucket | String | | -| S3_ENDPOINT_URL | Custom Endpoint for S3 | String | | -| S3_FORCE_PATH_STYLE | Set this to true to force the request to use path-style addressing | Boolean | false | -| GOOGLE_CLOUD_STORAGE_PROJ_ID | The GCP project id for cloud storage & logging when `STORAGE_TYPE` is `gcs` | String | | -| GOOGLE_CLOUD_STORAGE_CREDENTIAL | The credential key file path when `STORAGE_TYPE` is `gcs` | String | | -| GOOGLE_CLOUD_STORAGE_BUCKET_NAME | Bucket name to hold the uploaded files when `STORAGE_TYPE` is `gcs` | String | | -| GOOGLE_CLOUD_UNIFORM_BUCKET_ACCESS | Enable uniform bucket level access when `STORAGE_TYPE` is `gcs` | Boolean | true | -| AZURE_BLOB_STORAGE_CONNECTION_STRING | Azure Blob Storage connection string when `STORAGE_TYPE` is `azure`. Either this or account name + key is required | String | | -| AZURE_BLOB_STORAGE_ACCOUNT_NAME | Azure storage account name when `STORAGE_TYPE` is `azure`. Required if connection string is not provided | String | | -| AZURE_BLOB_STORAGE_ACCOUNT_KEY | Azure storage account key when `STORAGE_TYPE` is `azure`. Required if connection string is not provided | String | | -| AZURE_BLOB_STORAGE_CONTAINER_NAME | Container name to hold the uploaded files when `STORAGE_TYPE` is `azure` | String | | -| SHOW_COMMUNITY_NODES | Show nodes created by community | Boolean | | -| DISABLED_NODES | Hide nodes from UI (comma separated list of node names) | String | | -| TRUST_PROXY | Configure proxy trust settings for proper IP detection. Values: 'true' (trust all), 'false' (disable), number (hop count), or Express proxy values (e.g., 'loopback', 'linklocal', 'uniquelocal', IP addresses). [Learn More](https://expressjs.com/en/guide/behind-proxies.html) | Boolean/String/Number | true | +| Variable | Description | Type | Default | +| ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | ----------------------------------- | +| PORT | The HTTP port Flowise runs on | Number | 3000 | +| CORS_ALLOW_CREDENTIALS | Enables CORS `Access-Control-Allow-Credentials` when `true` | Boolean | false | +| CORS_ORIGINS | The allowed origins for all cross-origin HTTP calls | String | | +| MCP_CORS_ORIGINS | The allowed origins for MCP endpoint cross-origin calls. If unset, only non-browser (no Origin header) requests are allowed. Set to `*` to allow all origins. | String | | +| IFRAME_ORIGINS | The allowed origins for iframe src embedding | String | | +| FLOWISE_FILE_SIZE_LIMIT | Upload File Size Limit | String | 50mb | +| CUSTOM_MCP_TOOLS_MAX_BYTES | Maximum byte size of the JSON tools payload stored per Custom MCP Server row (after stringify). Rejects oversized payloads returned by remote MCP servers. Set to `0` to disable the check. | Number | 524288 (512 KB) | +| CUSTOM_MCP_AUTHORIZE_TIMEOUT_MS | Maximum time in milliseconds to wait for the MCP server handshake during authorize. Bounds the request so a slow/tarpit upstream cannot tie up the HTTP worker indefinitely. Minimum 1000. | Number | 15000 | +| DEBUG | Print logs from components | Boolean | | +| LOG_PATH | Location where log files are stored | String | `your-path/Flowise/logs` | +| LOG_LEVEL | Different levels of logs | Enum String: `error`, `info`, `verbose`, `debug` | `info` | +| LOG_JSON_SPACES | Spaces to beautify JSON logs | | 2 | +| TOOL_FUNCTION_BUILTIN_DEP | NodeJS built-in modules to be used for Custom Tool or Function | String | | +| TOOL_FUNCTION_EXTERNAL_DEP | External modules to be used for Custom Tool or Function | String | | +| ALLOW_BUILTIN_DEP | Allow project dependencies to be used for Custom Tool or Function | Boolean | false | +| DATABASE_TYPE | Type of database to store the flowise data | Enum String: `sqlite`, `mysql`, `postgres` | `sqlite` | +| DATABASE_PATH | Location where database is saved (When DATABASE_TYPE is sqlite) | String | `your-home-dir/.flowise` | +| DATABASE_HOST | Host URL or IP address (When DATABASE_TYPE is not sqlite) | String | | +| DATABASE_PORT | Database port (When DATABASE_TYPE is not sqlite) | String | | +| DATABASE_USER | Database username (When DATABASE_TYPE is not sqlite) | String | | +| DATABASE_PASSWORD | Database password (When DATABASE_TYPE is not sqlite) | String | | +| DATABASE_NAME | Database name (When DATABASE_TYPE is not sqlite) | String | | +| DATABASE_SSL_KEY_BASE64 | Database SSL client cert in base64 (takes priority over DATABASE_SSL) | Boolean | false | +| DATABASE_SSL | Database connection overssl (When DATABASE_TYPE is postgre) | Boolean | false | +| SECRETKEY_PATH | Location where encryption key (used to encrypt/decrypt credentials) is saved | String | `your-path/Flowise/packages/server` | +| FLOWISE_SECRETKEY_OVERWRITE | Encryption key to be used instead of the key stored in SECRETKEY_PATH | String | | +| MODEL_LIST_CONFIG_JSON | File path to load list of models from your local config file | String | `/your_model_list_config_file_path` | +| STORAGE_TYPE | Type of storage for uploaded files. default is `local` | Enum String: `s3`, `local`, `gcs` ,`azure` | `local` | +| BLOB_STORAGE_PATH | Local folder path where uploaded files are stored when `STORAGE_TYPE` is `local` | String | `your-home-dir/.flowise/storage` | +| S3_STORAGE_BUCKET_NAME | Bucket name to hold the uploaded files when `STORAGE_TYPE` is `s3` | String | | +| S3_STORAGE_ACCESS_KEY_ID | AWS Access Key | String | | +| S3_STORAGE_SECRET_ACCESS_KEY | AWS Secret Key | String | | +| S3_STORAGE_REGION | Region for S3 bucket | String | | +| S3_ENDPOINT_URL | Custom Endpoint for S3 | String | | +| S3_FORCE_PATH_STYLE | Set this to true to force the request to use path-style addressing | Boolean | false | +| GOOGLE_CLOUD_STORAGE_PROJ_ID | The GCP project id for cloud storage & logging when `STORAGE_TYPE` is `gcs` | String | | +| GOOGLE_CLOUD_STORAGE_CREDENTIAL | The credential key file path when `STORAGE_TYPE` is `gcs` | String | | +| GOOGLE_CLOUD_STORAGE_BUCKET_NAME | Bucket name to hold the uploaded files when `STORAGE_TYPE` is `gcs` | String | | +| GOOGLE_CLOUD_UNIFORM_BUCKET_ACCESS | Enable uniform bucket level access when `STORAGE_TYPE` is `gcs` | Boolean | true | +| AZURE_BLOB_STORAGE_CONNECTION_STRING | Azure Blob Storage connection string when `STORAGE_TYPE` is `azure`. Either this or account name + key is required | String | | +| AZURE_BLOB_STORAGE_ACCOUNT_NAME | Azure storage account name when `STORAGE_TYPE` is `azure`. Required if connection string is not provided | String | | +| AZURE_BLOB_STORAGE_ACCOUNT_KEY | Azure storage account key when `STORAGE_TYPE` is `azure`. Required if connection string is not provided | String | | +| AZURE_BLOB_STORAGE_CONTAINER_NAME | Container name to hold the uploaded files when `STORAGE_TYPE` is `azure` | String | | +| SHOW_COMMUNITY_NODES | Show nodes created by community | Boolean | | +| DISABLED_NODES | Hide nodes from UI (comma separated list of node names) | String | | +| TRUST_PROXY | Configure proxy trust settings for proper IP detection. Values: 'true' (trust all), 'false' (disable), number (hop count), or Express proxy values (e.g., 'loopback', 'linklocal', 'uniquelocal', IP addresses). [Learn More](https://expressjs.com/en/guide/behind-proxies.html) | Boolean/String/Number | true | +| HTTP_ALLOW_LIST | Comma-separated list of hostnames that bypass the SSRF deny list. Entries are exact, case-insensitive hostname matches (no wildcards). Use this to allow connections to Docker-internal or other trusted private services (e.g. `mcp-server,internal-api`) without disabling global SSRF protection via `HTTP_SECURITY_CHECK=false` | String | | You can also specify the env variables when using `npx`. For example: diff --git a/packages/components/src/httpSecurity.test.ts b/packages/components/src/httpSecurity.test.ts index 6276dec7aed..a5dd4fd30ab 100644 --- a/packages/components/src/httpSecurity.test.ts +++ b/packages/components/src/httpSecurity.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, it } from '@jest/globals' -import { isDeniedIP } from './httpSecurity' +import { describe, expect, it, beforeEach, afterEach, jest } from '@jest/globals' +import { isDeniedIP, checkDenyList, secureFetch } from './httpSecurity' // Test deny list covering common SSRF targets const TEST_DENY_LIST = [ @@ -395,3 +395,122 @@ describe('isDeniedIP - SSRF Protection', () => { }) }) }) + +jest.mock('dns/promises', () => ({ + lookup: jest.fn() +})) + +describe('checkDenyList - HTTP_ALLOW_LIST', () => { + const dnsMock = jest.requireMock('dns/promises') as { lookup: jest.Mock } + + beforeEach(() => { + delete process.env.HTTP_ALLOW_LIST + delete process.env.HTTP_SECURITY_CHECK + // Default: resolve to a Docker-internal private IP + dnsMock.lookup.mockResolvedValue([{ address: '172.18.0.2', family: 4 }] as never) + }) + + afterEach(() => { + jest.clearAllMocks() + delete process.env.HTTP_ALLOW_LIST + delete process.env.HTTP_SECURITY_CHECK + }) + + it('blocks Docker-internal hostname when no allow list is set', async () => { + await expect(checkDenyList('http://mcp-server:8000/mcp')).rejects.toThrow() + }) + + it('allows Docker-internal hostname when it is in HTTP_ALLOW_LIST', async () => { + process.env.HTTP_ALLOW_LIST = 'mcp-server' + await expect(checkDenyList('http://mcp-server:8000/mcp')).resolves.toBeUndefined() + }) + + it('allows one of multiple entries in HTTP_ALLOW_LIST', async () => { + process.env.HTTP_ALLOW_LIST = 'other-service,mcp-server,yet-another' + await expect(checkDenyList('http://mcp-server:8000/mcp')).resolves.toBeUndefined() + }) + + it('does not allow a hostname not present in HTTP_ALLOW_LIST', async () => { + process.env.HTTP_ALLOW_LIST = 'other-service' + await expect(checkDenyList('http://mcp-server:8000/mcp')).rejects.toThrow() + }) + + it('allow list does not bypass the deny list for unrelated private hostnames', async () => { + process.env.HTTP_ALLOW_LIST = 'mcp-server' + dnsMock.lookup.mockResolvedValue([{ address: '10.0.0.1', family: 4 }] as never) + // different-host is not in the allow list, so it must still be blocked + await expect(checkDenyList('http://different-host/path')).rejects.toThrow() + }) + + it('trims whitespace around entries in HTTP_ALLOW_LIST', async () => { + process.env.HTTP_ALLOW_LIST = ' mcp-server , other ' + await expect(checkDenyList('http://mcp-server:8000/mcp')).resolves.toBeUndefined() + }) + + it('still blocks private IPs directly when HTTP_SECURITY_CHECK is enabled', async () => { + // Direct IP, no allow list entry matching it + await expect(checkDenyList('http://172.18.0.2/')).rejects.toThrow() + }) + + it('skips DNS lookup entirely for allowed hostnames', async () => { + process.env.HTTP_ALLOW_LIST = 'mcp-server' + await checkDenyList('http://mcp-server:8000/mcp') + expect(dnsMock.lookup).not.toHaveBeenCalled() + }) + + it('matches case-insensitively (URL hostnames are always lowercase)', async () => { + process.env.HTTP_ALLOW_LIST = 'MCP-Server' + await expect(checkDenyList('http://mcp-server:8000/mcp')).resolves.toBeUndefined() + }) +}) + +jest.mock('node-fetch', () => jest.fn()) + +describe('resolveAndValidate - HTTP_ALLOW_LIST (via secureFetch)', () => { + const dnsMock = jest.requireMock('dns/promises') as { lookup: jest.Mock } + const fetchMock = jest.requireMock('node-fetch') as jest.Mock + + beforeEach(() => { + delete process.env.HTTP_ALLOW_LIST + delete process.env.HTTP_SECURITY_CHECK + // Hostnames resolve to a Docker-internal private IP by default + dnsMock.lookup.mockResolvedValue([{ address: '172.18.0.2', family: 4 }] as never) + fetchMock.mockReset() + fetchMock.mockResolvedValue({ + status: 200, + headers: { get: () => null } + } as never) + }) + + afterEach(() => { + jest.clearAllMocks() + delete process.env.HTTP_ALLOW_LIST + delete process.env.HTTP_SECURITY_CHECK + }) + + it('blocks a Docker-internal hostname when no allow list is set', async () => { + await expect(secureFetch('http://mcp-server:8000/mcp')).rejects.toThrow() + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('allows a Docker-internal hostname when it is in HTTP_ALLOW_LIST', async () => { + process.env.HTTP_ALLOW_LIST = 'mcp-server' + // DNS lookup still runs — resolveAndValidate needs the IP to build a pinned agent — + // but the resolved private IP is not deny-checked because the hostname is allowed. + await secureFetch('http://mcp-server:8000/mcp') + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(dnsMock.lookup).toHaveBeenCalledTimes(1) + }) + + it('allows a direct private IP when it is in HTTP_ALLOW_LIST', async () => { + process.env.HTTP_ALLOW_LIST = '172.18.0.2' + await secureFetch('http://172.18.0.2:8000/mcp') + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('still blocks a direct private IP that is not in the allow list', async () => { + process.env.HTTP_ALLOW_LIST = 'mcp-server' + await expect(secureFetch('http://172.18.0.2:8000/mcp')).rejects.toThrow() + expect(fetchMock).not.toHaveBeenCalled() + }) +}) diff --git a/packages/components/src/httpSecurity.ts b/packages/components/src/httpSecurity.ts index 9f09e000ab1..d356263934e 100644 --- a/packages/components/src/httpSecurity.ts +++ b/packages/components/src/httpSecurity.ts @@ -48,6 +48,23 @@ function getHttpDenyList(): string[] { return customList } +/** + * Gets the HTTP allow list from HTTP_ALLOW_LIST env variable. + * Hostnames in this list bypass the deny list check, allowing connections to + * internal services (e.g. Docker Compose siblings) without disabling SSRF + * protection globally. Entries are exact hostname matches (no wildcards) and + * are normalized to lowercase to align with URL hostname parsing. + * @returns Array of hostnames explicitly allowed + */ +function getHttpAllowList(): string[] { + const raw = process.env.HTTP_ALLOW_LIST + if (!raw) return [] + return raw + .split(',') + .map((s) => s.trim().toLowerCase()) + .filter(Boolean) +} + /** * Checks if an IP address is in the deny list * @param ip - IP address to check @@ -114,12 +131,16 @@ export function isDeniedIP(ip: string, denyList: string[]): void { } /** - * Checks if a URL is allowed based on HTTP_DENY_LIST environment variable. + * Checks if a URL is allowed based on HTTP_DENY_LIST / HTTP_ALLOW_LIST environment variables. + * Hostnames present in HTTP_ALLOW_LIST bypass the deny list entirely, which lets + * Docker-internal or other trusted private services connect without disabling + * global SSRF protection via HTTP_SECURITY_CHECK=false. * @param url - URL to check * @throws Error if URL hostname resolves to a denied IP */ export async function checkDenyList(url: string): Promise { const httpDenyList = getHttpDenyList() + const httpAllowList = getHttpAllowList() const urlObj = new URL(url) let hostname = urlObj.hostname @@ -128,6 +149,9 @@ export async function checkDenyList(url: string): Promise { hostname = hostname.slice(1, -1) } + // Explicitly allowed hostnames bypass the deny list + if (httpAllowList.includes(hostname)) return + if (ipaddr.isValid(hostname)) { isDeniedIP(hostname, httpDenyList) } else { @@ -294,6 +318,7 @@ type ResolvedTarget = { async function resolveAndValidate(url: string): Promise { const denyList = getHttpDenyList() + const allowList = getHttpAllowList() const u = new URL(url) let hostname = u.hostname @@ -303,8 +328,11 @@ async function resolveAndValidate(url: string): Promise { } const protocol: 'http' | 'https' = u.protocol === 'https:' ? 'https' : 'http' + // Explicitly allowed hostnames bypass the deny list + const isAllowed = allowList.includes(hostname) + if (ipaddr.isValid(hostname)) { - isDeniedIP(hostname, denyList) + if (!isAllowed) isDeniedIP(hostname, denyList) return { hostname, ip: hostname, @@ -318,8 +346,10 @@ async function resolveAndValidate(url: string): Promise { throw new Error(`DNS resolution failed for ${hostname}`) } - for (const r of records) { - isDeniedIP(r.address, denyList) + if (!isAllowed) { + for (const r of records) { + isDeniedIP(r.address, denyList) + } } const chosen = records.find((r) => r.family === 4) ?? records[0] diff --git a/packages/server/.env.example b/packages/server/.env.example index 28a89414dd4..2ab4637e243 100644 --- a/packages/server/.env.example +++ b/packages/server/.env.example @@ -193,6 +193,7 @@ JWT_REFRESH_TOKEN_EXPIRY_IN_MINUTES=43200 # HTTP_DENY_LIST= # HTTP_SECURITY_CHECK=true +# HTTP_ALLOW_LIST= # Comma-separated hostnames that bypass the deny list. Exact, case-insensitive matches only, no wildcards (e.g. Docker Compose service names like "mcp-server,internal-api") # PATH_TRAVERSAL_SAFETY=true # CUSTOM_MCP_SECURITY_CHECK=true # CUSTOM_MCP_PROTOCOL=sse #(stdio | sse) 'stdio' can run arbitrary commands on your server, enable only if you trust all users From fdb8ea338887138b3157b054b035fbcfa6281614 Mon Sep 17 00:00:00 2001 From: Laraib Waheed Date: Wed, 15 Jul 2026 10:00:38 +0500 Subject: [PATCH 2/2] Normalize HTTP_ALLOW_LIST entries: strip ports and IPv6 brackets Users naturally copy the "host:port" or "[::1]" form from connection strings into HTTP_ALLOW_LIST, but URL.hostname strips ports and brackets before the allow list is checked, so those entries would silently never match. Normalize entries in getHttpAllowList to match what the URL parser produces: - "host:port" -> "host" - "[::1]" -> "::1" - "[::1]:8080" -> "::1" - bare IPv6 (e.g. "fe80::1") is left as-is (guarded by ipaddr.isValid to avoid truncating on ":" like the port-stripping branch would) Adds 5 tests covering each normalized form and the bare-IPv6 non-mutation case. Updates .env.example and CONTRIBUTING.md to document the accepted formats. Addresses review feedback on #6627. Co-Authored-By: Claude Opus 4.7 --- CONTRIBUTING.md | 98 ++++++++++---------- packages/components/src/httpSecurity.test.ts | 25 +++++ packages/components/src/httpSecurity.ts | 27 +++++- packages/server/.env.example | 2 +- 4 files changed, 101 insertions(+), 51 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ef5c636cd5e..28d6c0d58f0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -181,55 +181,55 @@ If in doubt, use `type: 'password'`. The only cost is that the field must be re- Flowise support different environment variables to configure your instance. You can specify the following variables in the `.env` file inside `packages/server` folder. Read [more](https://docs.flowiseai.com/configuration/environment-variables) -| Variable | Description | Type | Default | -| ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | ----------------------------------- | -| PORT | The HTTP port Flowise runs on | Number | 3000 | -| CORS_ALLOW_CREDENTIALS | Enables CORS `Access-Control-Allow-Credentials` when `true` | Boolean | false | -| CORS_ORIGINS | The allowed origins for all cross-origin HTTP calls | String | | -| MCP_CORS_ORIGINS | The allowed origins for MCP endpoint cross-origin calls. If unset, only non-browser (no Origin header) requests are allowed. Set to `*` to allow all origins. | String | | -| IFRAME_ORIGINS | The allowed origins for iframe src embedding | String | | -| FLOWISE_FILE_SIZE_LIMIT | Upload File Size Limit | String | 50mb | -| CUSTOM_MCP_TOOLS_MAX_BYTES | Maximum byte size of the JSON tools payload stored per Custom MCP Server row (after stringify). Rejects oversized payloads returned by remote MCP servers. Set to `0` to disable the check. | Number | 524288 (512 KB) | -| CUSTOM_MCP_AUTHORIZE_TIMEOUT_MS | Maximum time in milliseconds to wait for the MCP server handshake during authorize. Bounds the request so a slow/tarpit upstream cannot tie up the HTTP worker indefinitely. Minimum 1000. | Number | 15000 | -| DEBUG | Print logs from components | Boolean | | -| LOG_PATH | Location where log files are stored | String | `your-path/Flowise/logs` | -| LOG_LEVEL | Different levels of logs | Enum String: `error`, `info`, `verbose`, `debug` | `info` | -| LOG_JSON_SPACES | Spaces to beautify JSON logs | | 2 | -| TOOL_FUNCTION_BUILTIN_DEP | NodeJS built-in modules to be used for Custom Tool or Function | String | | -| TOOL_FUNCTION_EXTERNAL_DEP | External modules to be used for Custom Tool or Function | String | | -| ALLOW_BUILTIN_DEP | Allow project dependencies to be used for Custom Tool or Function | Boolean | false | -| DATABASE_TYPE | Type of database to store the flowise data | Enum String: `sqlite`, `mysql`, `postgres` | `sqlite` | -| DATABASE_PATH | Location where database is saved (When DATABASE_TYPE is sqlite) | String | `your-home-dir/.flowise` | -| DATABASE_HOST | Host URL or IP address (When DATABASE_TYPE is not sqlite) | String | | -| DATABASE_PORT | Database port (When DATABASE_TYPE is not sqlite) | String | | -| DATABASE_USER | Database username (When DATABASE_TYPE is not sqlite) | String | | -| DATABASE_PASSWORD | Database password (When DATABASE_TYPE is not sqlite) | String | | -| DATABASE_NAME | Database name (When DATABASE_TYPE is not sqlite) | String | | -| DATABASE_SSL_KEY_BASE64 | Database SSL client cert in base64 (takes priority over DATABASE_SSL) | Boolean | false | -| DATABASE_SSL | Database connection overssl (When DATABASE_TYPE is postgre) | Boolean | false | -| SECRETKEY_PATH | Location where encryption key (used to encrypt/decrypt credentials) is saved | String | `your-path/Flowise/packages/server` | -| FLOWISE_SECRETKEY_OVERWRITE | Encryption key to be used instead of the key stored in SECRETKEY_PATH | String | | -| MODEL_LIST_CONFIG_JSON | File path to load list of models from your local config file | String | `/your_model_list_config_file_path` | -| STORAGE_TYPE | Type of storage for uploaded files. default is `local` | Enum String: `s3`, `local`, `gcs` ,`azure` | `local` | -| BLOB_STORAGE_PATH | Local folder path where uploaded files are stored when `STORAGE_TYPE` is `local` | String | `your-home-dir/.flowise/storage` | -| S3_STORAGE_BUCKET_NAME | Bucket name to hold the uploaded files when `STORAGE_TYPE` is `s3` | String | | -| S3_STORAGE_ACCESS_KEY_ID | AWS Access Key | String | | -| S3_STORAGE_SECRET_ACCESS_KEY | AWS Secret Key | String | | -| S3_STORAGE_REGION | Region for S3 bucket | String | | -| S3_ENDPOINT_URL | Custom Endpoint for S3 | String | | -| S3_FORCE_PATH_STYLE | Set this to true to force the request to use path-style addressing | Boolean | false | -| GOOGLE_CLOUD_STORAGE_PROJ_ID | The GCP project id for cloud storage & logging when `STORAGE_TYPE` is `gcs` | String | | -| GOOGLE_CLOUD_STORAGE_CREDENTIAL | The credential key file path when `STORAGE_TYPE` is `gcs` | String | | -| GOOGLE_CLOUD_STORAGE_BUCKET_NAME | Bucket name to hold the uploaded files when `STORAGE_TYPE` is `gcs` | String | | -| GOOGLE_CLOUD_UNIFORM_BUCKET_ACCESS | Enable uniform bucket level access when `STORAGE_TYPE` is `gcs` | Boolean | true | -| AZURE_BLOB_STORAGE_CONNECTION_STRING | Azure Blob Storage connection string when `STORAGE_TYPE` is `azure`. Either this or account name + key is required | String | | -| AZURE_BLOB_STORAGE_ACCOUNT_NAME | Azure storage account name when `STORAGE_TYPE` is `azure`. Required if connection string is not provided | String | | -| AZURE_BLOB_STORAGE_ACCOUNT_KEY | Azure storage account key when `STORAGE_TYPE` is `azure`. Required if connection string is not provided | String | | -| AZURE_BLOB_STORAGE_CONTAINER_NAME | Container name to hold the uploaded files when `STORAGE_TYPE` is `azure` | String | | -| SHOW_COMMUNITY_NODES | Show nodes created by community | Boolean | | -| DISABLED_NODES | Hide nodes from UI (comma separated list of node names) | String | | -| TRUST_PROXY | Configure proxy trust settings for proper IP detection. Values: 'true' (trust all), 'false' (disable), number (hop count), or Express proxy values (e.g., 'loopback', 'linklocal', 'uniquelocal', IP addresses). [Learn More](https://expressjs.com/en/guide/behind-proxies.html) | Boolean/String/Number | true | -| HTTP_ALLOW_LIST | Comma-separated list of hostnames that bypass the SSRF deny list. Entries are exact, case-insensitive hostname matches (no wildcards). Use this to allow connections to Docker-internal or other trusted private services (e.g. `mcp-server,internal-api`) without disabling global SSRF protection via `HTTP_SECURITY_CHECK=false` | String | | +| Variable | Description | Type | Default | +| ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | ----------------------------------- | +| PORT | The HTTP port Flowise runs on | Number | 3000 | +| CORS_ALLOW_CREDENTIALS | Enables CORS `Access-Control-Allow-Credentials` when `true` | Boolean | false | +| CORS_ORIGINS | The allowed origins for all cross-origin HTTP calls | String | | +| MCP_CORS_ORIGINS | The allowed origins for MCP endpoint cross-origin calls. If unset, only non-browser (no Origin header) requests are allowed. Set to `*` to allow all origins. | String | | +| IFRAME_ORIGINS | The allowed origins for iframe src embedding | String | | +| FLOWISE_FILE_SIZE_LIMIT | Upload File Size Limit | String | 50mb | +| CUSTOM_MCP_TOOLS_MAX_BYTES | Maximum byte size of the JSON tools payload stored per Custom MCP Server row (after stringify). Rejects oversized payloads returned by remote MCP servers. Set to `0` to disable the check. | Number | 524288 (512 KB) | +| CUSTOM_MCP_AUTHORIZE_TIMEOUT_MS | Maximum time in milliseconds to wait for the MCP server handshake during authorize. Bounds the request so a slow/tarpit upstream cannot tie up the HTTP worker indefinitely. Minimum 1000. | Number | 15000 | +| DEBUG | Print logs from components | Boolean | | +| LOG_PATH | Location where log files are stored | String | `your-path/Flowise/logs` | +| LOG_LEVEL | Different levels of logs | Enum String: `error`, `info`, `verbose`, `debug` | `info` | +| LOG_JSON_SPACES | Spaces to beautify JSON logs | | 2 | +| TOOL_FUNCTION_BUILTIN_DEP | NodeJS built-in modules to be used for Custom Tool or Function | String | | +| TOOL_FUNCTION_EXTERNAL_DEP | External modules to be used for Custom Tool or Function | String | | +| ALLOW_BUILTIN_DEP | Allow project dependencies to be used for Custom Tool or Function | Boolean | false | +| DATABASE_TYPE | Type of database to store the flowise data | Enum String: `sqlite`, `mysql`, `postgres` | `sqlite` | +| DATABASE_PATH | Location where database is saved (When DATABASE_TYPE is sqlite) | String | `your-home-dir/.flowise` | +| DATABASE_HOST | Host URL or IP address (When DATABASE_TYPE is not sqlite) | String | | +| DATABASE_PORT | Database port (When DATABASE_TYPE is not sqlite) | String | | +| DATABASE_USER | Database username (When DATABASE_TYPE is not sqlite) | String | | +| DATABASE_PASSWORD | Database password (When DATABASE_TYPE is not sqlite) | String | | +| DATABASE_NAME | Database name (When DATABASE_TYPE is not sqlite) | String | | +| DATABASE_SSL_KEY_BASE64 | Database SSL client cert in base64 (takes priority over DATABASE_SSL) | Boolean | false | +| DATABASE_SSL | Database connection overssl (When DATABASE_TYPE is postgre) | Boolean | false | +| SECRETKEY_PATH | Location where encryption key (used to encrypt/decrypt credentials) is saved | String | `your-path/Flowise/packages/server` | +| FLOWISE_SECRETKEY_OVERWRITE | Encryption key to be used instead of the key stored in SECRETKEY_PATH | String | | +| MODEL_LIST_CONFIG_JSON | File path to load list of models from your local config file | String | `/your_model_list_config_file_path` | +| STORAGE_TYPE | Type of storage for uploaded files. default is `local` | Enum String: `s3`, `local`, `gcs` ,`azure` | `local` | +| BLOB_STORAGE_PATH | Local folder path where uploaded files are stored when `STORAGE_TYPE` is `local` | String | `your-home-dir/.flowise/storage` | +| S3_STORAGE_BUCKET_NAME | Bucket name to hold the uploaded files when `STORAGE_TYPE` is `s3` | String | | +| S3_STORAGE_ACCESS_KEY_ID | AWS Access Key | String | | +| S3_STORAGE_SECRET_ACCESS_KEY | AWS Secret Key | String | | +| S3_STORAGE_REGION | Region for S3 bucket | String | | +| S3_ENDPOINT_URL | Custom Endpoint for S3 | String | | +| S3_FORCE_PATH_STYLE | Set this to true to force the request to use path-style addressing | Boolean | false | +| GOOGLE_CLOUD_STORAGE_PROJ_ID | The GCP project id for cloud storage & logging when `STORAGE_TYPE` is `gcs` | String | | +| GOOGLE_CLOUD_STORAGE_CREDENTIAL | The credential key file path when `STORAGE_TYPE` is `gcs` | String | | +| GOOGLE_CLOUD_STORAGE_BUCKET_NAME | Bucket name to hold the uploaded files when `STORAGE_TYPE` is `gcs` | String | | +| GOOGLE_CLOUD_UNIFORM_BUCKET_ACCESS | Enable uniform bucket level access when `STORAGE_TYPE` is `gcs` | Boolean | true | +| AZURE_BLOB_STORAGE_CONNECTION_STRING | Azure Blob Storage connection string when `STORAGE_TYPE` is `azure`. Either this or account name + key is required | String | | +| AZURE_BLOB_STORAGE_ACCOUNT_NAME | Azure storage account name when `STORAGE_TYPE` is `azure`. Required if connection string is not provided | String | | +| AZURE_BLOB_STORAGE_ACCOUNT_KEY | Azure storage account key when `STORAGE_TYPE` is `azure`. Required if connection string is not provided | String | | +| AZURE_BLOB_STORAGE_CONTAINER_NAME | Container name to hold the uploaded files when `STORAGE_TYPE` is `azure` | String | | +| SHOW_COMMUNITY_NODES | Show nodes created by community | Boolean | | +| DISABLED_NODES | Hide nodes from UI (comma separated list of node names) | String | | +| TRUST_PROXY | Configure proxy trust settings for proper IP detection. Values: 'true' (trust all), 'false' (disable), number (hop count), or Express proxy values (e.g., 'loopback', 'linklocal', 'uniquelocal', IP addresses). [Learn More](https://expressjs.com/en/guide/behind-proxies.html) | Boolean/String/Number | true | +| HTTP_ALLOW_LIST | Comma-separated list of hostnames that bypass the SSRF deny list. Entries are exact, case-insensitive hostname matches (no wildcards); ports and IPv6 brackets are stripped, so `mcp-server:8000` and `[::1]:8080` are accepted and normalized. Use this to allow connections to Docker-internal or other trusted private services (e.g. `mcp-server,internal-api`) without disabling global SSRF protection via `HTTP_SECURITY_CHECK=false` | String | | You can also specify the env variables when using `npx`. For example: diff --git a/packages/components/src/httpSecurity.test.ts b/packages/components/src/httpSecurity.test.ts index a5dd4fd30ab..40944c4507a 100644 --- a/packages/components/src/httpSecurity.test.ts +++ b/packages/components/src/httpSecurity.test.ts @@ -462,6 +462,31 @@ describe('checkDenyList - HTTP_ALLOW_LIST', () => { process.env.HTTP_ALLOW_LIST = 'MCP-Server' await expect(checkDenyList('http://mcp-server:8000/mcp')).resolves.toBeUndefined() }) + + it('strips port from hostname:port entries (URL parser strips ports too)', async () => { + process.env.HTTP_ALLOW_LIST = 'mcp-server:8000' + await expect(checkDenyList('http://mcp-server:8000/mcp')).resolves.toBeUndefined() + }) + + it('strips port from ipv4:port entries', async () => { + process.env.HTTP_ALLOW_LIST = '172.18.0.2:8080' + await expect(checkDenyList('http://172.18.0.2:8080/')).resolves.toBeUndefined() + }) + + it('strips brackets from bare IPv6 entries like [::1]', async () => { + process.env.HTTP_ALLOW_LIST = '[::1]' + await expect(checkDenyList('http://[::1]/')).resolves.toBeUndefined() + }) + + it('strips brackets and port from IPv6-with-port entries like [::1]:8080', async () => { + process.env.HTTP_ALLOW_LIST = '[::1]:8080' + await expect(checkDenyList('http://[::1]:8080/')).resolves.toBeUndefined() + }) + + it('leaves bare IPv6 addresses (e.g. fe80::1) untouched', async () => { + process.env.HTTP_ALLOW_LIST = 'fe80::1' + await expect(checkDenyList('http://[fe80::1]/')).resolves.toBeUndefined() + }) }) jest.mock('node-fetch', () => jest.fn()) diff --git a/packages/components/src/httpSecurity.ts b/packages/components/src/httpSecurity.ts index d356263934e..e68e232fd59 100644 --- a/packages/components/src/httpSecurity.ts +++ b/packages/components/src/httpSecurity.ts @@ -54,6 +54,13 @@ function getHttpDenyList(): string[] { * internal services (e.g. Docker Compose siblings) without disabling SSRF * protection globally. Entries are exact hostname matches (no wildcards) and * are normalized to lowercase to align with URL hostname parsing. + * + * Common formats users copy from connection strings are normalized before + * matching so they line up with the hostname the URL parser produces: + * - `host:port` → `host` (port stripped; the allow list is hostname-scoped) + * - `[::1]` / `[::1]:8080` → `::1` (IPv6 brackets stripped, port stripped) + * - bare IPv4/IPv6 (e.g. `172.18.0.2`, `fe80::1`) are left as-is + * * @returns Array of hostnames explicitly allowed */ function getHttpAllowList(): string[] { @@ -61,7 +68,25 @@ function getHttpAllowList(): string[] { if (!raw) return [] return raw .split(',') - .map((s) => s.trim().toLowerCase()) + .map((s) => { + let trimmed = s.trim().toLowerCase() + if (trimmed.startsWith('[') && trimmed.endsWith(']')) { + // Bracketed IPv6 with no port: [::1] + trimmed = trimmed.slice(1, -1) + } else if (trimmed.startsWith('[') && trimmed.includes(']')) { + // Bracketed IPv6 with port: [::1]:8080 + const closeBracketIndex = trimmed.indexOf(']') + trimmed = trimmed.slice(1, closeBracketIndex) + } else if (!ipaddr.isValid(trimmed) && trimmed.includes(':')) { + // hostname:port or ipv4:port — the isValid guard avoids + // truncating bare IPv6 addresses like fe80::1 + const parts = trimmed.split(':') + if (parts.length === 2) { + trimmed = parts[0] + } + } + return trimmed + }) .filter(Boolean) } diff --git a/packages/server/.env.example b/packages/server/.env.example index 2ab4637e243..becfa59fae8 100644 --- a/packages/server/.env.example +++ b/packages/server/.env.example @@ -193,7 +193,7 @@ JWT_REFRESH_TOKEN_EXPIRY_IN_MINUTES=43200 # HTTP_DENY_LIST= # HTTP_SECURITY_CHECK=true -# HTTP_ALLOW_LIST= # Comma-separated hostnames that bypass the deny list. Exact, case-insensitive matches only, no wildcards (e.g. Docker Compose service names like "mcp-server,internal-api") +# HTTP_ALLOW_LIST= # Comma-separated hostnames that bypass the deny list. Exact, case-insensitive matches only, no wildcards. Ports and IPv6 brackets are stripped, so "mcp-server:8000" and "[::1]:8080" are accepted (e.g. Docker Compose service names like "mcp-server,internal-api") # PATH_TRAVERSAL_SAFETY=true # CUSTOM_MCP_SECURITY_CHECK=true # CUSTOM_MCP_PROTOCOL=sse #(stdio | sse) 'stdio' can run arbitrary commands on your server, enable only if you trust all users