Skip to content

Commit aff0647

Browse files
christopherholland-workdaychristopherholland-workday
andauthored
Control Path Traversal Protections via Env Var (#5962)
* Fix isPathTraversal method to be less strict * Fix isPathTraversal method to be less strict * Control isPathTraversal via an Env Var * Path traversal check env var --------- Co-authored-by: christopherholland-workday <christopher.holland+evisort@workday.com>
1 parent 378d3ec commit aff0647

9 files changed

Lines changed: 146 additions & 9 deletions

File tree

docker/.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,7 @@ JWT_REFRESH_TOKEN_EXPIRY_IN_MINUTES=43200
190190

191191
# HTTP_DENY_LIST=
192192
# HTTP_SECURITY_CHECK=true
193+
# PATH_TRAVERSAL_SAFETY=true
193194
# CUSTOM_MCP_SECURITY_CHECK=true
194195
# CUSTOM_MCP_PROTOCOL=sse #(stdio | sse)
195196
# TRUST_PROXY=true #(true | false | 1 | loopback| linklocal | uniquelocal | IP addresses | loopback, IP addresses)

docker/docker-compose-queue-prebuilt.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@ services:
153153
- CUSTOM_MCP_PROTOCOL=${CUSTOM_MCP_PROTOCOL}
154154
- HTTP_DENY_LIST=${HTTP_DENY_LIST}
155155
- HTTP_SECURITY_CHECK=${HTTP_SECURITY_CHECK}
156+
- PATH_TRAVERSAL_SAFETY=${PATH_TRAVERSAL_SAFETY}
156157
- TRUST_PROXY=${TRUST_PROXY}
157158
healthcheck:
158159
test: ['CMD', 'curl', '-f', 'http://localhost:${PORT:-3000}/api/v1/ping']
@@ -300,6 +301,7 @@ services:
300301
- CUSTOM_MCP_PROTOCOL=${CUSTOM_MCP_PROTOCOL}
301302
- HTTP_DENY_LIST=${HTTP_DENY_LIST}
302303
- HTTP_SECURITY_CHECK=${HTTP_SECURITY_CHECK}
304+
- PATH_TRAVERSAL_SAFETY=${PATH_TRAVERSAL_SAFETY}
303305
- TRUST_PROXY=${TRUST_PROXY}
304306
healthcheck:
305307
test: ['CMD', 'curl', '-f', 'http://localhost:${WORKER_PORT:-5566}/healthz']

docker/docker-compose.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ services:
138138
- CUSTOM_MCP_PROTOCOL=${CUSTOM_MCP_PROTOCOL}
139139
- HTTP_DENY_LIST=${HTTP_DENY_LIST}
140140
- HTTP_SECURITY_CHECK=${HTTP_SECURITY_CHECK}
141+
- PATH_TRAVERSAL_SAFETY=${PATH_TRAVERSAL_SAFETY}
141142
- TRUST_PROXY=${TRUST_PROXY}
142143
ports:
143144
- '${PORT}:${PORT}'

docker/worker/.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,7 @@ JWT_REFRESH_TOKEN_EXPIRY_IN_MINUTES=43200
190190

191191
# HTTP_DENY_LIST=
192192
# HTTP_SECURITY_CHECK=true
193+
# PATH_TRAVERSAL_SAFETY=true
193194
# CUSTOM_MCP_SECURITY_CHECK=true
194195
# CUSTOM_MCP_PROTOCOL=sse #(stdio | sse)
195196
# TRUST_PROXY=true #(true | false | 1 | loopback| linklocal | uniquelocal | IP addresses | loopback, IP addresses)

docker/worker/docker-compose.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ services:
138138
- CUSTOM_MCP_PROTOCOL=${CUSTOM_MCP_PROTOCOL}
139139
- HTTP_DENY_LIST=${HTTP_DENY_LIST}
140140
- HTTP_SECURITY_CHECK=${HTTP_SECURITY_CHECK}
141+
- PATH_TRAVERSAL_SAFETY=${PATH_TRAVERSAL_SAFETY}
141142
- TRUST_PROXY=${TRUST_PROXY}
142143
ports:
143144
- '${WORKER_PORT}:${WORKER_PORT}'

packages/components/src/validator.ts

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,17 +33,28 @@ export const isValidURL = (url: string): boolean => {
3333
* @returns {boolean} True if path traversal detected, false otherwise
3434
*/
3535
export const isPathTraversal = (path: string): boolean => {
36-
// Check for common path traversal patterns
36+
// PATH_TRAVERSAL_SAFETY defaults to true; must be explicitly set to 'false' to disable
37+
if (process.env.PATH_TRAVERSAL_SAFETY === 'false') {
38+
return false
39+
}
40+
41+
// Normalize %2e → . before checking for .. to catch mixed-encoding bypasses
42+
// e.g. .%2e/, %2e./, %2e%2e all become ../
43+
if (/\.\./.test(path.replace(/%2e/gi, '.'))) {
44+
return true
45+
}
46+
3747
const dangerousPatterns = [
38-
'..', // Directory traversal
39-
'/', // Root directory
40-
'\\', // Windows root directory
41-
'%2e', // URL encoded .
42-
'%2f', // URL encoded /
43-
'%5c' // URL encoded \
48+
/%2f/i, // URL encoded /
49+
/%5c/i, // URL encoded \ (Windows path)
50+
/\0/, // Null bytes
51+
/%00/i, // URL encoded null byte
52+
/^\s*[a-zA-Z]:[/\\]/, // Windows absolute paths (C:\, C:/) with optional leading whitespace
53+
/^\\\\[^\\]/, // UNC paths (\\server\)
54+
/^\// // Absolute Unix paths (/etc, /data, /root, etc.)
4455
]
4556

46-
return dangerousPatterns.some((pattern) => path.toLowerCase().includes(pattern))
57+
return dangerousPatterns.some((pattern) => pattern.test(path))
4758
}
4859

4960
/**
@@ -52,6 +63,10 @@ export const isPathTraversal = (path: string): boolean => {
5263
* @returns {boolean} True if path traversal detected, false otherwise
5364
*/
5465
export const isUnsafeFilePath = (filePath: string): boolean => {
66+
if (process.env.PATH_TRAVERSAL_SAFETY === 'false') {
67+
return false
68+
}
69+
5570
if (!filePath || typeof filePath !== 'string') {
5671
return true
5772
}
@@ -191,6 +206,14 @@ const getAllowedVectorStoreBaseDirs = (): string[] => {
191206
* @throws {Error} If path validation fails or path is outside allowed directories
192207
*/
193208
export const validateVectorStorePath = (userProvidedPath: string | undefined): string => {
209+
if (process.env.PATH_TRAVERSAL_SAFETY === 'false') {
210+
if (!userProvidedPath || userProvidedPath.trim() === '') {
211+
return path.join(getUserHome(), '.flowise', 'vectorstore')
212+
}
213+
const bypassPath = userProvidedPath.trim()
214+
return path.isAbsolute(bypassPath) ? bypassPath : path.resolve(path.join(getUserHome(), '.flowise', bypassPath))
215+
}
216+
194217
// If no path provided, use default secure location
195218
if (!userProvidedPath || userProvidedPath.trim() === '') {
196219
return path.join(getUserHome(), '.flowise', 'vectorstore')

packages/components/test/validator.test.ts

Lines changed: 107 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,86 @@
1-
import { validateMimeTypeAndExtensionMatch, validateVectorStorePath } from '../src/validator'
1+
import { isPathTraversal, isUnsafeFilePath, validateMimeTypeAndExtensionMatch, validateVectorStorePath } from '../src/validator'
22
import path from 'path'
33
import { getUserHome } from '../src/utils'
44

5+
describe('isPathTraversal', () => {
6+
describe('returns true for dangerous patterns', () => {
7+
it.each([
8+
['directory traversal (..)', '../etc/passwd'],
9+
['multiple levels of traversal', '../../sensitive'],
10+
['bare double-dot', '..'],
11+
['Windows absolute path', 'C:\\windows'],
12+
['Windows absolute path with forward slash', 'C:/windows'],
13+
['Windows absolute path with leading whitespace', ' C:\\windows'],
14+
['UNC path', '\\\\server\\share'],
15+
['URL encoded dot (%2e)', '%2e%2e/etc'],
16+
['URL encoded dot uppercase (%2E)', '%2E%2E'],
17+
['mixed encoding (.%2e)', '.%2e/etc'],
18+
['mixed encoding (%2e.)', '%2e./etc'],
19+
['URL encoded forward slash (%2f)', '%2f'],
20+
['URL encoded forward slash uppercase (%2F)', '%2F'],
21+
['URL encoded backslash (%5c)', '%5c'],
22+
['URL encoded backslash uppercase (%5C)', '%5C'],
23+
['null byte', 'path\0name'],
24+
['URL encoded null byte (%00)', 'path%00name'],
25+
['absolute Unix path', '/etc/passwd'],
26+
['absolute Unix root', '/']
27+
])('should detect %s: %s', (_description, input) => {
28+
expect(isPathTraversal(input)).toBe(true)
29+
})
30+
})
31+
32+
describe('returns false for safe inputs', () => {
33+
it.each([
34+
['simple filename with extension', 'filename.txt'],
35+
['plain name without extension', 'myfile'],
36+
['empty string', ''],
37+
['name with underscores', 'hello_world'],
38+
['relative path with slash', 'uploads/file.txt']
39+
])('should not flag %s: %s', (_description, input) => {
40+
expect(isPathTraversal(input)).toBe(false)
41+
})
42+
})
43+
44+
describe('PATH_TRAVERSAL_SAFETY=false bypasses all checks', () => {
45+
beforeEach(() => {
46+
process.env.PATH_TRAVERSAL_SAFETY = 'false'
47+
})
48+
afterEach(() => {
49+
delete process.env.PATH_TRAVERSAL_SAFETY
50+
})
51+
52+
it.each([
53+
['absolute Unix path', '/data/uploads'],
54+
['mixed encoding', '.%2e/etc'],
55+
['directory traversal', '../etc/passwd'],
56+
['Windows absolute path', 'C:\\windows']
57+
])('should return false for %s when safety disabled', (_desc, input) => {
58+
expect(isPathTraversal(input)).toBe(false)
59+
})
60+
})
61+
})
62+
63+
describe('isUnsafeFilePath', () => {
64+
describe('PATH_TRAVERSAL_SAFETY=false bypasses all checks', () => {
65+
beforeEach(() => {
66+
process.env.PATH_TRAVERSAL_SAFETY = 'false'
67+
})
68+
afterEach(() => {
69+
delete process.env.PATH_TRAVERSAL_SAFETY
70+
})
71+
72+
it.each([
73+
['absolute Unix path', '/data/uploads'],
74+
['directory traversal', '../etc/passwd'],
75+
['Windows absolute path', 'C:\\windows'],
76+
['null byte', 'path\0name'],
77+
['control character', 'path\x01name']
78+
])('should return false for %s when safety disabled', (_desc, input) => {
79+
expect(isUnsafeFilePath(input)).toBe(false)
80+
})
81+
})
82+
})
83+
584
describe('validateMimeTypeAndExtensionMatch', () => {
685
describe('valid cases', () => {
786
it.each([
@@ -359,4 +438,31 @@ describe('validateVectorStorePath', () => {
359438
expect(result).toBe(path.normalize(mixedPath))
360439
})
361440
})
441+
442+
describe('PATH_TRAVERSAL_SAFETY=false bypasses all checks', () => {
443+
beforeEach(() => {
444+
process.env.PATH_TRAVERSAL_SAFETY = 'false'
445+
})
446+
afterEach(() => {
447+
delete process.env.PATH_TRAVERSAL_SAFETY
448+
})
449+
450+
it('should allow arbitrary absolute Unix path', () => {
451+
expect(validateVectorStorePath('/data/faiss-store')).toBe('/data/faiss-store')
452+
})
453+
454+
it('should allow path outside allowed directories (/tmp)', () => {
455+
expect(validateVectorStorePath('/tmp/mystore')).toBe('/tmp/mystore')
456+
})
457+
458+
it('should allow path containing .. without throwing', () => {
459+
const result = validateVectorStorePath('../mystore')
460+
expect(typeof result).toBe('string')
461+
})
462+
463+
it('should return default path when undefined', () => {
464+
const userHome = getUserHome()
465+
expect(validateVectorStorePath(undefined)).toBe(path.join(userHome, '.flowise', 'vectorstore'))
466+
})
467+
})
362468
})

packages/server/.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ JWT_REFRESH_TOKEN_EXPIRY_IN_MINUTES=43200
189189

190190
# HTTP_DENY_LIST=
191191
# HTTP_SECURITY_CHECK=true
192+
# PATH_TRAVERSAL_SAFETY=true
192193
# CUSTOM_MCP_SECURITY_CHECK=true
193194
# CUSTOM_MCP_PROTOCOL=sse #(stdio | sse)
194195
# TRUST_PROXY=true #(true | false | 1 | loopback| linklocal | uniquelocal | IP addresses | loopback, IP addresses)

packages/server/src/commands/base.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ export abstract class BaseCommand extends Command {
106106
CUSTOM_MCP_PROTOCOL: Flags.string(),
107107
HTTP_DENY_LIST: Flags.string(),
108108
HTTP_SECURITY_CHECK: Flags.string(),
109+
PATH_TRAVERSAL_SAFETY: Flags.string(),
109110
TRUST_PROXY: Flags.string(),
110111

111112
// Auth

0 commit comments

Comments
 (0)