-
Notifications
You must be signed in to change notification settings - Fork 51.5k
fix(core): Only resolve the filepath once #22767
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from 1 commit
c3f6350
5a7bf76
77d6244
ea4923a
671881a
ef47707
52be596
5dc9144
2e752b4
7140e57
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,9 +1,11 @@ | ||
| import { isContainedWithin, safeJoinPath } from '@n8n/backend-common'; | ||
| import { SecurityConfig } from '@n8n/config'; | ||
| import { Container } from '@n8n/di'; | ||
| import type { FileSystemHelperFunctions, INode } from 'n8n-workflow'; | ||
| import { NodeOperationError } from 'n8n-workflow'; | ||
| import { createReadStream } from 'node:fs'; | ||
| import type { PathLike } from 'node:fs'; | ||
| import { constants, createReadStream } from 'node:fs'; | ||
| import type { ResolvedFilePath } from 'n8n-workflow'; | ||
|
Check failure on line 8 in packages/core/src/execution-engine/node-execution-context/utils/file-system-helper-functions.ts
|
||
| import { | ||
| access as fsAccess, | ||
| writeFile as fsWriteFile, | ||
|
|
@@ -35,18 +37,19 @@ | |
| return allowedPaths; | ||
| }; | ||
|
|
||
| export async function isFilePathBlocked(filePath: string): Promise<boolean> { | ||
| const allowedPaths = getAllowedPaths(); | ||
| let resolvedFilePath = ''; | ||
| const resolvePath = async (path: PathLike): Promise<ResolvedFilePath> => { | ||
| try { | ||
| resolvedFilePath = await fsRealpath(filePath); | ||
| return (await fsRealpath(path)) as ResolvedFilePath; // apply brand, since we know it's resolved now | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Branded types seem like a smart way to enforce this 👍 |
||
| } catch (error: unknown) { | ||
| if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { | ||
| resolvedFilePath = resolve(filePath); | ||
| } else { | ||
| throw error; | ||
| return resolve(path.toString()) as ResolvedFilePath; // apply brand, since we know it's resolved now | ||
| } | ||
| throw error; | ||
| } | ||
| }; | ||
|
|
||
| function isFilePathBlocked(resolvedFilePath: ResolvedFilePath): boolean { | ||
| const allowedPaths = getAllowedPaths(); | ||
| const blockFileAccessToN8nFiles = process.env[BLOCK_FILE_ACCESS_TO_N8N_FILES] !== 'false'; | ||
|
|
||
| const restrictedPaths = blockFileAccessToN8nFiles ? getN8nRestrictedPaths() : []; | ||
|
|
@@ -64,8 +67,8 @@ | |
| } | ||
|
|
||
| export const getFileSystemHelperFunctions = (node: INode): FileSystemHelperFunctions => ({ | ||
| async createReadStream(filePath) { | ||
| if (await isFilePathBlocked(filePath.toString())) { | ||
| async createReadStream(resolvedFilePath) { | ||
| if (isFilePathBlocked(resolvedFilePath)) { | ||
| const allowedPaths = getAllowedPaths(); | ||
| const message = allowedPaths.length ? ` Allowed paths: ${allowedPaths.join(', ')}` : ''; | ||
| throw new NodeOperationError(node, `Access to the file is not allowed.${message}`, { | ||
|
|
@@ -74,34 +77,58 @@ | |
| } | ||
|
|
||
| try { | ||
| await fsAccess(filePath); | ||
| await fsAccess(resolvedFilePath); | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Just a note: the NodeJS docs say: |
||
| } catch (error) { | ||
| // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access | ||
| throw error.code === 'ENOENT' | ||
| ? // eslint-disable-next-line @typescript-eslint/no-unsafe-argument | ||
| new NodeOperationError(node, error, { | ||
| message: `The file "${String(filePath)}" could not be accessed.`, | ||
| message: `The file "${String(resolvedFilePath)}" could not be accessed.`, | ||
| level: 'warning', | ||
| }) | ||
| : error; | ||
| } | ||
|
|
||
| return createReadStream(filePath); | ||
| // Use O_NOFOLLOW to prevent TOCTOU race conditions where the file could be | ||
| // swapped to a symlink after validation but before opening | ||
| try { | ||
| return createReadStream(resolvedFilePath, { | ||
| // TypeScript types expect flags to be a string, but Node.js accepts numbers | ||
| // We use numeric flags here to combine O_RDONLY and O_NOFOLLOW bitwise | ||
| flags: (constants.O_RDONLY | constants.O_NOFOLLOW) as unknown as string, | ||
| }); | ||
| } catch (error) { | ||
| // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access | ||
| if (error.code === 'ELOOP') { | ||
| throw new NodeOperationError( | ||
| node, | ||
| `The file "${String(resolvedFilePath)}" could not be opened.`, | ||
| { | ||
| level: 'warning', | ||
| }, | ||
| ); | ||
mfsiega marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| } | ||
| throw error; | ||
| } | ||
| }, | ||
|
|
||
| getStoragePath() { | ||
| return safeJoinPath(Container.get(InstanceSettings).n8nFolder, `storage/${node.type}`); | ||
| }, | ||
|
|
||
| async writeContentToFile(filePath, content, flag) { | ||
| if (await isFilePathBlocked(filePath as string)) { | ||
| throw new NodeOperationError(node, `The file "${String(filePath)}" is not writable.`, { | ||
| level: 'warning', | ||
| }); | ||
| async writeContentToFile(resolvedFilePath, content, flag) { | ||
| if (isFilePathBlocked(resolvedFilePath)) { | ||
| throw new NodeOperationError( | ||
| node, | ||
| `The file "${String(resolvedFilePath)}" is not writable.`, | ||
| { | ||
| level: 'warning', | ||
| }, | ||
| ); | ||
| } | ||
| return await fsWriteFile(filePath, content, { encoding: 'binary', flag }); | ||
| return await fsWriteFile(resolvedFilePath, content, { encoding: 'binary', flag }); | ||
|
||
| }, | ||
|
|
||
| resolvePath, | ||
| isFilePathBlocked, | ||
| }); | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: Rest of the functions in this file have been defined with
function. Maybe we could keep it consistent?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done.