From 4ea391204a499fb6d19747104502362295b4dde3 Mon Sep 17 00:00:00 2001 From: Ilango Date: Mon, 27 Jul 2026 17:40:21 +0530 Subject: [PATCH] fix: flowise-598 (#6665) * update: user and permissions in docker files * fix: sql database chain node --- docker/Dockerfile | 6 +++ docker/README.md | 10 ++-- docker/docker-compose-queue-prebuilt.yml | 11 +++-- docker/docker-compose-queue-source.yml | 27 ++++++----- docker/docker-compose.yml | 4 +- docker/worker/Dockerfile | 6 +++ docker/worker/docker-compose.yml | 4 +- .../SqlDatabaseChain/SqlDatabaseChain.ts | 17 ++++++- packages/components/src/validator.test.ts | 47 +++++++++++++++++++ packages/components/src/validator.ts | 40 ++++++++++++++++ 10 files changed, 148 insertions(+), 24 deletions(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index c2a6b20ebe4..47b317668d6 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -28,4 +28,10 @@ ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser COPY --from=build /usr/local/lib/node_modules /usr/local/lib/node_modules COPY --from=build /usr/local/bin /usr/local/bin +# Give the node user ownership of the installed package (node:*-alpine already has this user) +RUN chown -R node:node /usr/local/lib/node_modules /usr/local/bin + +# Switch to non-root user +USER node + ENTRYPOINT ["flowise", "start"] diff --git a/docker/README.md b/docker/README.md index bcadd93d55f..eeb48a54a9a 100644 --- a/docker/README.md +++ b/docker/README.md @@ -13,13 +13,15 @@ Starts Flowise from [DockerHub Image](https://hub.docker.com/r/flowiseai/flowise If you like to persist your data (flows, logs, credentials, storage), set these variables in the `.env` file inside `docker` folder: -- DATABASE_PATH=/root/.flowise -- LOG_PATH=/root/.flowise/logs -- SECRETKEY_PATH=/root/.flowise -- BLOB_STORAGE_PATH=/root/.flowise/storage +- DATABASE_PATH=/home/node/.flowise +- LOG_PATH=/home/node/.flowise/logs +- SECRETKEY_PATH=/home/node/.flowise +- BLOB_STORAGE_PATH=/home/node/.flowise/storage Flowise also support different environment variables to configure your instance. Read [more](https://docs.flowiseai.com/configuration/environment-variables) +> The container runs as the non-root `node` user (uid 1000), whose home directory is `/home/node`. If you bind-mount a host directory (e.g. `~/.flowise`) for persistence, make sure it's writable by that user - on Linux hosts this may require `chown -R 1000:1000 ~/.flowise`. + ## Queue Mode: ### Building from source: diff --git a/docker/docker-compose-queue-prebuilt.yml b/docker/docker-compose-queue-prebuilt.yml index d5bf6a0af3d..e9488b58758 100644 --- a/docker/docker-compose-queue-prebuilt.yml +++ b/docker/docker-compose-queue-prebuilt.yml @@ -19,11 +19,13 @@ services: ports: - '${PORT:-3000}:${PORT:-3000}' volumes: - - ~/.flowise:/root/.flowise + # Container runs as the non-root `node` user, whose home is /home/node. + # Ensure the host directory is writable by that user (e.g. `chown -R 1000:1000 ~/.flowise` on Linux hosts). + - ~/.flowise:/home/node/.flowise environment: # --- Essential Flowise Vars --- - PORT=${PORT:-3000} - - DATABASE_PATH=${DATABASE_PATH:-/root/.flowise} + - DATABASE_PATH=${DATABASE_PATH:-/home/node/.flowise} - DATABASE_TYPE=${DATABASE_TYPE} - DATABASE_PORT=${DATABASE_PORT} - DATABASE_HOST=${DATABASE_HOST} @@ -183,11 +185,12 @@ services: container_name: flowise-worker restart: always volumes: - - ~/.flowise:/root/.flowise + # Container runs as the non-root `node` user, whose home is /home/node. + - ~/.flowise:/home/node/.flowise environment: # --- Essential Flowise Vars --- - WORKER_PORT=${WORKER_PORT:-5566} - - DATABASE_PATH=${DATABASE_PATH:-/root/.flowise} + - DATABASE_PATH=${DATABASE_PATH:-/home/node/.flowise} - DATABASE_TYPE=${DATABASE_TYPE} - DATABASE_PORT=${DATABASE_PORT} - DATABASE_HOST=${DATABASE_HOST} diff --git a/docker/docker-compose-queue-source.yml b/docker/docker-compose-queue-source.yml index a95608e5cfc..66c3aba8410 100644 --- a/docker/docker-compose-queue-source.yml +++ b/docker/docker-compose-queue-source.yml @@ -19,15 +19,17 @@ services: ports: - '${PORT}:${PORT}' volumes: - # Mount local .flowise to container's default location - - ../.flowise:/root/.flowise + # Mount local .flowise to container's default location. + # Container runs as the non-root `node` user, whose home is /home/node. + # Ensure the host directory is writable by that user (e.g. `chown -R 1000:1000 ../.flowise` on Linux hosts). + - ../.flowise:/home/node/.flowise environment: # --- Essential Flowise Vars --- - PORT=${PORT:-3000} - - DATABASE_PATH=/root/.flowise - - SECRETKEY_PATH=/root/.flowise - - LOG_PATH=/root/.flowise/logs - - BLOB_STORAGE_PATH=/root/.flowise/storage + - DATABASE_PATH=/home/node/.flowise + - SECRETKEY_PATH=/home/node/.flowise + - LOG_PATH=/home/node/.flowise/logs + - BLOB_STORAGE_PATH=/home/node/.flowise/storage # --- Queue Vars (Main Instance) --- - MODE=queue - QUEUE_NAME=flowise-queue # Ensure this matches worker @@ -43,15 +45,16 @@ services: context: .. # Build context is still the root dockerfile: docker/worker/Dockerfile # Ensure this path is correct volumes: - # Mount same local .flowise to worker - - ../.flowise:/root/.flowise + # Mount same local .flowise to worker. + # Container runs as the non-root `node` user, whose home is /home/node. + - ../.flowise:/home/node/.flowise environment: # --- Essential Flowise Vars --- - WORKER_PORT=${WORKER_PORT:-5566} # Port for worker healthcheck - - DATABASE_PATH=/root/.flowise - - SECRETKEY_PATH=/root/.flowise - - LOG_PATH=/root/.flowise/logs - - BLOB_STORAGE_PATH=/root/.flowise/storage + - DATABASE_PATH=/home/node/.flowise + - SECRETKEY_PATH=/home/node/.flowise + - LOG_PATH=/home/node/.flowise/logs + - BLOB_STORAGE_PATH=/home/node/.flowise/storage # --- Queue Vars (Main Instance) --- - MODE=queue - QUEUE_NAME=flowise-queue # Ensure this matches worker diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index d6b9f3f4305..ec3ff244655 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -160,5 +160,7 @@ services: retries: 5 start_period: 30s volumes: - - ~/.flowise:/root/.flowise + # Container now runs as the non-root `node` user, whose home is /home/node. + # Ensure the host directory is writable by that user (e.g. `chown -R 1000:1000 ~/.flowise` on Linux hosts). + - ~/.flowise:/home/node/.flowise entrypoint: /bin/sh -c "sleep 3; flowise start" diff --git a/docker/worker/Dockerfile b/docker/worker/Dockerfile index ffe60283236..f8a43e83e90 100644 --- a/docker/worker/Dockerfile +++ b/docker/worker/Dockerfile @@ -45,5 +45,11 @@ ENV WORKER_PORT=5566 # Expose port (can be overridden by env var) EXPOSE ${WORKER_PORT} +# Give the node user ownership of the application files (node:*-alpine already has this user) +RUN chown -R node:node /usr/src /app + +# Switch to non-root user +USER node + # Start healthcheck in background and flowise worker in foreground CMD ["/bin/sh", "-c", "node /app/healthcheck/healthcheck.js & sleep 5 && pnpm run start-worker"] diff --git a/docker/worker/docker-compose.yml b/docker/worker/docker-compose.yml index ec3d45dd0c8..76a4bad25e4 100644 --- a/docker/worker/docker-compose.yml +++ b/docker/worker/docker-compose.yml @@ -159,5 +159,7 @@ services: retries: 5 start_period: 30s volumes: - - ~/.flowise:/root/.flowise + # Container now runs as the non-root `node` user, whose home is /home/node. + # Ensure the host directory is writable by that user (e.g. `chown -R 1000:1000 ~/.flowise` on Linux hosts). + - ~/.flowise:/home/node/.flowise entrypoint: /bin/sh -c "node /app/healthcheck/healthcheck.js & sleep 5 && pnpm run start-worker" diff --git a/packages/components/nodes/chains/SqlDatabaseChain/SqlDatabaseChain.ts b/packages/components/nodes/chains/SqlDatabaseChain/SqlDatabaseChain.ts index 87fe9d65a9a..250d63c05e8 100644 --- a/packages/components/nodes/chains/SqlDatabaseChain/SqlDatabaseChain.ts +++ b/packages/components/nodes/chains/SqlDatabaseChain/SqlDatabaseChain.ts @@ -7,7 +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 { assertReadOnlySqlStatement, validateSQLitePath } from '../../../src/validator' import { checkInputs, Moderation, streamResponse } from '../../moderation/Moderation' import { formatResponse } from '../../outputparsers/OutputParserHelpers' @@ -67,7 +67,9 @@ class SqlDatabaseChain_Chains implements INode { label: 'Connection string or file path (sqlite only)', name: 'url', type: 'string', - placeholder: '127.0.0.1:5432/chinook' + placeholder: '127.0.0.1:5432/chinook', + warning: + 'This chain executes LLM-generated SQL directly against the database. For SQLite, only point this at a trusted, disposable database file — queries are restricted to read-only SELECT/WITH statements, but the underlying file should not be shared with other sensitive data.' }, { label: 'Include Tables', @@ -232,6 +234,17 @@ const getSQLDBChain = async ( } as DataSourceOptions) ) + if (databaseType === 'sqlite') { + // Restrict every query run against this connection - including langchain's own + // schema introspection and the LLM-generated query - to a single read-only + // SELECT/WITH statement. See assertReadOnlySqlStatement for why. + const originalQuery = datasource.query.bind(datasource) + datasource.query = (async (sql: string, parameters?: any[], queryRunner?: any) => { + assertReadOnlySqlStatement(sql) + return originalQuery(sql, parameters, queryRunner) + }) as typeof datasource.query + } + const db = await SqlDatabase.fromDataSourceParams({ appDataSource: datasource, includesTables: includesTables, diff --git a/packages/components/src/validator.test.ts b/packages/components/src/validator.test.ts index 751a6f679eb..eab114d985c 100644 --- a/packages/components/src/validator.test.ts +++ b/packages/components/src/validator.test.ts @@ -1,4 +1,5 @@ import { + assertReadOnlySqlStatement, getSafeFilePath, isPathTraversal, isUnsafeFilePath, @@ -796,3 +797,49 @@ describe('getSafeFilePath', () => { }) }) }) + +describe('assertReadOnlySqlStatement', () => { + describe('accepts read-only statements', () => { + it.each([ + ['simple select', 'SELECT * FROM users'], + ['select with trailing semicolon', 'SELECT id FROM users;'], + ['lowercase select', 'select id from users'], + ['select with where clause', "SELECT id FROM users WHERE name = 'bob'"], + ['cte with with clause', 'WITH t AS (SELECT 1) SELECT * FROM t'], + [ + 'sqlite introspection query using pragma_table_info as a table-valued function', + "SELECT m.name AS table_name, p.name AS column_name FROM sqlite_master m JOIN pragma_table_info(m.name) p WHERE m.type = 'table'" + ] + ])('should not throw for %s', (_desc, sql) => { + expect(() => assertReadOnlySqlStatement(sql)).not.toThrow() + }) + }) + + describe('rejects write / dangerous statements', () => { + it.each([ + ['attach database', "ATTACH DATABASE '/etc/chromium/exploit.conf' AS x"], + ['vacuum into', "VACUUM INTO '/etc/chromium/exploit.conf'"], + ['bare pragma statement', 'PRAGMA journal_mode=WAL'], + ['stacked statements', 'SELECT 1; DROP TABLE users'], + ['stacked statements with attach', "SELECT 1; ATTACH DATABASE '/etc/chromium/exploit.conf' AS x"], + ['insert', "INSERT INTO users (name) VALUES ('evil')"], + ['update', "UPDATE users SET name = 'evil'"], + ['delete', 'DELETE FROM users'], + ['drop table', 'DROP TABLE users'], + ['load_extension call', "SELECT load_extension('/tmp/evil.so')"], + ['load_extension call mixed case', "SELECT LOAD_EXTENSION('/tmp/evil.so')"] + ])('should throw for %s', (_desc, sql) => { + expect(() => assertReadOnlySqlStatement(sql)).toThrow() + }) + }) + + describe('invalid input', () => { + it.each([ + ['empty string', ''], + ['undefined', undefined], + ['null', null] + ])('should throw for %s', (_desc, sql) => { + expect(() => assertReadOnlySqlStatement(sql as any)).toThrow() + }) + }) +}) diff --git a/packages/components/src/validator.ts b/packages/components/src/validator.ts index 8580293f542..5529173ea20 100644 --- a/packages/components/src/validator.ts +++ b/packages/components/src/validator.ts @@ -358,6 +358,46 @@ export const validateSQLitePath = (userProvidedPath: string | undefined): string return resolvedPath } +/** + * Restricts SQL executed against a SQLite database opened via validateSQLitePath to a + * single read-only SELECT/WITH statement. + * + * The Sql Database Chain hands LLM-generated SQL directly to TypeORM's raw query + * executor with no statement-type filtering. Without this guard, a compromised or + * malicious LLM response can run `ATTACH DATABASE`/`VACUUM INTO`/bare `PRAGMA` to write + * arbitrary files anywhere the process can write, bypassing validateSQLitePath (which + * only constrains the initial connection path, not queries run afterward). + * + * All legitimate queries issued against sqlite by this chain (including langchain's own + * schema introspection, which uses `pragma_table_info()` as a table-valued function + * inside a SELECT) are single SELECT/WITH statements, so this restriction does not + * affect normal operation. + * + * @param {string} sql The SQL statement to validate + * @throws {Error} If the statement is not a single read-only SELECT/WITH statement + */ +export const assertReadOnlySqlStatement = (sql: string): void => { + if (!sql || typeof sql !== 'string') { + throw new Error('Invalid SQL statement: statement is required and must be a string') + } + + let trimmed = sql.trim() + // Strip at most one trailing semicolon (+ trailing whitespace) + trimmed = trimmed.replace(/;\s*$/, '') + + if (trimmed.includes(';')) { + throw new Error('Invalid SQL statement: multiple statements are not allowed') + } + + if (!/^(SELECT|WITH)\b/i.test(trimmed)) { + throw new Error('Invalid SQL statement: only read-only SELECT/WITH statements are allowed') + } + + if (/load_extension\s*\(/i.test(trimmed)) { + throw new Error('Invalid SQL statement: load_extension is not allowed') + } +} + /** * Sanitize a file name to prevent path traversal attacks. * Strips common storage prefixes, extracts the basename, runs it through