Skip to content

Commit 2414057

Browse files
taraka-vishnumolakalataraka-vishnumolakalaHenryHengZJ
authored
feat(security): enhance file path validation and implement non-root D… (#5474)
* feat(security): enhance file path validation and implement non-root Docker user - Validate resolved full file paths including workspace boundaries in SecureFileStore - Resolve paths before validation in readFile and writeFile operations - Run Docker container as non-root flowise user (uid/gid 1001) - Apply proper file ownership and permissions for application files Prevents path traversal attacks and follows container security best practices * Add sensitive system directory validation and Flowise internal file protection * Update Dockerfile to use default node user * update validation patterns to include additional system binary directories (/usr/bin, /usr/sbin, /usr/local/bin) * added isSafeBrowserExecutable function to validate browser executable paths for Playwright and Puppeteer loaders --------- Co-authored-by: taraka-vishnumolakala <taraka.vishnumolakala@workday.com> Co-authored-by: Henry Heng <henryheng@flowiseai.com> Co-authored-by: Henry <hzj94@hotmail.com>
1 parent 4a642f0 commit 2414057

5 files changed

Lines changed: 188 additions & 28 deletions

File tree

Dockerfile

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,34 +5,38 @@
55
# docker run -d -p 3000:3000 flowise
66

77
FROM node:20-alpine
8-
RUN apk add --update libc6-compat python3 make g++
9-
# needed for pdfjs-dist
10-
RUN apk add --no-cache build-base cairo-dev pango-dev
118

12-
# Install Chromium
13-
RUN apk add --no-cache chromium
14-
15-
# Install curl for container-level health checks
16-
# Fixes: https://github.com/FlowiseAI/Flowise/issues/4126
17-
RUN apk add --no-cache curl
18-
19-
#install PNPM globaly
20-
RUN npm install -g pnpm
9+
# Install system dependencies and build tools
10+
RUN apk update && \
11+
apk add --no-cache \
12+
libc6-compat \
13+
python3 \
14+
make \
15+
g++ \
16+
build-base \
17+
cairo-dev \
18+
pango-dev \
19+
chromium \
20+
curl && \
21+
npm install -g pnpm
2122

2223
ENV PUPPETEER_SKIP_DOWNLOAD=true
2324
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
2425

2526
ENV NODE_OPTIONS=--max-old-space-size=8192
2627

27-
WORKDIR /usr/src
28+
WORKDIR /usr/src/flowise
2829

2930
# Copy app source
3031
COPY . .
3132

32-
RUN pnpm install
33+
# Install dependencies and build
34+
RUN pnpm install && \
35+
pnpm build
3336

34-
RUN pnpm build
37+
# Switch to non-root user (node user already exists in node:20-alpine)
38+
USER node
3539

3640
EXPOSE 3000
3741

38-
CMD [ "pnpm", "start" ]
42+
CMD [ "pnpm", "start" ]

packages/components/nodes/documentloaders/Playwright/Playwright.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { test } from 'linkifyjs'
1010
import { omit } from 'lodash'
1111
import { handleEscapeCharacters, INodeOutputsValue, webCrawl, xmlScrape } from '../../../src'
1212
import { ICommonObject, INode, INodeData, INodeParams } from '../../../src/Interface'
13+
import { isSafeBrowserExecutable } from '../../../src/validator'
1314

1415
class Playwright_DocumentLoaders implements INode {
1516
label: string
@@ -190,11 +191,17 @@ class Playwright_DocumentLoaders implements INode {
190191
async function playwrightLoader(url: string): Promise<Document[] | undefined> {
191192
try {
192193
let docs = []
194+
195+
const executablePath = process.env.PLAYWRIGHT_EXECUTABLE_PATH
196+
if (!isSafeBrowserExecutable(executablePath)) {
197+
throw new Error(`Invalid or unsafe browser executable path: ${executablePath || 'undefined'}. `)
198+
}
199+
193200
const config: PlaywrightWebBaseLoaderOptions = {
194201
launchOptions: {
195202
args: ['--no-sandbox'],
196203
headless: true,
197-
executablePath: process.env.PLAYWRIGHT_EXECUTABLE_FILE_PATH
204+
executablePath: executablePath
198205
}
199206
}
200207
if (waitUntilGoToOption) {

packages/components/nodes/documentloaders/Puppeteer/Puppeteer.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { omit } from 'lodash'
66
import { PuppeteerLifeCycleEvent } from 'puppeteer'
77
import { handleEscapeCharacters, INodeOutputsValue, webCrawl, xmlScrape } from '../../../src'
88
import { ICommonObject, INode, INodeData, INodeParams } from '../../../src/Interface'
9+
import { isSafeBrowserExecutable } from '../../../src/validator'
910

1011
class Puppeteer_DocumentLoaders implements INode {
1112
label: string
@@ -181,11 +182,17 @@ class Puppeteer_DocumentLoaders implements INode {
181182
async function puppeteerLoader(url: string): Promise<Document[] | undefined> {
182183
try {
183184
let docs: Document[] = []
185+
186+
const executablePath = process.env.PUPPETEER_EXECUTABLE_PATH
187+
if (!isSafeBrowserExecutable(executablePath)) {
188+
throw new Error(`Invalid or unsafe browser executable path: ${executablePath || 'undefined'}. `)
189+
}
190+
184191
const config: PuppeteerWebBaseLoaderOptions = {
185192
launchOptions: {
186193
args: ['--no-sandbox'],
187194
headless: 'new',
188-
executablePath: process.env.PUPPETEER_EXECUTABLE_FILE_PATH
195+
executablePath: executablePath
189196
}
190197
}
191198
if (waitUntilGoToOption) {

packages/components/src/SecureFileStore.ts

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { Serializable } from '@langchain/core/load/serializable'
2+
import * as fs from 'fs'
23
import { NodeFileStore } from 'langchain/stores/file/node'
3-
import { isUnsafeFilePath, isWithinWorkspace } from './validator'
44
import * as path from 'path'
5-
import * as fs from 'fs'
5+
import { isSensitiveSystemPath, isUnsafeFilePath, isWithinWorkspace } from './validator'
66

77
/**
88
* Security configuration for file operations
@@ -65,28 +65,50 @@ export class SecureFileStore extends Serializable {
6565
throw new Error(`Workspace directory does not exist: ${this.config.workspacePath}`)
6666
}
6767

68+
// Validate that workspace path is not a sensitive system directory
69+
// This prevents setting workspace to /usr/bin, /etc, etc. which would allow access to system files
70+
if (isSensitiveSystemPath(path.normalize(this.config.workspacePath))) {
71+
throw new Error(`Workspace path cannot be set to sensitive system directory: ${this.config.workspacePath}`)
72+
}
73+
6874
// Initialize the underlying NodeFileStore with workspace path
6975
this.nodeFileStore = new NodeFileStore(this.config.workspacePath)
7076
}
7177

7278
/**
7379
* Validates a file path against security policies
80+
* @param filePath The raw user-provided file path (relative to workspace)
81+
* @param resolvedPath The resolved absolute path (for extension validation)
7482
*/
75-
private validateFilePath(filePath: string): void {
76-
// Check for unsafe path patterns
83+
private validateFilePath(filePath: string, resolvedPath: string): void {
84+
// Validate the raw user input for unsafe patterns (path traversal, absolute paths, etc.)
85+
// This must be done on the raw input, not the resolved path, because isUnsafeFilePath
86+
// is designed to detect absolute paths in user input
7787
if (isUnsafeFilePath(filePath)) {
7888
throw new Error(`Unsafe file path detected: ${filePath}`)
7989
}
8090

81-
// Enforce workspace boundaries if enabled
91+
// Enforce workspace boundaries if enabled (this handles path resolution internally)
8292
if (this.config.enforceWorkspaceBoundaries) {
8393
if (!isWithinWorkspace(filePath, this.config.workspacePath)) {
8494
throw new Error(`File path outside workspace boundaries: ${filePath}`)
8595
}
8696
}
8797

88-
// Check file extension
89-
const ext = path.extname(filePath).toLowerCase()
98+
// Prevent access to Flowise internal files (any path containing .flowise)
99+
const normalizedResolved = path.normalize(resolvedPath)
100+
if (normalizedResolved.includes('.flowise')) {
101+
throw new Error(`Access to Flowise internal files denied: ${filePath}`)
102+
}
103+
104+
// Validate that the resolved path does not access sensitive system directories
105+
// This prevents access to system files even if workspace is set to a system directory
106+
if (isSensitiveSystemPath(normalizedResolved)) {
107+
throw new Error(`Access to sensitive system directory denied: ${filePath}`)
108+
}
109+
110+
// Check file extension on the resolved path to get the actual extension
111+
const ext = path.extname(resolvedPath).toLowerCase()
90112

91113
// Check blocked extensions
92114
if (this.config.blockedExtensions.includes(ext)) {
@@ -113,7 +135,10 @@ export class SecureFileStore extends Serializable {
113135
* Reads a file with security validation
114136
*/
115137
async readFile(filePath: string): Promise<string> {
116-
this.validateFilePath(filePath)
138+
// Resolve the full path for extension validation
139+
const resolvedPath = path.resolve(this.config.workspacePath, filePath)
140+
// Validate the raw user input (not the resolved path) to avoid false positives
141+
this.validateFilePath(filePath, resolvedPath)
117142

118143
try {
119144
return await this.nodeFileStore.readFile(filePath)
@@ -127,12 +152,16 @@ export class SecureFileStore extends Serializable {
127152
* Writes a file with security validation
128153
*/
129154
async writeFile(filePath: string, contents: string): Promise<void> {
130-
this.validateFilePath(filePath)
131155
this.validateFileSize(contents)
132156

157+
// Resolve the full path for extension validation and directory creation
158+
const resolvedPath = path.resolve(this.config.workspacePath, filePath)
159+
// Validate the raw user input (not the resolved path) to avoid false positives
160+
this.validateFilePath(filePath, resolvedPath)
161+
133162
try {
134163
// Ensure the directory exists
135-
const dir = path.dirname(path.resolve(this.config.workspacePath, filePath))
164+
const dir = path.dirname(resolvedPath)
136165
if (!fs.existsSync(dir)) {
137166
fs.mkdirSync(dir, { recursive: true })
138167
}

packages/components/src/validator.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,35 @@ export const isUnsafeFilePath = (filePath: string): boolean => {
7070
return dangerousPatterns.some((pattern) => pattern.test(filePath))
7171
}
7272

73+
/**
74+
* Validates if a resolved path accesses sensitive system directories
75+
* Uses pattern-based detection to identify known sensitive system directories
76+
* at root level or one level deep, while allowing legitimate paths like /usr/src
77+
* @param {string} resolvedPath The resolved absolute path to validate
78+
* @returns {boolean} True if path accesses sensitive system directory, false otherwise
79+
*/
80+
export const isSensitiveSystemPath = (resolvedPath: string): boolean => {
81+
if (!resolvedPath || typeof resolvedPath !== 'string') {
82+
return false
83+
}
84+
85+
// Pattern-based detection for known sensitive system directories:
86+
// Blocks obvious system directories while allowing legitimate paths like /usr/src, /usr/local/src, /opt, etc.
87+
// 1. At root level (e.g., /etc, /sys, /bin, /sbin) - one segment after root
88+
// 2. One level deep (e.g., /etc/passwd, /sys/kernel, /var/log) - two segments total
89+
// 3. Specific sensitive subdirectories (e.g., /var/log, /var/run) - two segments with specific parent
90+
// 4. System binary directories (e.g., /usr/bin, /usr/sbin, /usr/local/bin) - prevents overwriting system executables
91+
const sensitiveSystemPatterns = [
92+
/^[/\\](etc|sys|proc|dev|boot|root|bin|sbin)([/\\]|$)/i, // Root level: /etc, /sys, /proc, /bin, /sbin, etc.
93+
/^[/\\](etc|sys|proc|dev|boot|root|bin|sbin)[/\\][^/\\]*$/i, // One level deep: /etc/passwd, /sys/kernel, /bin/sh, etc.
94+
/^[/\\]var[/\\](log|run|lib|spool|mail)([/\\]|$)/i, // Sensitive /var subdirectories: /var/log, /var/run, etc.
95+
/^[/\\]usr[/\\](bin|sbin)([/\\]|$)/i, // System binary directories: /usr/bin, /usr/sbin
96+
/^[/\\]usr[/\\]local[/\\](bin|sbin)([/\\]|$)/i // Local system binaries: /usr/local/bin, /usr/local/sbin
97+
]
98+
99+
return sensitiveSystemPatterns.some((pattern) => pattern.test(resolvedPath))
100+
}
101+
73102
/**
74103
* Validates if a file path is within the allowed workspace boundaries
75104
* @param {string} filePath The file path to validate
@@ -102,3 +131,87 @@ export const isWithinWorkspace = (filePath: string, workspacePath: string): bool
102131
return false
103132
}
104133
}
134+
135+
/**
136+
* Validates if a browser executable path is safe to use
137+
* Prevents arbitrary code execution through environment variable manipulation
138+
* @param {string} executablePath The browser executable path to validate
139+
* @returns {boolean} True if path is safe, false otherwise
140+
*/
141+
export const isSafeBrowserExecutable = (executablePath: string | undefined): boolean => {
142+
if (!executablePath) {
143+
return true // If not specified, let browser library use its default
144+
}
145+
146+
if (typeof executablePath !== 'string' || executablePath.trim() === '') {
147+
return false
148+
}
149+
150+
const path = require('path')
151+
const fs = require('fs')
152+
153+
try {
154+
// Normalize the path
155+
const normalizedPath = path.normalize(executablePath)
156+
157+
// Must be an absolute path
158+
if (!path.isAbsolute(normalizedPath)) {
159+
return false
160+
}
161+
162+
// Allowed browser executable locations (system-managed only)
163+
const allowedPaths = [
164+
// Linux/Unix Chromium/Chrome paths
165+
'/usr/bin/chromium',
166+
'/usr/bin/chromium-browser',
167+
'/usr/bin/google-chrome',
168+
'/usr/bin/google-chrome-stable',
169+
'/usr/bin/chrome',
170+
'/snap/bin/chromium',
171+
// macOS Chrome/Chromium paths
172+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
173+
'/Applications/Chromium.app/Contents/MacOS/Chromium',
174+
// Windows Chrome/Chromium paths (normalized with forward slashes)
175+
'C:/Program Files/Google/Chrome/Application/chrome.exe',
176+
'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
177+
'C:/Program Files/Chromium/Application/chrome.exe',
178+
// Firefox paths
179+
'/usr/bin/firefox',
180+
'/Applications/Firefox.app/Contents/MacOS/firefox',
181+
'C:/Program Files/Mozilla Firefox/firefox.exe',
182+
'C:/Program Files (x86)/Mozilla Firefox/firefox.exe'
183+
]
184+
185+
// Normalize allowed paths for comparison (handle Windows backslashes)
186+
const normalizedAllowedPaths = allowedPaths.map((p) => path.normalize(p))
187+
188+
// Check if the path exactly matches one of the allowed paths
189+
const isAllowedPath = normalizedAllowedPaths.some((allowedPath) => normalizedPath.toLowerCase() === allowedPath.toLowerCase())
190+
191+
if (!isAllowedPath) {
192+
return false
193+
}
194+
195+
// Additional security: Verify file exists and is executable (where applicable)
196+
// This prevents using a path before malicious file is written
197+
try {
198+
if (fs.existsSync(normalizedPath)) {
199+
const stats = fs.statSync(normalizedPath)
200+
// On Unix-like systems, check if file is executable
201+
if (process.platform !== 'win32') {
202+
// Check if file has execute permissions (using bitwise AND)
203+
// 0o111 checks for execute permission for user, group, or others
204+
return (stats.mode & 0o111) !== 0
205+
}
206+
return stats.isFile()
207+
}
208+
// If file doesn't exist, reject it (prevents race conditions)
209+
return false
210+
} catch {
211+
return false
212+
}
213+
} catch (error) {
214+
// If any error occurs during validation, deny access
215+
return false
216+
}
217+
}

0 commit comments

Comments
 (0)