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 <[email protected]>
This commit is contained in:
christopherholland-workday
2026-03-16 08:40:26 -07:00
committed by GitHub
co-authored by christopherholland-workday
parent 378d3ec3bd
commit aff06479aa
9 changed files with 146 additions and 9 deletions
+1
View File
@@ -190,6 +190,7 @@ JWT_REFRESH_TOKEN_EXPIRY_IN_MINUTES=43200
# HTTP_DENY_LIST=
# HTTP_SECURITY_CHECK=true
# PATH_TRAVERSAL_SAFETY=true
# CUSTOM_MCP_SECURITY_CHECK=true
# CUSTOM_MCP_PROTOCOL=sse #(stdio | sse)
# TRUST_PROXY=true #(true | false | 1 | loopback| linklocal | uniquelocal | IP addresses | loopback, IP addresses)
+2
View File
@@ -153,6 +153,7 @@ services:
- CUSTOM_MCP_PROTOCOL=${CUSTOM_MCP_PROTOCOL}
- HTTP_DENY_LIST=${HTTP_DENY_LIST}
- HTTP_SECURITY_CHECK=${HTTP_SECURITY_CHECK}
- PATH_TRAVERSAL_SAFETY=${PATH_TRAVERSAL_SAFETY}
- TRUST_PROXY=${TRUST_PROXY}
healthcheck:
test: ['CMD', 'curl', '-f', 'http://localhost:${PORT:-3000}/api/v1/ping']
@@ -300,6 +301,7 @@ services:
- CUSTOM_MCP_PROTOCOL=${CUSTOM_MCP_PROTOCOL}
- HTTP_DENY_LIST=${HTTP_DENY_LIST}
- HTTP_SECURITY_CHECK=${HTTP_SECURITY_CHECK}
- PATH_TRAVERSAL_SAFETY=${PATH_TRAVERSAL_SAFETY}
- TRUST_PROXY=${TRUST_PROXY}
healthcheck:
test: ['CMD', 'curl', '-f', 'http://localhost:${WORKER_PORT:-5566}/healthz']
+1
View File
@@ -138,6 +138,7 @@ services:
- CUSTOM_MCP_PROTOCOL=${CUSTOM_MCP_PROTOCOL}
- HTTP_DENY_LIST=${HTTP_DENY_LIST}
- HTTP_SECURITY_CHECK=${HTTP_SECURITY_CHECK}
- PATH_TRAVERSAL_SAFETY=${PATH_TRAVERSAL_SAFETY}
- TRUST_PROXY=${TRUST_PROXY}
ports:
- '${PORT}:${PORT}'
+1
View File
@@ -190,6 +190,7 @@ JWT_REFRESH_TOKEN_EXPIRY_IN_MINUTES=43200
# HTTP_DENY_LIST=
# HTTP_SECURITY_CHECK=true
# PATH_TRAVERSAL_SAFETY=true
# CUSTOM_MCP_SECURITY_CHECK=true
# CUSTOM_MCP_PROTOCOL=sse #(stdio | sse)
# TRUST_PROXY=true #(true | false | 1 | loopback| linklocal | uniquelocal | IP addresses | loopback, IP addresses)
+1
View File
@@ -138,6 +138,7 @@ services:
- CUSTOM_MCP_PROTOCOL=${CUSTOM_MCP_PROTOCOL}
- HTTP_DENY_LIST=${HTTP_DENY_LIST}
- HTTP_SECURITY_CHECK=${HTTP_SECURITY_CHECK}
- PATH_TRAVERSAL_SAFETY=${PATH_TRAVERSAL_SAFETY}
- TRUST_PROXY=${TRUST_PROXY}
ports:
- '${WORKER_PORT}:${WORKER_PORT}'
+31 -8
View File
@@ -33,17 +33,28 @@ export const isValidURL = (url: string): boolean => {
* @returns {boolean} True if path traversal detected, false otherwise
*/
export const isPathTraversal = (path: string): boolean => {
// Check for common path traversal patterns
// PATH_TRAVERSAL_SAFETY defaults to true; must be explicitly set to 'false' to disable
if (process.env.PATH_TRAVERSAL_SAFETY === 'false') {
return false
}
// Normalize %2e → . before checking for .. to catch mixed-encoding bypasses
// e.g. .%2e/, %2e./, %2e%2e all become ../
if (/\.\./.test(path.replace(/%2e/gi, '.'))) {
return true
}
const dangerousPatterns = [
'..', // Directory traversal
'/', // Root directory
'\\', // Windows root directory
'%2e', // URL encoded .
'%2f', // URL encoded /
'%5c' // URL encoded \
/%2f/i, // URL encoded /
/%5c/i, // URL encoded \ (Windows path)
/\0/, // Null bytes
/%00/i, // URL encoded null byte
/^\s*[a-zA-Z]:[/\\]/, // Windows absolute paths (C:\, C:/) with optional leading whitespace
/^\\\\[^\\]/, // UNC paths (\\server\)
/^\// // Absolute Unix paths (/etc, /data, /root, etc.)
]
return dangerousPatterns.some((pattern) => path.toLowerCase().includes(pattern))
return dangerousPatterns.some((pattern) => pattern.test(path))
}
/**
@@ -52,6 +63,10 @@ export const isPathTraversal = (path: string): boolean => {
* @returns {boolean} True if path traversal detected, false otherwise
*/
export const isUnsafeFilePath = (filePath: string): boolean => {
if (process.env.PATH_TRAVERSAL_SAFETY === 'false') {
return false
}
if (!filePath || typeof filePath !== 'string') {
return true
}
@@ -191,6 +206,14 @@ const getAllowedVectorStoreBaseDirs = (): string[] => {
* @throws {Error} If path validation fails or path is outside allowed directories
*/
export const validateVectorStorePath = (userProvidedPath: string | undefined): string => {
if (process.env.PATH_TRAVERSAL_SAFETY === 'false') {
if (!userProvidedPath || userProvidedPath.trim() === '') {
return path.join(getUserHome(), '.flowise', 'vectorstore')
}
const bypassPath = userProvidedPath.trim()
return path.isAbsolute(bypassPath) ? bypassPath : path.resolve(path.join(getUserHome(), '.flowise', bypassPath))
}
// If no path provided, use default secure location
if (!userProvidedPath || userProvidedPath.trim() === '') {
return path.join(getUserHome(), '.flowise', 'vectorstore')
+107 -1
View File
@@ -1,7 +1,86 @@
import { validateMimeTypeAndExtensionMatch, validateVectorStorePath } from '../src/validator'
import { isPathTraversal, isUnsafeFilePath, validateMimeTypeAndExtensionMatch, validateVectorStorePath } from '../src/validator'
import path from 'path'
import { getUserHome } from '../src/utils'
describe('isPathTraversal', () => {
describe('returns true for dangerous patterns', () => {
it.each([
['directory traversal (..)', '../etc/passwd'],
['multiple levels of traversal', '../../sensitive'],
['bare double-dot', '..'],
['Windows absolute path', 'C:\\windows'],
['Windows absolute path with forward slash', 'C:/windows'],
['Windows absolute path with leading whitespace', ' C:\\windows'],
['UNC path', '\\\\server\\share'],
['URL encoded dot (%2e)', '%2e%2e/etc'],
['URL encoded dot uppercase (%2E)', '%2E%2E'],
['mixed encoding (.%2e)', '.%2e/etc'],
['mixed encoding (%2e.)', '%2e./etc'],
['URL encoded forward slash (%2f)', '%2f'],
['URL encoded forward slash uppercase (%2F)', '%2F'],
['URL encoded backslash (%5c)', '%5c'],
['URL encoded backslash uppercase (%5C)', '%5C'],
['null byte', 'path\0name'],
['URL encoded null byte (%00)', 'path%00name'],
['absolute Unix path', '/etc/passwd'],
['absolute Unix root', '/']
])('should detect %s: %s', (_description, input) => {
expect(isPathTraversal(input)).toBe(true)
})
})
describe('returns false for safe inputs', () => {
it.each([
['simple filename with extension', 'filename.txt'],
['plain name without extension', 'myfile'],
['empty string', ''],
['name with underscores', 'hello_world'],
['relative path with slash', 'uploads/file.txt']
])('should not flag %s: %s', (_description, input) => {
expect(isPathTraversal(input)).toBe(false)
})
})
describe('PATH_TRAVERSAL_SAFETY=false bypasses all checks', () => {
beforeEach(() => {
process.env.PATH_TRAVERSAL_SAFETY = 'false'
})
afterEach(() => {
delete process.env.PATH_TRAVERSAL_SAFETY
})
it.each([
['absolute Unix path', '/data/uploads'],
['mixed encoding', '.%2e/etc'],
['directory traversal', '../etc/passwd'],
['Windows absolute path', 'C:\\windows']
])('should return false for %s when safety disabled', (_desc, input) => {
expect(isPathTraversal(input)).toBe(false)
})
})
})
describe('isUnsafeFilePath', () => {
describe('PATH_TRAVERSAL_SAFETY=false bypasses all checks', () => {
beforeEach(() => {
process.env.PATH_TRAVERSAL_SAFETY = 'false'
})
afterEach(() => {
delete process.env.PATH_TRAVERSAL_SAFETY
})
it.each([
['absolute Unix path', '/data/uploads'],
['directory traversal', '../etc/passwd'],
['Windows absolute path', 'C:\\windows'],
['null byte', 'path\0name'],
['control character', 'path\x01name']
])('should return false for %s when safety disabled', (_desc, input) => {
expect(isUnsafeFilePath(input)).toBe(false)
})
})
})
describe('validateMimeTypeAndExtensionMatch', () => {
describe('valid cases', () => {
it.each([
@@ -359,4 +438,31 @@ describe('validateVectorStorePath', () => {
expect(result).toBe(path.normalize(mixedPath))
})
})
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(validateVectorStorePath('/data/faiss-store')).toBe('/data/faiss-store')
})
it('should allow path outside allowed directories (/tmp)', () => {
expect(validateVectorStorePath('/tmp/mystore')).toBe('/tmp/mystore')
})
it('should allow path containing .. without throwing', () => {
const result = validateVectorStorePath('../mystore')
expect(typeof result).toBe('string')
})
it('should return default path when undefined', () => {
const userHome = getUserHome()
expect(validateVectorStorePath(undefined)).toBe(path.join(userHome, '.flowise', 'vectorstore'))
})
})
})
+1
View File
@@ -189,6 +189,7 @@ JWT_REFRESH_TOKEN_EXPIRY_IN_MINUTES=43200
# HTTP_DENY_LIST=
# HTTP_SECURITY_CHECK=true
# PATH_TRAVERSAL_SAFETY=true
# CUSTOM_MCP_SECURITY_CHECK=true
# CUSTOM_MCP_PROTOCOL=sse #(stdio | sse)
# TRUST_PROXY=true #(true | false | 1 | loopback| linklocal | uniquelocal | IP addresses | loopback, IP addresses)
+1
View File
@@ -106,6 +106,7 @@ export abstract class BaseCommand extends Command {
CUSTOM_MCP_PROTOCOL: Flags.string(),
HTTP_DENY_LIST: Flags.string(),
HTTP_SECURITY_CHECK: Flags.string(),
PATH_TRAVERSAL_SAFETY: Flags.string(),
TRUST_PROXY: Flags.string(),
// Auth