Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docker/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ JWT_REFRESH_TOKEN_EXPIRY_IN_MINUTES=43200
# HTTP_DENY_LIST=
# HTTP_SECURITY_CHECK=true
# PATH_TRAVERSAL_SAFETY=true
# FLOWISE_ALLOWED_FOLDER_PATHS= # Comma-separated absolute paths Docker/self-hosted users may read with the Folder node (e.g. /data/docs,/mnt/shared). Prefer this over disabling PATH_TRAVERSAL_SAFETY entirely.
# 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)
Expand Down
40 changes: 40 additions & 0 deletions packages/components/src/validator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,46 @@ describe('isPathTraversal', () => {
})
})

describe('FLOWISE_ALLOWED_FOLDER_PATHS allows specific absolute paths', () => {
beforeEach(() => {
process.env.FLOWISE_ALLOWED_FOLDER_PATHS = '/data/documents,/mnt/shared'
})
afterEach(() => {
delete process.env.FLOWISE_ALLOWED_FOLDER_PATHS
})

it.each([
['exact allowed path', '/data/documents'],
['subdirectory of allowed path', '/data/documents/reports'],
['deeply nested subdirectory', '/data/documents/2024/q1/report.pdf'],
['second allowed path', '/mnt/shared'],
['subdirectory of second allowed path', '/mnt/shared/uploads']
])('should return false for %s when within an allowed base path', (_desc, input) => {
expect(isPathTraversal(input)).toBe(false)
})

it.each([
['path outside allowed list', '/etc/passwd'],
['path that is a prefix but not a child', '/data/documents2'],
['path with traversal into allowed dir', '/data/documents/../../../etc/passwd']
])('should still return true for %s even with allowed paths configured', (_desc, input) => {
expect(isPathTraversal(input)).toBe(true)
})
})

describe('FLOWISE_ALLOWED_FOLDER_PATHS with trailing slash normalisation', () => {
beforeEach(() => {
process.env.FLOWISE_ALLOWED_FOLDER_PATHS = '/data/documents/'
})
afterEach(() => {
delete process.env.FLOWISE_ALLOWED_FOLDER_PATHS
})

it('should allow a subdirectory of a path configured with trailing slash', () => {
expect(isPathTraversal('/data/documents/file.txt')).toBe(false)
})
})

describe('isUnsafeFilePath', () => {
describe('PATH_TRAVERSAL_SAFETY=false bypasses all checks', () => {
beforeEach(() => {
Expand Down
58 changes: 51 additions & 7 deletions packages/components/src/validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,34 +27,78 @@ export const isValidURL = (url: string): boolean => {
}
}

/**
* Returns the list of absolute Unix paths that are explicitly allowed for folder operations.
*
* Set FLOWISE_ALLOWED_FOLDER_PATHS to a comma-separated list of absolute paths, e.g.:
* FLOWISE_ALLOWED_FOLDER_PATHS=/data/documents,/mnt/shared
*
* Only absolute paths are accepted; relative entries are ignored.
* This is the targeted alternative to disabling all traversal safety with
* PATH_TRAVERSAL_SAFETY=false.
*/
const getAllowedFolderPaths = (): string[] => {
const raw = process.env.FLOWISE_ALLOWED_FOLDER_PATHS
if (!raw) return []
return raw
.split(',')
.map((p) => p.trim())
.filter((p) => p !== '' && path.isAbsolute(p))
.map((p) => {
const normalized = path.normalize(p)
// Strip trailing separator so startsWith checks work correctly,
// but never strip the root '/' itself.
return normalized.length > 1 && normalized.endsWith(path.sep) ? normalized.slice(0, -1) : normalized
})
}
Comment on lines +40 to +53
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The getAllowedFolderPaths function is called every time isPathTraversal encounters an absolute path. Since environment variables are typically static during the application's lifecycle, parsing the string, splitting, and normalizing paths on every call is inefficient. Consider caching the result.

const getAllowedFolderPaths = (() => {
    let cache: { raw: string | undefined; paths: string[] } | null = null
    return (): string[] => {
        const raw = process.env.FLOWISE_ALLOWED_FOLDER_PATHS
        if (cache && cache.raw === raw) return cache.paths
        const paths = (raw || '')
            .split(',')
            .map((p) => p.trim())
            .filter((p) => p !== '' && path.isAbsolute(p))
            .map((p) => {
                const normalized = path.normalize(p)
                return normalized.length > 1 && normalized.endsWith(path.sep) ? normalized.slice(0, -1) : normalized
            })
        cache = { raw, paths }
        return paths
    }
})()


/**
* Validates if a string contains path traversal attempts
* @param {string} path The string to validate
* @param {inputPath} inputPath The string to validate
* @returns {boolean} True if path traversal detected, false otherwise
*/
export const isPathTraversal = (path: string): boolean => {
export const isPathTraversal = (inputPath: string): boolean => {
// 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, '.'))) {
if (/\.\./.test(inputPath.replace(/%2e/gi, '.'))) {
return true
}

const dangerousPatterns = [
// These patterns are always dangerous regardless of FLOWISE_ALLOWED_FOLDER_PATHS
const alwaysDangerousPatterns = [
/%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.)
/^\\\\[^\\]/ // UNC paths (\\server\)
]

return dangerousPatterns.some((pattern) => pattern.test(path))
if (alwaysDangerousPatterns.some((pattern) => pattern.test(inputPath))) {
return true
}

// Absolute Unix paths are blocked by default.
// Exception: if the path starts with one of the entries in FLOWISE_ALLOWED_FOLDER_PATHS
// it is considered explicitly trusted by the operator and is allowed through.
if (/^\//.test(inputPath)) {
const allowedPaths = getAllowedFolderPaths()
if (allowedPaths.length > 0) {
const normalizedInput = path.normalize(inputPath)
const isExplicitlyAllowed = allowedPaths.some(
(allowed) => normalizedInput === allowed || normalizedInput.startsWith(allowed + path.sep)
)
Comment on lines +93 to +95
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

The current logic for checking subdirectories fails when the allowed path is the root directory (/). If allowed is /, allowed + path.sep becomes // (on Unix), and normalizedInput.startsWith('//') will be false for standard absolute paths like /etc/passwd. A more robust check for the root path is needed.

const isExplicitlyAllowed = allowedPaths.some((allowed) => {
    if (normalizedInput === allowed) return true
    const prefix = allowed === path.sep ? allowed : allowed + path.sep
    return normalizedInput.startsWith(prefix)
})

if (isExplicitlyAllowed) return false
}
return true
}

return false
}

/**
Expand Down
1 change: 1 addition & 0 deletions packages/server/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ JWT_REFRESH_TOKEN_EXPIRY_IN_MINUTES=43200
# HTTP_DENY_LIST=
# HTTP_SECURITY_CHECK=true
# PATH_TRAVERSAL_SAFETY=true
# FLOWISE_ALLOWED_FOLDER_PATHS= # Comma-separated absolute paths Docker/self-hosted users may read with the Folder node (e.g. /data/docs,/mnt/shared). Prefer this over disabling PATH_TRAVERSAL_SAFETY entirely.
# 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)
Expand Down