Skip to content

Commit 0344915

Browse files
committed
fix: address security and reliability issues in cp operations
- Add sanitizePath to prevent path traversal attacks - Implement recursive directory upload - Handle WebSocket close properly to prevent promise hang - Fix race condition between file close and utimesSync - Append to existing pathname in buildWsURL - Separate JSON parsing try-catch from FS operations
1 parent 62e74c8 commit 0344915

File tree

1 file changed

+186
-34
lines changed

1 file changed

+186
-34
lines changed

src/lib/cp.ts

Lines changed: 186 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -85,12 +85,44 @@ interface CpError {
8585

8686
type CpMessage = CpFileHeader | CpEndMarker | CpResult | CpError;
8787

88+
/**
89+
* Sanitizes a path from the server to prevent path traversal attacks.
90+
* Ensures the path stays within the destination directory.
91+
*/
92+
function sanitizePath(base: string, relativePath: string): string {
93+
// Normalize the path to resolve . and .. components
94+
const normalized = path.normalize(relativePath);
95+
96+
// Reject absolute paths
97+
if (path.isAbsolute(normalized)) {
98+
throw new Error(`Invalid path: absolute paths not allowed: ${relativePath}`);
99+
}
100+
101+
// Reject paths that escape the destination
102+
if (normalized.startsWith('..')) {
103+
throw new Error(`Invalid path: path escapes destination: ${relativePath}`);
104+
}
105+
106+
// Join with base and verify the result is under base
107+
const result = path.join(base, normalized);
108+
const absBase = path.resolve(base);
109+
const absResult = path.resolve(result);
110+
111+
if (!absResult.startsWith(absBase + path.sep) && absResult !== absBase) {
112+
throw new Error(`Invalid path: path escapes destination: ${relativePath}`);
113+
}
114+
115+
return result;
116+
}
117+
88118
/**
89119
* Builds the WebSocket URL for the cp endpoint.
90120
*/
91121
function buildWsURL(baseURL: string, instanceId: string): string {
92122
const url = new URL(baseURL);
93-
url.pathname = `/instances/${instanceId}/cp`;
123+
// Append to existing pathname instead of overwriting
124+
const existingPath = url.pathname.endsWith('/') ? url.pathname.slice(0, -1) : url.pathname;
125+
url.pathname = `${existingPath}/instances/${instanceId}/cp`;
94126

95127
if (url.protocol === 'https:') {
96128
url.protocol = 'wss:';
@@ -119,14 +151,54 @@ function buildWsURL(baseURL: string, instanceId: string): string {
119151
* ```
120152
*/
121153
export async function cpToInstance(cfg: CpConfig, opts: CpToInstanceOptions): Promise<void> {
154+
// Get file stats
155+
const stats = fs.statSync(opts.srcPath);
156+
const isDir = stats.isDirectory();
157+
158+
if (isDir) {
159+
// For directories, first create the directory, then recursively copy contents
160+
await cpSingleFileToInstance(cfg, {
161+
...opts,
162+
isDir: true,
163+
});
164+
165+
// Now recursively copy all contents
166+
const entries = fs.readdirSync(opts.srcPath, { withFileTypes: true });
167+
for (const entry of entries) {
168+
const srcEntryPath = path.join(opts.srcPath, entry.name);
169+
// Use path.posix.join for guest paths to ensure forward slashes
170+
const dstEntryPath = path.posix.join(opts.dstPath, entry.name);
171+
172+
await cpToInstance(cfg, {
173+
instanceId: opts.instanceId,
174+
srcPath: srcEntryPath,
175+
dstPath: dstEntryPath,
176+
...(opts.mode !== undefined && { mode: opts.mode }),
177+
});
178+
}
179+
} else {
180+
// For files, copy directly
181+
await cpSingleFileToInstance(cfg, {
182+
...opts,
183+
isDir: false,
184+
});
185+
}
186+
}
187+
188+
/**
189+
* Internal function to copy a single file or create a directory.
190+
*/
191+
async function cpSingleFileToInstance(
192+
cfg: CpConfig,
193+
opts: CpToInstanceOptions & { isDir: boolean },
194+
): Promise<void> {
122195
// Check if WebSocket is available (Node.js vs browser)
123196
const WebSocket = getWebSocket();
124197

125198
const wsURL = buildWsURL(cfg.baseURL, opts.instanceId);
126199

127200
// Get file stats
128201
const stats = fs.statSync(opts.srcPath);
129-
const isDir = stats.isDirectory();
130202
const mode = opts.mode ?? stats.mode & 0o777;
131203

132204
// Connect to WebSocket
@@ -137,26 +209,42 @@ export async function cpToInstance(cfg: CpConfig, opts: CpToInstanceOptions): Pr
137209
});
138210

139211
return new Promise((resolve, reject) => {
212+
let settled = false;
213+
214+
const doResolve = () => {
215+
if (!settled) {
216+
settled = true;
217+
resolve();
218+
}
219+
};
220+
221+
const doReject = (err: Error) => {
222+
if (!settled) {
223+
settled = true;
224+
reject(err);
225+
}
226+
};
227+
140228
ws.on('open', () => {
141229
// Send initial request
142230
const req: CpRequest = {
143231
direction: 'to',
144232
guest_path: opts.dstPath,
145-
is_dir: isDir,
233+
is_dir: opts.isDir,
146234
mode: mode,
147235
};
148236
ws.send(JSON.stringify(req));
149237

150-
if (isDir) {
151-
// For directories, just send end marker
238+
if (opts.isDir) {
239+
// For directories, just send end marker - the directory will be created
152240
ws.send(JSON.stringify({ type: 'end' }));
153241
} else {
154242
// Stream file content
155243
const fileStream = fs.createReadStream(opts.srcPath, {
156244
highWaterMark: 32 * 1024,
157245
});
158246

159-
fileStream.on('data', (chunk) => {
247+
fileStream.on('data', (chunk: any) => {
160248
ws.send(chunk as Buffer);
161249
});
162250

@@ -166,7 +254,7 @@ export async function cpToInstance(cfg: CpConfig, opts: CpToInstanceOptions): Pr
166254

167255
fileStream.on('error', (err) => {
168256
ws.close();
169-
reject(err);
257+
doReject(err);
170258
});
171259
}
172260
});
@@ -178,27 +266,31 @@ export async function cpToInstance(cfg: CpConfig, opts: CpToInstanceOptions): Pr
178266
if (msg.type === 'result') {
179267
if (msg.success) {
180268
ws.close();
181-
resolve();
269+
doResolve();
182270
} else {
183271
ws.close();
184-
reject(new Error(`Copy failed: ${msg.error}`));
272+
doReject(new Error(`Copy failed: ${msg.error}`));
185273
}
186274
} else if (msg.type === 'error') {
187275
ws.close();
188-
reject(new Error(`Copy error at ${msg.path}: ${msg.message}`));
276+
doReject(new Error(`Copy error at ${msg.path}: ${msg.message}`));
189277
}
190-
} catch (e) {
278+
} catch {
191279
// Ignore non-JSON messages
192280
}
193281
});
194282

195283
ws.on('error', (err) => {
196-
reject(err);
284+
doReject(err);
197285
});
198286

199287
ws.on('close', (code, reason) => {
200-
if (code !== 1000) {
201-
reject(new Error(`WebSocket closed: ${code} ${reason}`));
288+
if (!settled) {
289+
if (code === 1000) {
290+
doReject(new Error('WebSocket closed before receiving result'));
291+
} else {
292+
doReject(new Error(`WebSocket closed: ${code} ${reason}`));
293+
}
202294
}
203295
});
204296
});
@@ -234,6 +326,32 @@ export async function cpFromInstance(cfg: CpConfig, opts: CpFromInstanceOptions)
234326
return new Promise((resolve, reject) => {
235327
let currentFile: fs.WriteStream | null = null;
236328
let currentHeader: CpFileHeader | null = null;
329+
let settled = false;
330+
331+
const doResolve = () => {
332+
if (!settled) {
333+
settled = true;
334+
resolve();
335+
}
336+
};
337+
338+
const doReject = (err: Error) => {
339+
if (!settled) {
340+
settled = true;
341+
reject(err);
342+
}
343+
};
344+
345+
// Helper to close file and wait for it to complete
346+
const closeCurrentFile = (): Promise<void> => {
347+
return new Promise((res) => {
348+
if (currentFile) {
349+
currentFile.end(() => res());
350+
} else {
351+
res();
352+
}
353+
});
354+
};
237355

238356
ws.on('open', () => {
239357
// Send initial request
@@ -247,30 +365,46 @@ export async function cpFromInstance(cfg: CpConfig, opts: CpFromInstanceOptions)
247365

248366
ws.on('message', (data: Buffer | string) => {
249367
// Try to parse as JSON first
368+
let msg: CpMessage | null = null;
250369
if (typeof data === 'string' || (Buffer.isBuffer(data) && !isBinaryData(data))) {
251370
try {
252-
const msg = JSON.parse(data.toString()) as CpMessage;
371+
msg = JSON.parse(data.toString()) as CpMessage;
372+
} catch {
373+
// Not JSON, treat as binary data below
374+
}
375+
}
253376

377+
// Handle parsed message (outside try-catch so FS errors propagate)
378+
if (msg) {
379+
try {
254380
switch (msg.type) {
255381
case 'header': {
256-
// Close previous file if any
382+
// Close previous file if any (synchronously for simplicity in message handler)
257383
if (currentFile) {
258384
currentFile.close();
259385
currentFile = null;
260386
}
261387

262388
currentHeader = msg as CpFileHeader;
263-
const targetPath = path.join(opts.dstPath, currentHeader.path);
389+
// Sanitize server-provided path to prevent path traversal attacks
390+
const targetPath = sanitizePath(opts.dstPath, currentHeader.path);
264391

265392
if (currentHeader.is_dir) {
266393
fs.mkdirSync(targetPath, { recursive: true, mode: currentHeader.mode });
267394
} else if (currentHeader.is_symlink && currentHeader.link_target) {
395+
// Validate symlink target
396+
const linkTarget = currentHeader.link_target;
397+
if (path.isAbsolute(linkTarget) || path.normalize(linkTarget).startsWith('..')) {
398+
doReject(new Error(`Invalid symlink target: ${linkTarget}`));
399+
ws.close();
400+
return;
401+
}
268402
try {
269403
fs.unlinkSync(targetPath);
270404
} catch {
271405
// Ignore if doesn't exist
272406
}
273-
fs.symlinkSync(currentHeader.link_target, targetPath);
407+
fs.symlinkSync(linkTarget, targetPath);
274408
} else {
275409
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
276410
currentFile = fs.createWriteStream(targetPath, { mode: currentHeader.mode });
@@ -281,36 +415,50 @@ export async function cpFromInstance(cfg: CpConfig, opts: CpFromInstanceOptions)
281415
case 'end': {
282416
const endMsg = msg as CpEndMarker;
283417
if (currentFile) {
284-
currentFile.close();
285-
// Set modification time
286-
if (currentHeader && currentHeader.mtime > 0) {
287-
const targetPath = path.join(opts.dstPath, currentHeader.path);
288-
const mtime = new Date(currentHeader.mtime * 1000);
289-
fs.utimesSync(targetPath, mtime, mtime);
290-
}
418+
const fileToClose = currentFile;
419+
const headerToUse = currentHeader;
291420
currentFile = null;
292421
currentHeader = null;
422+
423+
// Wait for file to close before setting mtime
424+
fileToClose.end(() => {
425+
if (headerToUse && headerToUse.mtime > 0) {
426+
try {
427+
const targetPath = sanitizePath(opts.dstPath, headerToUse.path);
428+
const mtime = new Date(headerToUse.mtime * 1000);
429+
fs.utimesSync(targetPath, mtime, mtime);
430+
} catch (err) {
431+
// Log but don't fail for mtime errors
432+
}
433+
}
434+
if (endMsg.final) {
435+
ws.close();
436+
doResolve();
437+
}
438+
});
439+
return; // Exit early since we handle resolution in callback
293440
}
294441

295442
if (endMsg.final) {
296443
ws.close();
297-
resolve();
444+
doResolve();
298445
}
299446
break;
300447
}
301448

302449
case 'error': {
303450
const errMsg = msg as CpError;
304451
ws.close();
305-
reject(new Error(`Copy error at ${errMsg.path}: ${errMsg.message}`));
452+
doReject(new Error(`Copy error at ${errMsg.path}: ${errMsg.message}`));
306453
break;
307454
}
308455
}
309-
310-
return;
311-
} catch {
312-
// Not JSON, treat as binary data
456+
} catch (err) {
457+
// FS operation failed - propagate the error
458+
ws.close();
459+
doReject(err as Error);
313460
}
461+
return;
314462
}
315463

316464
// Binary data - write to current file
@@ -323,15 +471,19 @@ export async function cpFromInstance(cfg: CpConfig, opts: CpFromInstanceOptions)
323471
if (currentFile) {
324472
currentFile.close();
325473
}
326-
reject(err);
474+
doReject(err);
327475
});
328476

329477
ws.on('close', (code, reason) => {
330478
if (currentFile) {
331479
currentFile.close();
332480
}
333-
if (code !== 1000) {
334-
reject(new Error(`WebSocket closed: ${code} ${reason}`));
481+
if (!settled) {
482+
if (code === 1000) {
483+
doReject(new Error('WebSocket closed before receiving final marker'));
484+
} else {
485+
doReject(new Error(`WebSocket closed: ${code} ${reason}`));
486+
}
335487
}
336488
});
337489
});

0 commit comments

Comments
 (0)