Skip to content

Commit d0cd523

Browse files
committed
fix(isolation): enforce copy path containment
1 parent d7f44b2 commit d0cd523

4 files changed

Lines changed: 476 additions & 25 deletions

File tree

src/copy-containment.js

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
const fs = require('fs');
2+
const path = require('path');
3+
4+
const CONTAINMENT_ERROR_CODE = 'ERR_COPY_CONTAINMENT';
5+
6+
class CopyContainmentError extends Error {
7+
constructor(relativePath, reason) {
8+
super(`Copy containment violation for ${JSON.stringify(relativePath)}: ${reason}`);
9+
this.name = 'CopyContainmentError';
10+
this.code = CONTAINMENT_ERROR_CODE;
11+
this.relativePath = relativePath;
12+
}
13+
}
14+
15+
function isContained(rootPath, targetPath) {
16+
const relative = path.relative(rootPath, targetPath);
17+
return (
18+
relative === '' ||
19+
(!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith(`..${path.sep}`))
20+
);
21+
}
22+
23+
function containmentError(relativePath, reason, cause) {
24+
const error = new CopyContainmentError(relativePath, reason);
25+
if (cause) {
26+
error.cause = cause;
27+
}
28+
return error;
29+
}
30+
31+
function statIdentity(targetPath) {
32+
const stats = fs.statSync(targetPath, { bigint: true });
33+
return {
34+
device: stats.dev.toString(),
35+
inode: stats.ino.toString(),
36+
directory: stats.isDirectory(),
37+
};
38+
}
39+
40+
function pinRoot(rootPath, label, expectedRoot) {
41+
const requestedPath = path.resolve(rootPath);
42+
const canonicalPath = fs.realpathSync.native(requestedPath);
43+
const identity = statIdentity(canonicalPath);
44+
45+
if (!identity.directory) {
46+
throw containmentError('', `${label} root is not a directory`);
47+
}
48+
49+
const pinnedRoot = {
50+
requestedPath,
51+
canonicalPath,
52+
device: identity.device,
53+
inode: identity.inode,
54+
};
55+
56+
if (
57+
expectedRoot &&
58+
(expectedRoot.canonicalPath !== pinnedRoot.canonicalPath ||
59+
expectedRoot.device !== pinnedRoot.device ||
60+
expectedRoot.inode !== pinnedRoot.inode)
61+
) {
62+
throw containmentError('', `${label} root changed after it was pinned`);
63+
}
64+
65+
return pinnedRoot;
66+
}
67+
68+
function assertPinnedRoot(root, label, relativePath) {
69+
let canonicalPath;
70+
let identity;
71+
try {
72+
canonicalPath = fs.realpathSync.native(root.canonicalPath);
73+
identity = statIdentity(canonicalPath);
74+
} catch (err) {
75+
throw containmentError(relativePath, `${label} root can no longer be resolved`, err);
76+
}
77+
78+
if (
79+
canonicalPath !== root.canonicalPath ||
80+
identity.device !== root.device ||
81+
identity.inode !== root.inode ||
82+
!identity.directory
83+
) {
84+
throw containmentError(relativePath, `${label} root changed after it was pinned`);
85+
}
86+
}
87+
88+
function validateRelativePath(relativePath) {
89+
if (typeof relativePath !== 'string' || relativePath.length === 0) {
90+
throw containmentError(relativePath, 'path must be a non-empty relative string');
91+
}
92+
if (relativePath.includes('\0')) {
93+
throw containmentError(relativePath, 'path contains a null byte');
94+
}
95+
if (
96+
path.posix.isAbsolute(relativePath) ||
97+
path.win32.isAbsolute(relativePath) ||
98+
path.win32.parse(relativePath).root
99+
) {
100+
throw containmentError(relativePath, 'absolute paths are not allowed');
101+
}
102+
103+
const components = relativePath.split(/[\\/]/);
104+
if (components.some((component) => component === '' || component === '.' || component === '..')) {
105+
throw containmentError(
106+
relativePath,
107+
'empty, current-directory, and traversal components are not allowed'
108+
);
109+
}
110+
111+
return path.normalize(relativePath);
112+
}
113+
114+
function resolveSourcePath(boundary, relativePath) {
115+
const normalizedPath = validateRelativePath(relativePath);
116+
const root = boundary.sourceRoot;
117+
assertPinnedRoot(root, 'source', relativePath);
118+
119+
const candidatePath = path.resolve(root.canonicalPath, normalizedPath);
120+
if (!isContained(root.canonicalPath, candidatePath)) {
121+
throw containmentError(relativePath, 'source path escapes its pinned root');
122+
}
123+
124+
let canonicalPath;
125+
try {
126+
canonicalPath = fs.realpathSync.native(candidatePath);
127+
} catch (err) {
128+
if (err.code === 'ELOOP') {
129+
throw containmentError(relativePath, 'source path contains a symlink cycle', err);
130+
}
131+
throw err;
132+
}
133+
134+
if (!isContained(root.canonicalPath, canonicalPath)) {
135+
throw containmentError(relativePath, 'resolved source path escapes its pinned root');
136+
}
137+
return canonicalPath;
138+
}
139+
140+
function resolveDestinationPath(boundary, relativePath) {
141+
const normalizedPath = validateRelativePath(relativePath);
142+
const root = boundary.destinationRoot;
143+
assertPinnedRoot(root, 'destination', relativePath);
144+
145+
const candidatePath = path.resolve(root.canonicalPath, normalizedPath);
146+
if (!isContained(root.canonicalPath, candidatePath)) {
147+
throw containmentError(relativePath, 'destination path escapes its pinned root');
148+
}
149+
150+
let existingPath = candidatePath;
151+
while (true) {
152+
try {
153+
fs.lstatSync(existingPath);
154+
break;
155+
} catch (err) {
156+
if (err.code !== 'ENOENT') {
157+
throw err;
158+
}
159+
const parentPath = path.dirname(existingPath);
160+
if (parentPath === existingPath) {
161+
throw containmentError(relativePath, 'destination has no resolvable ancestor', err);
162+
}
163+
existingPath = parentPath;
164+
}
165+
}
166+
167+
let canonicalExistingPath;
168+
try {
169+
canonicalExistingPath = fs.realpathSync.native(existingPath);
170+
} catch (err) {
171+
throw containmentError(relativePath, 'destination contains an unresolved symlink', err);
172+
}
173+
174+
if (!isContained(root.canonicalPath, canonicalExistingPath)) {
175+
throw containmentError(relativePath, 'resolved destination path escapes its pinned root');
176+
}
177+
178+
const unresolvedSuffix = path.relative(existingPath, candidatePath);
179+
const resolvedPath = unresolvedSuffix
180+
? path.join(canonicalExistingPath, unresolvedSuffix)
181+
: canonicalExistingPath;
182+
if (!isContained(root.canonicalPath, resolvedPath)) {
183+
throw containmentError(relativePath, 'resolved destination path escapes its pinned root');
184+
}
185+
return resolvedPath;
186+
}
187+
188+
function createCopyBoundary(sourceBase, destinationBase, expectedBoundary) {
189+
return {
190+
sourceRoot: pinRoot(sourceBase, 'source', expectedBoundary?.sourceRoot),
191+
destinationRoot: pinRoot(destinationBase, 'destination', expectedBoundary?.destinationRoot),
192+
};
193+
}
194+
195+
function resolveCopyPath(boundary, relativePath) {
196+
return {
197+
sourcePath: resolveSourcePath(boundary, relativePath),
198+
destinationPath: resolveDestinationPath(boundary, relativePath),
199+
};
200+
}
201+
202+
function isCopyContainmentError(error) {
203+
return error?.code === CONTAINMENT_ERROR_CODE;
204+
}
205+
206+
module.exports = {
207+
CONTAINMENT_ERROR_CODE,
208+
CopyContainmentError,
209+
createCopyBoundary,
210+
isCopyContainmentError,
211+
resolveCopyPath,
212+
resolveSourcePath,
213+
validateRelativePath,
214+
};

src/copy-worker.js

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,36 +8,52 @@
88
const { parentPort, workerData } = require('worker_threads');
99
const fs = require('fs');
1010
const path = require('path');
11+
const {
12+
createCopyBoundary,
13+
isCopyContainmentError,
14+
resolveCopyPath,
15+
} = require('./copy-containment');
1116

12-
const { files, sourceBase, destBase } = workerData;
17+
const { files, sourceBase, destBase, expectedBoundary } = workerData;
18+
const copyBoundary = createCopyBoundary(sourceBase, destBase, expectedBoundary);
1319

1420
let copied = 0;
1521
let skipped = 0;
16-
const errors = [];
22+
let error = null;
1723

1824
for (const relativePath of files) {
19-
const srcPath = path.join(sourceBase, relativePath);
20-
const destPath = path.join(destBase, relativePath);
21-
2225
try {
2326
// Ensure parent directory exists
24-
const destDir = path.dirname(destPath);
25-
if (!fs.existsSync(destDir)) {
26-
fs.mkdirSync(destDir, { recursive: true });
27+
const parentRelativePath = path.dirname(relativePath);
28+
if (parentRelativePath !== '.') {
29+
const { destinationPath: destDir } = resolveCopyPath(copyBoundary, parentRelativePath);
30+
if (!fs.existsSync(destDir)) {
31+
fs.mkdirSync(destDir, { recursive: true });
32+
}
2733
}
2834

2935
// Copy the file
30-
fs.copyFileSync(srcPath, destPath);
36+
const { sourcePath, destinationPath } = resolveCopyPath(copyBoundary, relativePath);
37+
fs.copyFileSync(sourcePath, destinationPath);
3138
copied++;
3239
} catch (err) {
3340
// Skip files we can't copy (permission denied, broken symlinks, etc.)
34-
if (err.code === 'EACCES' || err.code === 'EPERM' || err.code === 'ENOENT') {
41+
if (
42+
!isCopyContainmentError(err) &&
43+
(err.code === 'EACCES' || err.code === 'EPERM' || err.code === 'ENOENT')
44+
) {
3545
skipped++;
3646
continue;
3747
}
38-
errors.push({ file: relativePath, error: err.message });
48+
error = {
49+
file: relativePath,
50+
name: err.name,
51+
code: err.code,
52+
message: err.message,
53+
};
54+
break;
3955
}
4056
}
4157

4258
// Report results back to main thread
43-
parentPort.postMessage({ copied, skipped, errors });
59+
parentPort.postMessage({ copied, skipped, error });

0 commit comments

Comments
 (0)