Skip to content

Commit c2c3e08

Browse files
fix(core): Handle symlinks in blocked paths (#17735)
1 parent e8e7b23 commit c2c3e08

File tree

5 files changed

+64
-40
lines changed

5 files changed

+64
-40
lines changed

packages/core/src/execution-engine/node-execution-context/utils/__tests__/file-system-helper-functions.test.ts

Lines changed: 45 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Container } from '@n8n/di';
22
import type { INode } from 'n8n-workflow';
33
import { createReadStream } from 'node:fs';
4-
import { access as fsAccess } from 'node:fs/promises';
4+
import { access as fsAccess, realpath as fsRealpath } from 'node:fs/promises';
55
import { join } from 'node:path';
66

77
import {
@@ -30,6 +30,7 @@ beforeEach(() => {
3030
// @ts-expect-error undefined property
3131
error.code = 'ENOENT';
3232
(fsAccess as jest.Mock).mockRejectedValue(error);
33+
(fsRealpath as jest.Mock).mockImplementation((path: string) => path);
3334

3435
instanceSettings = Container.get(InstanceSettings);
3536
});
@@ -39,115 +40,125 @@ describe('isFilePathBlocked', () => {
3940
process.env[BLOCK_FILE_ACCESS_TO_N8N_FILES] = 'true';
4041
});
4142

42-
it('should return true for static cache dir', () => {
43+
it('should return true for static cache dir', async () => {
4344
const filePath = instanceSettings.staticCacheDir;
44-
expect(isFilePathBlocked(filePath)).toBe(true);
45+
expect(await isFilePathBlocked(filePath)).toBe(true);
4546
});
4647

47-
it('should return true for restricted paths', () => {
48+
it('should return true for restricted paths', async () => {
4849
const restrictedPath = instanceSettings.n8nFolder;
49-
expect(isFilePathBlocked(restrictedPath)).toBe(true);
50+
expect(await isFilePathBlocked(restrictedPath)).toBe(true);
5051
});
5152

52-
it('should handle empty allowed paths', () => {
53+
it('should handle empty allowed paths', async () => {
5354
delete process.env[RESTRICT_FILE_ACCESS_TO];
54-
const result = isFilePathBlocked('/some/random/path');
55+
const result = await isFilePathBlocked('/some/random/path');
5556
expect(result).toBe(false);
5657
});
5758

58-
it('should handle multiple allowed paths', () => {
59+
it('should handle multiple allowed paths', async () => {
5960
process.env[RESTRICT_FILE_ACCESS_TO] = '/path1;/path2;/path3';
6061
const allowedPath = '/path2/somefile';
61-
expect(isFilePathBlocked(allowedPath)).toBe(false);
62+
expect(await isFilePathBlocked(allowedPath)).toBe(false);
6263
});
6364

64-
it('should handle empty strings in allowed paths', () => {
65+
it('should handle empty strings in allowed paths', async () => {
6566
process.env[RESTRICT_FILE_ACCESS_TO] = '/path1;;/path2';
6667
const allowedPath = '/path2/somefile';
67-
expect(isFilePathBlocked(allowedPath)).toBe(false);
68+
expect(await isFilePathBlocked(allowedPath)).toBe(false);
6869
});
6970

70-
it('should trim whitespace in allowed paths', () => {
71+
it('should trim whitespace in allowed paths', async () => {
7172
process.env[RESTRICT_FILE_ACCESS_TO] = ' /path1 ; /path2 ; /path3 ';
7273
const allowedPath = '/path2/somefile';
73-
expect(isFilePathBlocked(allowedPath)).toBe(false);
74+
expect(await isFilePathBlocked(allowedPath)).toBe(false);
7475
});
7576

76-
it('should return false when BLOCK_FILE_ACCESS_TO_N8N_FILES is false', () => {
77+
it('should return false when BLOCK_FILE_ACCESS_TO_N8N_FILES is false', async () => {
7778
process.env[BLOCK_FILE_ACCESS_TO_N8N_FILES] = 'false';
7879
const restrictedPath = instanceSettings.n8nFolder;
79-
expect(isFilePathBlocked(restrictedPath)).toBe(false);
80+
expect(await isFilePathBlocked(restrictedPath)).toBe(false);
8081
});
8182

82-
it('should return true when path is in allowed paths but still restricted', () => {
83+
it('should return true when path is in allowed paths but still restricted', async () => {
8384
process.env[RESTRICT_FILE_ACCESS_TO] = '/some/allowed/path';
8485
const restrictedPath = instanceSettings.n8nFolder;
85-
expect(isFilePathBlocked(restrictedPath)).toBe(true);
86+
expect(await isFilePathBlocked(restrictedPath)).toBe(true);
8687
});
8788

88-
it('should return false when path is in allowed paths', () => {
89+
it('should return false when path is in allowed paths', async () => {
8990
const allowedPath = '/some/allowed/path';
9091
process.env[RESTRICT_FILE_ACCESS_TO] = allowedPath;
91-
expect(isFilePathBlocked(allowedPath)).toBe(false);
92+
expect(await isFilePathBlocked(allowedPath)).toBe(false);
9293
});
9394

94-
it('should return true when file paths in CONFIG_FILES', () => {
95+
it('should return true when file paths in CONFIG_FILES', async () => {
9596
process.env[CONFIG_FILES] = '/path/to/config1,/path/to/config2';
9697
const configPath = '/path/to/config1/somefile';
97-
expect(isFilePathBlocked(configPath)).toBe(true);
98+
expect(await isFilePathBlocked(configPath)).toBe(true);
9899
});
99100

100-
it('should return true when file paths in CUSTOM_EXTENSION_ENV', () => {
101+
it('should return true when file paths in CUSTOM_EXTENSION_ENV', async () => {
101102
process.env[CUSTOM_EXTENSION_ENV] = '/path/to/extensions1;/path/to/extensions2';
102103
const extensionPath = '/path/to/extensions1/somefile';
103-
expect(isFilePathBlocked(extensionPath)).toBe(true);
104+
expect(await isFilePathBlocked(extensionPath)).toBe(true);
104105
});
105106

106-
it('should return true when file paths in BINARY_DATA_STORAGE_PATH', () => {
107+
it('should return true when file paths in BINARY_DATA_STORAGE_PATH', async () => {
107108
process.env[BINARY_DATA_STORAGE_PATH] = '/path/to/binary/storage';
108109
const binaryPath = '/path/to/binary/storage/somefile';
109-
expect(isFilePathBlocked(binaryPath)).toBe(true);
110+
expect(await isFilePathBlocked(binaryPath)).toBe(true);
110111
});
111112

112-
it('should block file paths in email template paths', () => {
113+
it('should block file paths in email template paths', async () => {
113114
process.env[UM_EMAIL_TEMPLATES_INVITE] = '/path/to/invite/templates';
114115
process.env[UM_EMAIL_TEMPLATES_PWRESET] = '/path/to/pwreset/templates';
115116

116117
const invitePath = '/path/to/invite/templates/invite.html';
117118
const pwResetPath = '/path/to/pwreset/templates/reset.html';
118119

119-
expect(isFilePathBlocked(invitePath)).toBe(true);
120-
expect(isFilePathBlocked(pwResetPath)).toBe(true);
120+
expect(await isFilePathBlocked(invitePath)).toBe(true);
121+
expect(await isFilePathBlocked(pwResetPath)).toBe(true);
121122
});
122123

123-
it('should block access to n8n files if restrict and block are set', () => {
124+
it('should block access to n8n files if restrict and block are set', async () => {
124125
const homeVarName = process.platform === 'win32' ? 'USERPROFILE' : 'HOME';
125126
const userHome = process.env.N8N_USER_FOLDER ?? process.env[homeVarName] ?? process.cwd();
126127

127128
process.env[RESTRICT_FILE_ACCESS_TO] = userHome;
128129
process.env[BLOCK_FILE_ACCESS_TO_N8N_FILES] = 'true';
129130
const restrictedPath = instanceSettings.n8nFolder;
130-
expect(isFilePathBlocked(restrictedPath)).toBe(true);
131+
expect(await isFilePathBlocked(restrictedPath)).toBe(true);
131132
});
132133

133-
it('should allow access to parent folder if restrict and block are set', () => {
134+
it('should allow access to parent folder if restrict and block are set', async () => {
134135
const homeVarName = process.platform === 'win32' ? 'USERPROFILE' : 'HOME';
135136
const userHome = process.env.N8N_USER_FOLDER ?? process.env[homeVarName] ?? process.cwd();
136137

137138
process.env[RESTRICT_FILE_ACCESS_TO] = userHome;
138139
process.env[BLOCK_FILE_ACCESS_TO_N8N_FILES] = 'true';
139140
const restrictedPath = join(userHome, 'somefile.txt');
140-
expect(isFilePathBlocked(restrictedPath)).toBe(false);
141+
expect(await isFilePathBlocked(restrictedPath)).toBe(false);
141142
});
142143

143-
it('should not block similar paths', () => {
144+
it('should not block similar paths', async () => {
144145
const homeVarName = process.platform === 'win32' ? 'USERPROFILE' : 'HOME';
145146
const userHome = process.env.N8N_USER_FOLDER ?? process.env[homeVarName] ?? process.cwd();
146147

147148
process.env[RESTRICT_FILE_ACCESS_TO] = userHome;
148149
process.env[BLOCK_FILE_ACCESS_TO_N8N_FILES] = 'true';
149150
const restrictedPath = join(userHome, '.n8n_x');
150-
expect(isFilePathBlocked(restrictedPath)).toBe(false);
151+
expect(await isFilePathBlocked(restrictedPath)).toBe(false);
152+
});
153+
154+
it('should return true for a symlink in a allowed path to a restricted path', async () => {
155+
process.env[RESTRICT_FILE_ACCESS_TO] = '/path1';
156+
const allowedPath = '/path1/symlink';
157+
const actualPath = '/path2/realfile';
158+
(fsRealpath as jest.Mock).mockImplementation((path: string) =>
159+
path === allowedPath ? actualPath : path,
160+
);
161+
expect(await isFilePathBlocked(allowedPath)).toBe(true);
151162
});
152163
});
153164

packages/core/src/execution-engine/node-execution-context/utils/file-system-helper-functions.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,11 @@ import { Container } from '@n8n/di';
33
import type { FileSystemHelperFunctions, INode } from 'n8n-workflow';
44
import { NodeOperationError } from 'n8n-workflow';
55
import { createReadStream } from 'node:fs';
6-
import { access as fsAccess, writeFile as fsWriteFile } from 'node:fs/promises';
7-
import { resolve } from 'node:path';
6+
import {
7+
access as fsAccess,
8+
writeFile as fsWriteFile,
9+
realpath as fsRealpath,
10+
} from 'node:fs/promises';
811

912
import {
1013
BINARY_DATA_STORAGE_PATH,
@@ -29,9 +32,9 @@ const getAllowedPaths = () => {
2932
return allowedPaths;
3033
};
3134

32-
export function isFilePathBlocked(filePath: string): boolean {
35+
export async function isFilePathBlocked(filePath: string): Promise<boolean> {
3336
const allowedPaths = getAllowedPaths();
34-
const resolvedFilePath = resolve(filePath);
37+
const resolvedFilePath = await fsRealpath(filePath);
3538
const blockFileAccessToN8nFiles = process.env[BLOCK_FILE_ACCESS_TO_N8N_FILES] !== 'false';
3639

3740
const restrictedPaths = blockFileAccessToN8nFiles ? getN8nRestrictedPaths() : [];
@@ -62,7 +65,7 @@ export const getFileSystemHelperFunctions = (node: INode): FileSystemHelperFunct
6265
})
6366
: error;
6467
}
65-
if (isFilePathBlocked(filePath as string)) {
68+
if (await isFilePathBlocked(filePath as string)) {
6669
const allowedPaths = getAllowedPaths();
6770
const message = allowedPaths.length ? ` Allowed paths: ${allowedPaths.join(', ')}` : '';
6871
throw new NodeOperationError(node, `Access to the file is not allowed.${message}`, {
@@ -77,7 +80,7 @@ export const getFileSystemHelperFunctions = (node: INode): FileSystemHelperFunct
7780
},
7881

7982
async writeContentToFile(filePath, content, flag) {
80-
if (isFilePathBlocked(filePath as string)) {
83+
if (await isFilePathBlocked(filePath as string)) {
8184
throw new NodeOperationError(node, `The file "${String(filePath)}" is not writable.`, {
8285
level: 'warning',
8386
});

packages/nodes-base/nodes/Crypto/test/Crypto.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ describe('Test Crypto Node', () => {
77
jest.mock('fast-glob', () => async () => ['/test/binary.data']);
88
jest.mock('fs/promises');
99
fsPromises.access = async () => {};
10+
const realpathSpy = jest.spyOn(fsPromises, 'realpath');
11+
realpathSpy.mockImplementation(async (path) => path as string);
1012
jest.mock('fs');
1113
fs.createReadStream = () => Readable.from(Buffer.from('test')) as fs.ReadStream;
1214

packages/nodes-base/nodes/Files/ReadWriteFile/test/ReadWriteFile.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { NodeTestHarness } from '@nodes-testing/node-test-harness';
2+
import fsPromises from 'fs/promises';
23
import type { WorkflowTestData } from 'n8n-workflow';
34

45
describe('Test ReadWriteFile Node', () => {
@@ -13,6 +14,9 @@ describe('Test ReadWriteFile Node', () => {
1314
const writeFileNode = workflowData.nodes.find((n) => n.name === 'Write to Disk')!;
1415
writeFileNode.parameters.fileName = `${testHarness.temporaryDir}/image-written.jpg`;
1516

17+
const realpathSpy = jest.spyOn(fsPromises, 'realpath');
18+
realpathSpy.mockImplementation(async (path) => path as string);
19+
1620
const tests: WorkflowTestData[] = [
1721
{
1822
description: 'nodes/Files/ReadWriteFile/test/ReadWriteFile.workflow.json',

packages/nodes-base/nodes/WriteBinaryFile/test/WriteBinaryFile.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { NodeTestHarness } from '@nodes-testing/node-test-harness';
2+
import fsPromises from 'fs/promises';
23
import type { WorkflowTestData } from 'n8n-workflow';
34
import path from 'path';
45

@@ -12,6 +13,9 @@ describe('Test Write Binary File Node', () => {
1213
const writeFilePath = path.join(testHarness.temporaryDir, 'image-written.jpg');
1314
writeFileNode.parameters.fileName = writeFilePath;
1415

16+
const realpathSpy = jest.spyOn(fsPromises, 'realpath');
17+
realpathSpy.mockImplementation(async (path) => path as string);
18+
1519
const tests: WorkflowTestData[] = [
1620
{
1721
description: 'nodes/WriteBinaryFile/test/WriteBinaryFile.workflow.json',

0 commit comments

Comments
 (0)