@@ -85,12 +85,44 @@ interface CpError {
8585
8686type 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 */
91121function 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 */
121153export 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