@ebowwa/terminal 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/files.ts ADDED
@@ -0,0 +1,636 @@
1
+ /**
2
+ * Remote file operations via SSH
3
+ */
4
+
5
+ import type { SSHOptions } from "./types.js";
6
+ import { execSSH } from "./client.js";
7
+ import { SSHError } from "./error.js";
8
+
9
+ /**
10
+ * Path sanitization options
11
+ */
12
+ export interface SanitizePathOptions {
13
+ /**
14
+ * Allowed base directories (absolute paths)
15
+ * Default: ["/root"] for root user
16
+ */
17
+ allowedBaseDirs?: string[];
18
+
19
+ /**
20
+ * User context for determining default base directory
21
+ */
22
+ user?: string;
23
+
24
+ /**
25
+ * Whether to allow absolute paths
26
+ * Default: false (security best practice)
27
+ */
28
+ allowAbsolutePaths?: boolean;
29
+
30
+ /**
31
+ * Maximum path depth to prevent deep traversal attempts
32
+ * Default: 20
33
+ */
34
+ maxDepth?: number;
35
+
36
+ /**
37
+ * Log suspicious path attempts
38
+ * Default: true
39
+ */
40
+ logSuspicious?: boolean;
41
+ }
42
+
43
+ /**
44
+ * Path traversal security error
45
+ */
46
+ export class PathTraversalError extends SSHError {
47
+ constructor(
48
+ message: string,
49
+ public readonly attemptedPath: string,
50
+ public readonly reason: string,
51
+ ) {
52
+ super(message);
53
+ this.name = "PathTraversalError";
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Security event log for path traversal attempts
59
+ */
60
+ interface SecurityEvent {
61
+ timestamp: string;
62
+ attemptedPath: string;
63
+ reason: string;
64
+ severity: "blocked" | "suspicious" | "warning";
65
+ }
66
+
67
+ const securityEvents: SecurityEvent[] = [];
68
+ const MAX_SECURITY_EVENTS = 1000;
69
+
70
+ /**
71
+ * Log a security event
72
+ */
73
+ function logSecurityEvent(
74
+ attemptedPath: string,
75
+ reason: string,
76
+ severity: SecurityEvent["severity"],
77
+ ): void {
78
+ const event: SecurityEvent = {
79
+ timestamp: new Date().toISOString(),
80
+ attemptedPath,
81
+ reason,
82
+ severity,
83
+ };
84
+
85
+ securityEvents.push(event);
86
+
87
+ // Keep only recent events
88
+ if (securityEvents.length > MAX_SECURITY_EVENTS) {
89
+ securityEvents.shift();
90
+ }
91
+
92
+ // Log to console with appropriate severity
93
+ const logPrefix = {
94
+ blocked: "[SECURITY BLOCKED]",
95
+ suspicious: "[SECURITY SUSPICIOUS]",
96
+ warning: "[SECURITY WARNING]",
97
+ }[severity];
98
+
99
+ console.error(
100
+ `${logPrefix} Path traversal attempt detected:`,
101
+ JSON.stringify(event),
102
+ );
103
+ }
104
+
105
+ /**
106
+ * Get recent security events for monitoring
107
+ */
108
+ export function getSecurityEvents(
109
+ limit: number = 50,
110
+ ): SecurityEvent[] {
111
+ return securityEvents.slice(-limit);
112
+ }
113
+
114
+ /**
115
+ * Clear old security events (for maintenance)
116
+ */
117
+ export function clearSecurityEvents(olderThanMs: number = 24 * 60 * 60 * 1000): number {
118
+ const cutoff = Date.now() - olderThanMs;
119
+ const initialLength = securityEvents.length;
120
+
121
+ for (let i = securityEvents.length - 1; i >= 0; i--) {
122
+ const eventTime = new Date(securityEvents[i].timestamp).getTime();
123
+ if (eventTime < cutoff) {
124
+ securityEvents.splice(i, 1);
125
+ }
126
+ }
127
+
128
+ return initialLength - securityEvents.length;
129
+ }
130
+
131
+ /**
132
+ * Normalize a path by resolving . and removing redundant slashes
133
+ * Does NOT resolve .. (those are checked separately)
134
+ */
135
+ function normalizePath(path: string): string {
136
+ // Remove redundant slashes
137
+ let normalized = path.replace(/\/+/g, "/");
138
+
139
+ // Remove trailing slash (unless it's just "/")
140
+ if (normalized.length > 1 && normalized.endsWith("/")) {
141
+ normalized = normalized.slice(0, -1);
142
+ }
143
+
144
+ // Resolve single dots (current directory)
145
+ normalized = normalized.replace(/\/\.\//g, "/").replace(/\/\.$/, "");
146
+
147
+ return normalized;
148
+ }
149
+
150
+ /**
151
+ * Check if a path contains parent directory references
152
+ */
153
+ function hasParentDirReference(path: string): boolean {
154
+ // Check for .. in path components
155
+ const parts = path.split("/");
156
+ return parts.some((part) => part === ".." || part.includes("\\.."));
157
+ }
158
+
159
+ /**
160
+ * Check if path attempts to escape using null bytes
161
+ */
162
+ function hasNullByte(path: string): boolean {
163
+ return path.includes("\0");
164
+ }
165
+
166
+ /**
167
+ * Calculate path depth (number of directories)
168
+ */
169
+ function calculatePathDepth(path: string): number {
170
+ return path.split("/").filter((p) => p.length > 0).length;
171
+ }
172
+
173
+ /**
174
+ * Validate that a resolved path stays within allowed base directories
175
+ */
176
+ function validatePathInAllowedDirs(
177
+ resolvedPath: string,
178
+ allowedDirs: string[],
179
+ ): boolean {
180
+ // Normalize all paths for comparison
181
+ const normalizedResolved = normalizePath(resolvedPath);
182
+ const normalizedAllowed = allowedDirs.map(normalizePath);
183
+
184
+ // Check if resolved path starts with any allowed directory
185
+ return normalizedAllowed.some((allowedDir) => {
186
+ // Ensure allowed directory ends with / for proper prefix matching
187
+ const prefix = allowedDir.endsWith("/") ? allowedDir : allowedDir + "/";
188
+ return (
189
+ normalizedResolved === allowedDir || normalizedResolved.startsWith(prefix)
190
+ );
191
+ });
192
+ }
193
+
194
+ /**
195
+ * Resolve a path relative to a base directory
196
+ * Throws if the result would escape the base directory
197
+ */
198
+ function resolveRelativePath(
199
+ baseDir: string,
200
+ inputPath: string,
201
+ ): string {
202
+ const normalizedBase = normalizePath(baseDir);
203
+ const normalizedInput = normalizePath(inputPath);
204
+
205
+ // If input is absolute, reject (unless allowed by options)
206
+ if (normalizedInput.startsWith("/")) {
207
+ throw new Error("Absolute paths are not allowed");
208
+ }
209
+
210
+ // Build full path
211
+ let fullPath = normalizedBase + "/" + normalizedInput;
212
+ fullPath = normalizePath(fullPath);
213
+
214
+ // Check for parent directory references in the full path
215
+ if (hasParentDirReference(fullPath)) {
216
+ throw new Error("Path contains parent directory references");
217
+ }
218
+
219
+ // Verify the path is still within base directory
220
+ if (!fullPath.startsWith(normalizedBase + "/") && fullPath !== normalizedBase) {
221
+ throw new Error("Path escapes allowed directory");
222
+ }
223
+
224
+ return fullPath;
225
+ }
226
+
227
+ /**
228
+ * Sanitize and validate a file path for security
229
+ *
230
+ * This function prevents path traversal attacks by:
231
+ * 1. Rejecting paths with .. components
232
+ * 2. Validating against allowed base directories
233
+ * 3. Normalizing paths to remove . and redundant /
234
+ * 4. Checking for null bytes and other escape sequences
235
+ * 5. Limiting path depth to prevent deep traversal
236
+ *
237
+ * @param inputPath - The user-provided path to sanitize
238
+ * @param options - Sanitization options
239
+ * @returns Sanitized absolute path
240
+ * @throws PathTraversalError if path is suspicious or invalid
241
+ *
242
+ * @example
243
+ * ```ts
244
+ * // Safe: within /root
245
+ * sanitizePath("project/file.txt", { user: "root" })
246
+ * // Returns: "/root/project/file.txt"
247
+ *
248
+ * // BLOCKED: attempts to escape
249
+ * sanitizePath("../../../etc/passwd", { user: "root" })
250
+ * // Throws: PathTraversalError
251
+ *
252
+ * // BLOCKED: null byte injection
253
+ * sanitizePath("file.txt\0../../../etc/passwd", { user: "root" })
254
+ * // Throws: PathTraversalError
255
+ * ```
256
+ */
257
+ export function sanitizePath(
258
+ inputPath: string,
259
+ options: SanitizePathOptions = {},
260
+ ): string {
261
+ const {
262
+ allowedBaseDirs,
263
+ user = "root",
264
+ allowAbsolutePaths = false,
265
+ maxDepth = 20,
266
+ logSuspicious = true,
267
+ } = options;
268
+
269
+ // Determine default allowed base directories based on user
270
+ const defaultAllowedDirs = user === "root"
271
+ ? ["/root"]
272
+ : [`/home/${user}`, "/tmp"];
273
+
274
+ const finalAllowedDirs = allowedBaseDirs || defaultAllowedDirs;
275
+
276
+ // ===== SECURITY CHECKS =====
277
+
278
+ // 1. Check for null byte injection (CWE-158)
279
+ if (hasNullByte(inputPath)) {
280
+ const error = "Path contains null byte (possible injection attempt)";
281
+ if (logSuspicious) {
282
+ logSecurityEvent(inputPath, "Null byte injection detected", "blocked");
283
+ }
284
+ throw new PathTraversalError(
285
+ "Invalid path: contains null byte",
286
+ inputPath,
287
+ error,
288
+ );
289
+ }
290
+
291
+ // 2. Check for parent directory references (CWE-22)
292
+ if (hasParentDirReference(inputPath)) {
293
+ const error = "Path contains parent directory reference (..)";
294
+ if (logSuspicious) {
295
+ logSecurityEvent(inputPath, "Path traversal attempt detected", "blocked");
296
+ }
297
+ throw new PathTraversalError(
298
+ "Path traversal blocked: parent directory references not allowed",
299
+ inputPath,
300
+ error,
301
+ );
302
+ }
303
+
304
+ // 3. Check for backslashes (Windows path separator, potential escape)
305
+ if (inputPath.includes("\\")) {
306
+ const error = "Path contains backslashes";
307
+ if (logSuspicious) {
308
+ logSecurityEvent(inputPath, "Backslash in path detected", "suspicious");
309
+ }
310
+ throw new PathTraversalError(
311
+ "Invalid path: backslashes not allowed",
312
+ inputPath,
313
+ error,
314
+ );
315
+ }
316
+
317
+ // 4. Check for URL-encoded characters (possible bypass attempt)
318
+ if (/%2e|%2f|%5c/i.test(inputPath)) {
319
+ const error = "Path contains URL-encoded characters";
320
+ if (logSuspicious) {
321
+ logSecurityEvent(inputPath, "URL encoding detected in path", "suspicious");
322
+ }
323
+ throw new PathTraversalError(
324
+ "Invalid path: URL-encoded characters not allowed",
325
+ inputPath,
326
+ error,
327
+ );
328
+ }
329
+
330
+ // 5. Check path depth
331
+ const pathDepth = calculatePathDepth(inputPath);
332
+ if (pathDepth > maxDepth) {
333
+ const error = `Path depth ${pathDepth} exceeds maximum ${maxDepth}`;
334
+ if (logSuspicious) {
335
+ logSecurityEvent(inputPath, error, "suspicious");
336
+ }
337
+ throw new PathTraversalError(
338
+ "Path too deep: possible traversal attempt",
339
+ inputPath,
340
+ error,
341
+ );
342
+ }
343
+
344
+ // 6. Check for suspicious patterns
345
+ const suspiciousPatterns = [
346
+ /\.\.[\/\\]/, // ../ or ..\
347
+ /\/\.\./, // /..
348
+ /\.\.$/, // ends with ..
349
+ /^\.\./, // starts with ..
350
+ ];
351
+
352
+ for (const pattern of suspiciousPatterns) {
353
+ if (pattern.test(inputPath)) {
354
+ const error = `Path matches suspicious pattern: ${pattern}`;
355
+ if (logSuspicious) {
356
+ logSecurityEvent(inputPath, error, "blocked");
357
+ }
358
+ throw new PathTraversalError(
359
+ "Path blocked: matches suspicious pattern",
360
+ inputPath,
361
+ error,
362
+ );
363
+ }
364
+ }
365
+
366
+ // ===== PATH RESOLUTION =====
367
+
368
+ let sanitizedPath: string | undefined;
369
+
370
+ if (inputPath.startsWith("/")) {
371
+ // Handle absolute paths
372
+ if (!allowAbsolutePaths) {
373
+ const error = "Absolute paths are not allowed";
374
+ if (logSuspicious) {
375
+ logSecurityEvent(inputPath, error, "blocked");
376
+ }
377
+ throw new PathTraversalError(
378
+ error + ": use relative paths only",
379
+ inputPath,
380
+ error,
381
+ );
382
+ }
383
+
384
+ // Normalize the absolute path
385
+ sanitizedPath = normalizePath(inputPath);
386
+
387
+ // Validate against allowed directories
388
+ if (!validatePathInAllowedDirs(sanitizedPath, finalAllowedDirs)) {
389
+ const error = `Absolute path not within allowed directories: ${finalAllowedDirs.join(", ")}`;
390
+ if (logSuspicious) {
391
+ logSecurityEvent(inputPath, error, "blocked");
392
+ }
393
+ throw new PathTraversalError(
394
+ "Access denied: path outside allowed directories",
395
+ inputPath,
396
+ error,
397
+ );
398
+ }
399
+ } else {
400
+ // Handle relative paths - resolve against each allowed base directory
401
+ // Use the first valid resolution
402
+ let resolved = false;
403
+
404
+ for (const baseDir of finalAllowedDirs) {
405
+ try {
406
+ sanitizedPath = resolveRelativePath(baseDir, inputPath);
407
+ resolved = true;
408
+ break;
409
+ } catch {
410
+ // Try next base directory
411
+ continue;
412
+ }
413
+ }
414
+
415
+ if (!resolved) {
416
+ const error = `Path cannot be resolved within allowed directories: ${finalAllowedDirs.join(", ")}`;
417
+ if (logSuspicious) {
418
+ logSecurityEvent(inputPath, error, "blocked");
419
+ }
420
+ throw new PathTraversalError(
421
+ "Access denied: path outside allowed directories",
422
+ inputPath,
423
+ error,
424
+ );
425
+ }
426
+ }
427
+
428
+ // Final validation
429
+ if (!sanitizedPath || sanitizedPath.length === 0) {
430
+ throw new PathTraversalError(
431
+ "Invalid path: resulted in empty path",
432
+ inputPath,
433
+ "Empty path after sanitization",
434
+ );
435
+ }
436
+
437
+ return sanitizedPath;
438
+ }
439
+
440
+ export type FileType = "file" | "directory";
441
+
442
+ export interface RemoteFile {
443
+ name: string;
444
+ path: string;
445
+ size: string;
446
+ modified: string;
447
+ type: FileType;
448
+ }
449
+
450
+ export type PreviewType = "text" | "image" | "binary" | "error";
451
+
452
+ export interface FilePreview {
453
+ type: PreviewType;
454
+ content?: string;
455
+ error?: string;
456
+ }
457
+
458
+ /**
459
+ * List files in a directory on remote server
460
+ * @param path - Directory path to list (default: .)
461
+ * @param options - SSH connection options
462
+ * @returns List of files with metadata
463
+ * @throws PathTraversalError if path attempts to escape allowed directories
464
+ * @throws SSHError if SSH command fails
465
+ */
466
+ export async function listFiles(
467
+ path: string = ".",
468
+ options: SSHOptions,
469
+ ): Promise<RemoteFile[]> {
470
+ const { host, user = "root", timeout = 5 } = options;
471
+
472
+ // SECURITY: Sanitize path to prevent directory traversal attacks
473
+ let sanitizedPath: string;
474
+ try {
475
+ sanitizedPath = sanitizePath(path, {
476
+ user,
477
+ allowAbsolutePaths: false,
478
+ logSuspicious: true,
479
+ });
480
+ } catch (error) {
481
+ if (error instanceof PathTraversalError) {
482
+ // Re-throw path traversal errors with security context
483
+ throw error;
484
+ }
485
+ throw new SSHError(`Failed to sanitize path: ${path}`, error);
486
+ }
487
+
488
+ try {
489
+ // Use sanitized path in command
490
+ const command = `ls -la "${sanitizedPath}" 2>/dev/null || echo "FAILED"`;
491
+ const output = await execSSH(command, { host, user, timeout });
492
+
493
+ if (output === "FAILED" || output === "0") {
494
+ throw new SSHError(
495
+ `Failed to list directory: ${sanitizedPath} (original: ${path})`,
496
+ );
497
+ }
498
+
499
+ const lines = output.split("\n").slice(1); // Skip first line (total)
500
+ const files: RemoteFile[] = [];
501
+
502
+ for (const line of lines) {
503
+ if (!line.trim()) continue;
504
+
505
+ const parts = line.split(/\s+/);
506
+ if (parts.length < 8) continue;
507
+
508
+ const permissions = parts[0];
509
+ const isDir = permissions.startsWith("d");
510
+ const fileName = parts[8]?.split(" ->")[0] || parts[8]; // Handle symlinks
511
+
512
+ // Build file path based on sanitized base path
513
+ const filePath = `${sanitizedPath}/${fileName}`.replace(/\/\//g, "/");
514
+
515
+ files.push({
516
+ name: fileName,
517
+ path: filePath,
518
+ size: isDir ? "-" : parts[4],
519
+ modified:
520
+ parts[5] +
521
+ " " +
522
+ parts[6] +
523
+ " " +
524
+ parts[7]?.split(".").slice(0, 2).join(":") || "",
525
+ type: isDir ? "directory" : "file",
526
+ });
527
+ }
528
+
529
+ return files;
530
+ } catch (error) {
531
+ throw new SSHError(`Failed to list files: ${path}`, error);
532
+ }
533
+ }
534
+
535
+ /**
536
+ * Preview a file's content from remote server
537
+ * @param filePath - Path to the file to preview
538
+ * @param options - SSH connection options
539
+ * @returns File content for preview
540
+ * @throws PathTraversalError if path attempts to escape allowed directories
541
+ * @throws SSHError if SSH command fails
542
+ */
543
+ export async function previewFile(
544
+ filePath: string,
545
+ options: SSHOptions,
546
+ ): Promise<FilePreview> {
547
+ const { host, user = "root", timeout = 10 } = options;
548
+
549
+ // SECURITY: Sanitize path to prevent directory traversal attacks
550
+ let absolutePath: string;
551
+ try {
552
+ absolutePath = sanitizePath(filePath, {
553
+ user,
554
+ allowAbsolutePaths: false,
555
+ logSuspicious: true,
556
+ });
557
+ } catch (error) {
558
+ if (error instanceof PathTraversalError) {
559
+ // Re-throw path traversal errors with security context
560
+ return {
561
+ type: "error",
562
+ error: "Access denied: path outside allowed directories",
563
+ };
564
+ }
565
+ return { type: "error", error: "Failed to validate file path" };
566
+ }
567
+
568
+ try {
569
+ // Check if file exists (using sanitized path)
570
+ const checkCmd = `test -f "${absolutePath}" && echo "EXISTS" || echo "NOFILE"`;
571
+ const checkOutput = await execSSH(checkCmd, { host, user, timeout });
572
+
573
+ if (checkOutput !== "EXISTS") {
574
+ return { type: "error", error: "File not found" };
575
+ }
576
+
577
+ // Try to read file content to determine if it's text or binary
578
+ const readCmd = `cat "${absolutePath}" 2>/dev/null || echo "READFAIL"`;
579
+ const content = await execSSH(readCmd, { host, user, timeout: 15 });
580
+
581
+ if (content === "READFAIL" || content === "0") {
582
+ return { type: "error", error: "Failed to read file content" };
583
+ }
584
+
585
+ // Determine if content is text or binary by checking for null bytes and non-printable characters
586
+ const hasNullBytes = content.includes("\u0000");
587
+ // Count non-printable ASCII characters (excluding whitespace and common text chars)
588
+ const nonPrintableCount = (content.match(/[\x00-\x08\x0E-\x1F]/g) || [])
589
+ .length;
590
+ const isLikelyBinary =
591
+ hasNullBytes ||
592
+ (content.length > 0 && nonPrintableCount / content.length > 0.3);
593
+
594
+ // Check for image file extensions
595
+ const imageExtensions = [
596
+ ".png",
597
+ ".jpg",
598
+ ".jpeg",
599
+ ".gif",
600
+ ".bmp",
601
+ ".svg",
602
+ ".webp",
603
+ ".ico",
604
+ ];
605
+ const isImage = imageExtensions.some((ext) =>
606
+ absolutePath.toLowerCase().endsWith(ext),
607
+ );
608
+
609
+ if (isImage) {
610
+ return { type: "image", content: "[Image file - download to view]" };
611
+ } else if (isLikelyBinary) {
612
+ return { type: "binary", content: "[Binary file - download to view]" };
613
+ } else {
614
+ // Text file - limit content size for preview
615
+ const maxPreviewSize = 10000; // 10KB max for preview
616
+ const trimmedContent =
617
+ content.length > maxPreviewSize
618
+ ? content.slice(0, maxPreviewSize) + "\n\n... (truncated)"
619
+ : content;
620
+
621
+ return {
622
+ type: "text",
623
+ content: trimmedContent,
624
+ };
625
+ }
626
+ } catch (error) {
627
+ // Log the error for security auditing
628
+ console.error(`[File Preview Error] path="${filePath}", sanitized="${absolutePath}", error=${error}`);
629
+
630
+ // Return error without exposing internal details
631
+ if (error instanceof SSHError || error instanceof PathTraversalError) {
632
+ return { type: "error", error: "Access denied" };
633
+ }
634
+ return { type: "error", error: "Failed to preview file" };
635
+ }
636
+ }