webtun 1.5.2 → 1.5.4

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/server.js CHANGED
@@ -85,12 +85,22 @@ function mimeLookup(filePath) {
85
85
  }
86
86
 
87
87
  // In-memory rate limiter factory
88
+ // NOTE: Behind cloudflared tunnel every remote IP appears as 127.0.0.1 (tunnel collapses to loopback).
89
+ // We use req.ip (Express respects app.set('trust proxy')) so limiter correctly respects trust proxy config.
90
+ // All tunnel users share one bucket when behind loopback — consider per-token bucket if multi-tenant.
91
+ // Map size cap prevents blowup via spoofed X-Forwarded-For (now unused) or IP rotation.
88
92
  const rateLimitWindows = new Map();
89
93
  function createRateLimiter(opts) {
90
94
  return (req, res, next) => {
91
95
  if (opts.skipWhenNoPin && !PIN) return next();
92
96
  const now = Date.now();
93
- const key = (req.headers['x-forwarded-for'] || '').split(',')[0].trim() || req.socket.remoteAddress || 'default';
97
+ // Use Express req.ip which respects trust proxy; avoids manual X-Forwarded-For spoofing (F8,F44)
98
+ const key = req.ip || req.socket.remoteAddress || 'default';
99
+ // Cap Map size to prevent memory exhaustion (F44 blowup) — evict oldest entry
100
+ if (rateLimitWindows.size > 10000) {
101
+ const firstKey = rateLimitWindows.keys().next().value;
102
+ if (firstKey !== undefined) rateLimitWindows.delete(firstKey);
103
+ }
94
104
  let win = rateLimitWindows.get(key);
95
105
  if (!win || now > win.resetAt) {
96
106
  win = { count: 0, resetAt: now + (opts.windowMs || 10000) };
@@ -122,8 +132,26 @@ const SHELL = (os.platform() === 'win32' && !process.env.WEBTUN_SHELL)
122
132
  ? 'powershell.exe'
123
133
  : (process.env.SHELL || (os.platform() === 'win32' ? 'powershell.exe' : (fs.existsSync('/bin/bash') ? '/bin/bash' : 'sh')));
124
134
  const HOST = process.env.HOST || '0.0.0.0';
125
- const WORKSPACE_ROOT = process.env.WORKSPACE_ROOT ? path.resolve(process.env.WORKSPACE_ROOT) : os.homedir();
135
+ const ALLOW_FULL_FS = process.env.ALLOW_FULL_FS === 'true';
136
+ const WORKSPACE_ROOT = (() => {
137
+ let ws = process.env.WORKSPACE_ROOT ? path.resolve(process.env.WORKSPACE_ROOT) : os.homedir();
138
+ if (!ws || ws.trim() === '') ws = os.homedir() || process.cwd();
139
+ try {
140
+ if (fs.existsSync(ws)) {
141
+ const st = fs.statSync(ws);
142
+ if (!st.isDirectory()) ws = os.homedir() || process.cwd();
143
+ }
144
+ // if not exists, keep as is — mkdir will be attempted later or fallback
145
+ // ensure parent exists: if ws doesn't exist and ALLOW_FULL_FS false, fallback to homedir
146
+ if (!fs.existsSync(ws) && !ALLOW_FULL_FS) {
147
+ const fallback = os.homedir() || process.cwd();
148
+ if (fallback && fs.existsSync(fallback)) ws = fallback;
149
+ }
150
+ } catch { ws = os.homedir() || process.cwd(); }
151
+ return path.resolve(ws);
152
+ })();
126
153
 
154
+ // Global JSON limit 50MB — per-route guards (e.g., write 10MB, history 1k) are stricter (F42)
127
155
  app.use(express.json({ limit: '50mb' }));
128
156
  app.use(express.static(path.join(__dirname, 'public')));
129
157
 
@@ -152,14 +180,29 @@ app.set('trust proxy', process.env.TRUST_PROXY === 'true' ? 1 : 'loopback');
152
180
 
153
181
  function constantTimeEqual(a, b) {
154
182
  if (typeof a !== 'string' || typeof b !== 'string') return false;
155
- if (a.length !== b.length) return false;
156
- return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
183
+ const bufA = Buffer.from(a);
184
+ const bufB = Buffer.from(b);
185
+ if (bufA.length === bufB.length) {
186
+ return crypto.timingSafeEqual(bufA, bufB);
187
+ }
188
+ // Length mismatch: still do constant-time compare on padded buffers to avoid length leak (F7)
189
+ const len = Math.max(bufA.length, bufB.length);
190
+ const paddedA = Buffer.alloc(len, 0);
191
+ const paddedB = Buffer.alloc(len, 0);
192
+ bufA.copy(paddedA);
193
+ bufB.copy(paddedB);
194
+ // Always do timingSafeEqual then return false (constant-time failure)
195
+ crypto.timingSafeEqual(paddedA, paddedB);
196
+ return false;
157
197
  }
158
198
 
159
199
  // ── Auth ──────────────────────────────────────────────────────────────
160
200
  function checkPin(req, res, next) {
161
201
  if (!PIN) return next();
162
- const token = (req.headers['x-pin-token'] || req.query.token || '').trim();
202
+ // Validate token is string to prevent array injection (?token=a&token=b) (F45)
203
+ const raw = req.headers['x-pin-token'] || req.query.token;
204
+ const token = typeof raw === 'string' ? raw.trim() : '';
205
+ // Note: query token kept for backward compat (WS needs ?token=) but header preferred; query may leak to logs.
163
206
  if (token && constantTimeEqual(token, PIN)) return next();
164
207
  res.status(401).json({ error: 'Unauthorized' });
165
208
  }
@@ -190,16 +233,33 @@ app.get('/api/home', checkPin, (req, res) => {
190
233
  const fsPromises = fs.promises;
191
234
 
192
235
  function resolvePath(targetPath) {
193
- if (!targetPath) return WORKSPACE_ROOT;
194
- return path.resolve(targetPath);
236
+ if (Array.isArray(targetPath)) { const e = new Error('Invalid path: array not allowed'); e.status = 400; throw e; }
237
+ if (targetPath == null) return WORKSPACE_ROOT;
238
+ if (typeof targetPath !== 'string') { const e = new Error('Invalid path type'); e.status = 400; throw e; }
239
+ if (targetPath.includes('\0')) { const e = new Error('Invalid path: null byte'); e.status = 400; throw e; }
240
+ if (!targetPath || targetPath.trim() === '') return WORKSPACE_ROOT;
241
+ const resolved = path.resolve(targetPath);
242
+ if (!ALLOW_FULL_FS && !pathContained(WORKSPACE_ROOT, resolved)) {
243
+ const e = new Error('Access denied: path outside workspace'); e.status = 403; throw e;
244
+ }
245
+ return resolved;
195
246
  }
196
247
 
197
248
  // Resolve path and follow symlinks to their real location.
198
249
  // Used for write operations so files end up at the intended real path.
199
250
  function realPath(targetPath) {
200
- if (!targetPath) return WORKSPACE_ROOT;
251
+ if (Array.isArray(targetPath)) { const e = new Error('Invalid path: array not allowed'); e.status = 400; throw e; }
252
+ if (targetPath == null) return WORKSPACE_ROOT;
253
+ if (typeof targetPath !== 'string') { const e = new Error('Invalid path type'); e.status = 400; throw e; }
254
+ if (targetPath.includes('\0')) { const e = new Error('Invalid path: null byte'); e.status = 400; throw e; }
255
+ if (!targetPath || targetPath.trim() === '') return WORKSPACE_ROOT;
201
256
  const resolved = path.resolve(targetPath);
202
- try { return fs.realpathSync(resolved); } catch { return resolved; }
257
+ let real = resolved;
258
+ try { real = fs.realpathSync(resolved); } catch {}
259
+ if (!ALLOW_FULL_FS && !pathContained(WORKSPACE_ROOT, real)) {
260
+ const e = new Error('Access denied: path outside workspace (symlink)'); e.status = 403; throw e;
261
+ }
262
+ return real;
203
263
  }
204
264
 
205
265
  // Case-aware path containment (Windows paths are case-insensitive).
@@ -228,44 +288,123 @@ async function renameWithFallback(src, dst) {
228
288
  }
229
289
  }
230
290
 
231
- async function dirSize(dir) {
291
+ async function dirSize(dir, maxDepth = 10) {
232
292
  let total = 0;
233
- async function walk(d) {
293
+ let entryCount = 0;
294
+ const MAX_ENTRIES = 100000;
295
+ const visited = new Set();
296
+ const CONCURRENCY = 32;
297
+ // Helper to process entries with concurrency cap
298
+ async function walk(d, depth) {
299
+ if (depth > maxDepth) return;
300
+ if (entryCount > MAX_ENTRIES) return;
301
+ let real;
302
+ try { real = fs.realpathSync(d); } catch { real = path.resolve(d); }
303
+ if (visited.has(real)) return;
304
+ visited.add(real);
234
305
  let entries;
235
306
  try { entries = await fsPromises.readdir(d, { withFileTypes: true }); } catch { return; }
236
- await Promise.all(entries.map(async e => {
237
- const full = path.join(d, e.name);
238
- try {
239
- if (e.isDirectory()) await walk(full);
240
- else if (e.isFile()) {
241
- const st = await fsPromises.stat(full);
242
- total += st.size;
243
- }
244
- } catch {}
245
- }));
307
+ entryCount += entries.length;
308
+ if (entryCount > MAX_ENTRIES) return;
309
+ // Process in chunks to limit concurrency (32 parallel stat)
310
+ for (let i = 0; i < entries.length; i += CONCURRENCY) {
311
+ const chunk = entries.slice(i, i + CONCURRENCY);
312
+ await Promise.all(chunk.map(async e => {
313
+ const full = path.join(d, e.name);
314
+ try {
315
+ if (e.isDirectory()) {
316
+ // Check symlink for loops — if symlink to dir, resolve and check visited
317
+ let lst;
318
+ try { lst = await fsPromises.lstat(full); } catch { return; }
319
+ if (lst.isSymbolicLink()) {
320
+ let targetReal;
321
+ try { targetReal = fs.realpathSync(full); } catch { return; }
322
+ if (visited.has(targetReal)) return;
323
+ // Check containment if sandbox enabled — skip if outside
324
+ if (!ALLOW_FULL_FS && !pathContained(WORKSPACE_ROOT, targetReal)) return;
325
+ await walk(full, depth + 1);
326
+ } else {
327
+ await walk(full, depth + 1);
328
+ }
329
+ } else if (e.isFile()) {
330
+ const st = await fsPromises.stat(full);
331
+ total += st.size;
332
+ } else {
333
+ // For symlink files, use lstat then stat only if needed
334
+ try {
335
+ const lst = await fsPromises.lstat(full);
336
+ if (lst.isSymbolicLink()) return; // skip symlink files to avoid outside read
337
+ if (lst.isFile()) {
338
+ const st = await fsPromises.stat(full);
339
+ total += st.size;
340
+ }
341
+ } catch {}
342
+ }
343
+ } catch {}
344
+ }));
345
+ }
246
346
  }
247
- await walk(dir);
347
+ await walk(dir, 0);
248
348
  return total;
249
349
  }
250
350
 
251
351
  function createZipArchive(entries, zipPath) {
252
- return new Promise((resolve, reject) => {
253
- const output = fs.createWriteStream(zipPath);
254
- const archive = new ZipArchive({ zlib: { level: 6 } });
255
- output.on('close', () => resolve());
256
- output.on('error', reject);
257
- archive.on('error', reject);
258
- archive.pipe(output);
259
- for (const entry of entries) {
260
- try {
261
- const st = fs.statSync(entry.fullPath);
262
- if (st.isDirectory()) archive.directory(entry.fullPath, entry.nameInZip);
263
- else archive.file(entry.fullPath, { name: entry.nameInZip });
264
- } catch (e) {
265
- return reject(e);
352
+ return new Promise(async (resolve, reject) => {
353
+ try {
354
+ // Ensure destination inside workspace (F63)
355
+ if (!ALLOW_FULL_FS && !pathContained(WORKSPACE_ROOT, zipPath)) {
356
+ const e = new Error('Access denied: zip destination outside workspace'); e.status = 403; return reject(e);
266
357
  }
267
- }
268
- archive.finalize();
358
+ // Per-entry checks and total size guard (>1GB reject) (F63)
359
+ const MAX_TOTAL = 1 * 1024 * 1024 * 1024;
360
+ let totalSize = 0;
361
+ for (const entry of entries) {
362
+ let lst;
363
+ try { lst = fs.lstatSync(entry.fullPath); } catch (e) { return reject(e); }
364
+ if (lst.isSymbolicLink()) {
365
+ // Skip symlink that points outside workspace (leak protection) (F63)
366
+ let real;
367
+ try { real = fs.realpathSync(entry.fullPath); } catch { continue; }
368
+ if (!ALLOW_FULL_FS && !pathContained(WORKSPACE_ROOT, real)) continue;
369
+ // Skip symlinks even inside to avoid zip traversing outside via link
370
+ continue;
371
+ }
372
+ let size = 0;
373
+ if (lst.isDirectory()) {
374
+ size = await dirSize(entry.fullPath);
375
+ } else if (lst.isFile()) {
376
+ size = lst.size;
377
+ } else {
378
+ continue;
379
+ }
380
+ totalSize += size;
381
+ if (totalSize > MAX_TOTAL) {
382
+ const e = new Error('Total size exceeds 1GB'); e.status = 413; return reject(e);
383
+ }
384
+ if (!ALLOW_FULL_FS && !pathContained(WORKSPACE_ROOT, entry.fullPath)) {
385
+ const e = new Error('Access denied: entry outside workspace'); e.status = 403; return reject(e);
386
+ }
387
+ }
388
+ const output = fs.createWriteStream(zipPath);
389
+ const archive = new ZipArchive({ zlib: { level: 6 } });
390
+ output.on('close', () => resolve());
391
+ output.on('error', reject);
392
+ archive.on('error', reject);
393
+ archive.pipe(output);
394
+ for (const entry of entries) {
395
+ try {
396
+ let lst;
397
+ try { lst = fs.lstatSync(entry.fullPath); } catch (e) { return reject(e); }
398
+ if (lst.isSymbolicLink()) continue;
399
+ const st = fs.statSync(entry.fullPath);
400
+ if (st.isDirectory()) archive.directory(entry.fullPath, entry.nameInZip);
401
+ else archive.file(entry.fullPath, { name: entry.nameInZip });
402
+ } catch (e) {
403
+ return reject(e);
404
+ }
405
+ }
406
+ archive.finalize();
407
+ } catch (e) { reject(e); }
269
408
  });
270
409
  }
271
410
 
@@ -278,21 +417,48 @@ function streamZipDirectory(dirPath, res) {
278
417
  archive.pipe(res);
279
418
  archive.directory(dirPath, path.basename(dirPath));
280
419
  archive.finalize();
420
+ return archive;
281
421
  }
282
422
 
283
423
  function extractZip(zipPath, destDir) {
284
424
  return new Promise((resolve, reject) => {
425
+ // Validate zip magic (PK header) before extraction (F64)
426
+ try {
427
+ const fd = fs.openSync(zipPath, 'r');
428
+ const buf = Buffer.alloc(4);
429
+ const bytes = fs.readSync(fd, buf, 0, 4, 0);
430
+ fs.closeSync(fd);
431
+ if (bytes < 4 || !(buf[0] === 0x50 && buf[1] === 0x4B && (buf[2] === 0x03 || buf[2] === 0x05 || buf[2] === 0x07) && (buf[3] === 0x04 || buf[3] === 0x06 || buf[3] === 0x08))) {
432
+ return reject(Object.assign(new Error('Not a zip file (bad magic)'), { status: 400 }));
433
+ }
434
+ } catch (e) { return reject(e); }
285
435
  yauzl.open(zipPath, { lazyEntries: true }, (err, zipfile) => {
286
436
  if (err) return reject(err);
437
+ let entryCount = 0;
438
+ let totalUncompressed = 0;
439
+ const MAX_ENTRIES = 1000;
440
+ const MAX_TOTAL = 1 * 1024 * 1024 * 1024;
287
441
  zipfile.readEntry();
288
442
  zipfile.on('entry', entry => {
443
+ entryCount++;
444
+ if (entryCount > MAX_ENTRIES) {
445
+ zipfile.close();
446
+ const e = new Error('Too many entries in zip (max 1000)'); e.status = 413; return reject(e);
447
+ }
448
+ totalUncompressed += entry.uncompressedSize;
449
+ if (totalUncompressed > MAX_TOTAL) {
450
+ zipfile.close();
451
+ const e = new Error('Uncompressed size exceeds 1GB'); e.status = 413; return reject(e);
452
+ }
289
453
  const entryName = entry.fileName.replace(/\\/g, '/');
290
454
  const entryPath = path.normalize(entryName);
291
455
  if (entryPath.startsWith('..') || path.isAbsolute(entryPath) || entryName.includes('\0')) {
456
+ zipfile.close();
292
457
  return reject(new Error('Invalid zip entry: ' + entry.fileName));
293
458
  }
294
459
  const target = path.join(destDir, entryPath);
295
460
  if (!pathContained(destDir, target)) {
461
+ zipfile.close();
296
462
  return reject(new Error('Zip entry escapes destination directory'));
297
463
  }
298
464
  if (/\/$/.test(entryName)) {
@@ -302,10 +468,10 @@ function extractZip(zipPath, destDir) {
302
468
  }
303
469
  fs.mkdirSync(path.dirname(target), { recursive: true });
304
470
  zipfile.openReadStream(entry, (err2, readStream) => {
305
- if (err2) return reject(err2);
471
+ if (err2) { zipfile.close(); return reject(err2); }
306
472
  const writeStream = fs.createWriteStream(target);
307
- readStream.on('error', reject);
308
- writeStream.on('error', reject);
473
+ readStream.on('error', (e) => { zipfile.close(); reject(e); });
474
+ writeStream.on('error', (e) => { zipfile.close(); reject(e); });
309
475
  writeStream.on('close', () => zipfile.readEntry());
310
476
  readStream.pipe(writeStream);
311
477
  });
@@ -485,7 +651,8 @@ app.get('/api/files', checkPin, async (req, res) => {
485
651
  const parent = path.dirname(dir);
486
652
  res.json({ path: dir, parent: parent !== dir ? parent : null, files });
487
653
  } catch (e) {
488
- res.status(500).json({ error: e.message });
654
+ const status = e.status || 500;
655
+ res.status(status).json({ error: e.message });
489
656
  }
490
657
  });
491
658
 
@@ -495,12 +662,19 @@ app.post('/api/files/rename', checkPin, async (req, res) => {
495
662
  console.warn('POST /api/files/rename 400 — body requires { oldPath, newName }. Example: { "oldPath": "/home/user/file.txt", "newName": "renamed.txt" }');
496
663
  return res.status(400).json({ error: 'oldPath and newName are required', usage: 'POST JSON { "oldPath": "<path>", "newName": "<name>" }' });
497
664
  }
665
+ const newName = req.body.newName;
666
+ if (typeof newName !== 'string' || !newName.trim() || newName === '.' || newName.length > 255 || newName.includes('/') || newName.includes('\\') || newName.includes('..')) {
667
+ return res.status(400).json({ error: 'Invalid newName: must not contain / \\ .. , be empty, "." or >255 chars' });
668
+ }
669
+ // also reject if newName contains null byte
670
+ if (newName.includes('\0')) return res.status(400).json({ error: 'Invalid newName: null byte' });
498
671
  const oldPath = realPath(req.body.oldPath);
499
- const newPath = realPath(path.join(path.dirname(oldPath), req.body.newName));
672
+ const newPath = realPath(path.join(path.dirname(oldPath), newName));
500
673
  await renameWithFallback(oldPath, newPath);
501
674
  res.json({ success: true, newPath });
502
675
  } catch (e) {
503
- res.status(500).json({ error: e.message });
676
+ const status = e.status || 500;
677
+ res.status(status).json({ error: e.message });
504
678
  }
505
679
  });
506
680
 
@@ -568,13 +742,29 @@ async function resolveCopyMove(src, dst, conflict, isMove) {
568
742
  }
569
743
 
570
744
  async function handleCopyMove(req, res, isMove) {
571
- if (!req.body.source || !req.body.destination) {
572
- return res.status(400).json({ error: 'source and destination are required', usage: 'POST JSON { "source": "<src>", "destination": "<dst>", "conflict": "replace|skip|keep_both|merge|cancel" }' });
745
+ try {
746
+ if (!req.body.source || !req.body.destination) {
747
+ return res.status(400).json({ error: 'source and destination are required', usage: 'POST JSON { "source": "<src>", "destination": "<dst>", "conflict": "replace|skip|keep_both|merge|cancel" }' });
748
+ }
749
+ const src = realPath(req.body.source);
750
+ const dst = resolvePath(req.body.destination);
751
+ // Sandbox guards (F53)
752
+ if (!ALLOW_FULL_FS) {
753
+ if (!pathContained(WORKSPACE_ROOT, dst)) return res.status(403).json({ error: 'Access denied: destination outside workspace' });
754
+ // If dst exists via symlink, check realpath as well
755
+ try {
756
+ const realDst = fs.realpathSync(dst);
757
+ if (!pathContained(WORKSPACE_ROOT, realDst)) return res.status(403).json({ error: 'Access denied: destination symlink outside workspace' });
758
+ } catch {}
759
+ }
760
+ if (src === dst) return res.status(400).json({ error: 'source and destination are same' });
761
+ if (pathContained(src, dst)) return res.status(400).json({ error: 'destination inside source' });
762
+ const result = await resolveCopyMove(src, dst, req.body.conflict || '', isMove);
763
+ res.json(result);
764
+ } catch (e) {
765
+ const status = e.status || 500;
766
+ res.status(status).json({ error: e.message });
573
767
  }
574
- const src = realPath(req.body.source);
575
- const dst = resolvePath(req.body.destination);
576
- const result = await resolveCopyMove(src, dst, req.body.conflict || '', isMove);
577
- res.json(result);
578
768
  }
579
769
 
580
770
  app.post('/api/files/copy', checkPin, (req, res) => handleCopyMove(req, res, false));
@@ -587,15 +777,18 @@ app.delete('/api/files', checkPin, async (req, res) => {
587
777
  return res.status(400).json({ error: 'path is required', usage: 'DELETE /api/files?path=<path>' });
588
778
  }
589
779
  const p = realPath(req.query.path);
590
- const st = await fsPromises.stat(p);
591
- if (st.isDirectory()) {
780
+ const lst = await fsPromises.lstat(p);
781
+ if (lst.isSymbolicLink()) {
782
+ await fsPromises.unlink(p);
783
+ } else if (lst.isDirectory()) {
592
784
  await fsPromises.rm(p, { recursive: true, force: true });
593
785
  } else {
594
786
  await fsPromises.unlink(p);
595
787
  }
596
788
  res.json({ success: true });
597
789
  } catch (e) {
598
- res.status(500).json({ error: e.message });
790
+ const status = e.status || 500;
791
+ res.status(status).json({ error: e.message });
599
792
  }
600
793
  });
601
794
 
@@ -609,7 +802,8 @@ app.post('/api/files/mkdir', checkPin, async (req, res) => {
609
802
  await fsPromises.mkdir(p, { recursive: true });
610
803
  res.json({ success: true });
611
804
  } catch (e) {
612
- res.status(500).json({ error: e.message });
805
+ const status = e.status || 500;
806
+ res.status(status).json({ error: e.message });
613
807
  }
614
808
  });
615
809
 
@@ -623,7 +817,8 @@ app.post('/api/files/touch', checkPin, async (req, res) => {
623
817
  await fsPromises.writeFile(p, '', { flag: 'a' });
624
818
  res.json({ success: true });
625
819
  } catch (e) {
626
- res.status(500).json({ error: e.message });
820
+ const status = e.status || 500;
821
+ res.status(status).json({ error: e.message });
627
822
  }
628
823
  });
629
824
 
@@ -634,21 +829,32 @@ app.post('/api/files/zip', checkPin, async (req, res) => {
634
829
  return res.status(400).json({ error: 'path is required', usage: 'POST JSON { "path": "<file_or_dir>" }' });
635
830
  }
636
831
  const p = realPath(req.body.path);
637
- await fsPromises.stat(p);
832
+ const st = await fsPromises.stat(p);
833
+ // Dest dir size guard (F63) — reject if dir >1GB
834
+ if (st.isDirectory()) {
835
+ const sz = await dirSize(p);
836
+ if (sz > 1 * 1024 * 1024 * 1024) return res.status(413).json({ error: 'Directory too large to zip (max 1GB)' });
837
+ } else if (st.size > 1 * 1024 * 1024 * 1024) {
838
+ return res.status(413).json({ error: 'File too large to zip (max 1GB)' });
839
+ }
638
840
  const baseName = path.basename(p);
639
841
  let zipName = baseName + '.zip';
640
842
  let zipPath = path.join(path.dirname(p), zipName);
843
+ // Ensure zipPath inside workspace
844
+ if (!ALLOW_FULL_FS && !pathContained(WORKSPACE_ROOT, zipPath)) return res.status(403).json({ error: 'Access denied: zip destination outside workspace' });
641
845
  let counter = 1;
642
846
  while (true) {
643
847
  try { await fsPromises.access(zipPath); } catch { break; }
644
848
  zipName = baseName + ' (' + counter + ').zip';
645
849
  zipPath = path.join(path.dirname(p), zipName);
850
+ if (!ALLOW_FULL_FS && !pathContained(WORKSPACE_ROOT, zipPath)) return res.status(403).json({ error: 'Access denied' });
646
851
  counter++;
647
852
  }
648
853
  await createZipArchive([{ fullPath: p, nameInZip: baseName }], zipPath);
649
854
  res.json({ success: true, name: zipName });
650
855
  } catch (e) {
651
- res.status(500).json({ error: e.message });
856
+ const status = e.status || 500;
857
+ res.status(status).json({ error: e.message });
652
858
  }
653
859
  });
654
860
 
@@ -661,29 +867,45 @@ app.post('/api/files/unzip', checkPin, async (req, res) => {
661
867
  const p = realPath(req.body.path);
662
868
  const ext = path.extname(p).toLowerCase();
663
869
  if (ext !== '.zip') return res.status(400).json({ error: 'Not a zip file' });
870
+ // Validate zip magic (F64) — extractZip also checks, but early check here for 400 vs 500
871
+ try {
872
+ const fd = fs.openSync(p, 'r');
873
+ const buf = Buffer.alloc(4);
874
+ const bytes = fs.readSync(fd, buf, 0, 4, 0);
875
+ fs.closeSync(fd);
876
+ if (bytes < 4 || !(buf[0] === 0x50 && buf[1] === 0x4B)) {
877
+ return res.status(400).json({ error: 'Not a zip file (bad magic)' });
878
+ }
879
+ } catch {}
664
880
  const destDir = path.join(path.dirname(p), path.basename(p, '.zip'));
881
+ if (!ALLOW_FULL_FS && !pathContained(WORKSPACE_ROOT, destDir)) return res.status(403).json({ error: 'Access denied: destination outside workspace' });
665
882
  await fsPromises.mkdir(destDir, { recursive: true });
666
883
  try {
667
884
  await extractZip(p, destDir);
668
885
  } catch (e) {
669
- return res.status(500).json({ error: e.message });
886
+ // Rollback partial on failure (F64)
887
+ try { await fsPromises.rm(destDir, { recursive: true, force: true }); } catch {}
888
+ const status = e.status || 500;
889
+ return res.status(status).json({ error: e.message });
670
890
  }
671
891
  res.json({ success: true, dir: destDir });
672
892
  } catch (e) {
673
- res.status(500).json({ error: e.message });
893
+ const status = e.status || 500;
894
+ res.status(status).json({ error: e.message });
674
895
  }
675
896
  });
676
897
 
677
898
  app.get('/api/files/read', checkPin, async (req, res) => {
678
899
  try {
679
900
  const p = resolvePath(req.query.path);
680
- const [content, st] = await Promise.all([
681
- fsPromises.readFile(p, 'utf8'),
682
- fsPromises.stat(p)
683
- ]);
901
+ const st = await fsPromises.stat(p);
902
+ if (st.isDirectory()) return res.status(400).json({ error: 'Cannot read a directory' });
903
+ if (st.size > 10 * 1024 * 1024) return res.status(413).json({ error: 'File too large (max 10MB) - use download' });
904
+ const content = await fsPromises.readFile(p, 'utf8');
684
905
  res.json({ content, length: st.size });
685
906
  } catch (e) {
686
- res.status(500).json({ error: e.message });
907
+ const status = e.status || 500;
908
+ res.status(status).json({ error: e.message });
687
909
  }
688
910
  });
689
911
 
@@ -693,11 +915,18 @@ app.post('/api/files/write', checkPin, async (req, res) => {
693
915
  console.warn('POST /api/files/write 400 — body requires { path, content }. Example: { "path": "/home/user/file.txt", "content": "hello world" }');
694
916
  return res.status(400).json({ error: 'path is required', usage: 'POST JSON { "path": "<file>", "content": "<string>" }' });
695
917
  }
918
+ if (typeof req.body.content !== 'string') {
919
+ return res.status(400).json({ error: 'content must be a string' });
920
+ }
921
+ if (Buffer.byteLength(req.body.content, 'utf8') > 10 * 1024 * 1024) {
922
+ return res.status(413).json({ error: 'Content too large (max 10MB)' });
923
+ }
696
924
  const p = realPath(req.body.path);
697
925
  await fsPromises.writeFile(p, req.body.content, 'utf8');
698
926
  res.json({ success: true });
699
927
  } catch (e) {
700
- res.status(500).json({ error: e.message });
928
+ const status = e.status || 500;
929
+ res.status(status).json({ error: e.message });
701
930
  }
702
931
  });
703
932
 
@@ -707,14 +936,22 @@ app.get('/api/files/image', checkPin, async (req, res) => {
707
936
  const p = resolvePath(req.query.path);
708
937
  const mimeType = mimeLookup(p);
709
938
  res.setHeader('Content-Type', mimeType);
710
- res.setHeader('Cache-Control', 'private, max-age=3600');
939
+ // Avoid caching secrets served as octet-stream (F57)
940
+ if (mimeType === 'application/octet-stream') {
941
+ res.setHeader('Cache-Control', 'no-store');
942
+ } else {
943
+ res.setHeader('Cache-Control', 'private, max-age=3600');
944
+ }
711
945
  const stream = fs.createReadStream(p);
946
+ // Ensure stream destroyed when client aborts to avoid FD leak (F57)
947
+ req.on('close', () => { try { stream.destroy(); } catch {} });
712
948
  stream.on('error', err => {
713
- if (!res.headersSent) res.status(500).json({ error: err.message });
949
+ if (!res.headersSent) res.status(err.status || 500).json({ error: err.message });
950
+ else res.end();
714
951
  });
715
952
  stream.pipe(res);
716
953
  } catch (e) {
717
- if (!res.headersSent) res.status(500).json({ error: e.message });
954
+ if (!res.headersSent) res.status(e.status || 500).json({ error: e.message });
718
955
  }
719
956
  });
720
957
 
@@ -727,22 +964,28 @@ app.get('/api/files/download', checkPin, async (req, res) => {
727
964
  const p = realPath(req.query.path);
728
965
  const st = await fsPromises.stat(p);
729
966
  if (st.isDirectory()) {
967
+ const safeName = path.basename(p).replace(/["\r\n;]/g, '_') + '.zip';
730
968
  res.setHeader('Content-Type', 'application/zip');
731
- res.setHeader('Content-Disposition', `attachment; filename="${path.basename(p)}.zip"`);
732
- streamZipDirectory(p, res);
969
+ res.setHeader('Content-Disposition', `attachment; filename="${safeName}"`);
970
+ const arch = streamZipDirectory(p, res);
971
+ // Cleanup archiver when client aborts (F58)
972
+ res.on('close', () => { try { if (arch) arch.abort(); } catch {} });
733
973
  return;
734
974
  } else {
735
975
  const mimeType = mimeLookup(p);
976
+ const safeName = path.basename(p).replace(/["\r\n;]/g, '_');
736
977
  res.setHeader('Content-Type', mimeType);
737
- res.setHeader('Content-Disposition', `attachment; filename="${path.basename(p)}"`);
978
+ res.setHeader('Content-Disposition', `attachment; filename="${safeName}"`);
738
979
  const stream = fs.createReadStream(p);
980
+ req.on('close', () => { try { stream.destroy(); } catch {} });
739
981
  stream.on('error', err => {
740
- if (!res.headersSent) res.status(500).json({ error: err.message });
982
+ if (!res.headersSent) res.status(err.status || 500).json({ error: err.message });
983
+ else res.end();
741
984
  });
742
985
  stream.pipe(res);
743
986
  }
744
987
  } catch (e) {
745
- if (!res.headersSent) res.status(500).json({ error: e.message });
988
+ if (!res.headersSent) res.status(e.status || 500).json({ error: e.message });
746
989
  }
747
990
  });
748
991
 
@@ -754,10 +997,11 @@ app.post('/api/files/upload', checkPin, (req, res) => {
754
997
  } catch (e) {
755
998
  return res.status(403).json({ error: e.message });
756
999
  }
1000
+ const UNIFIED_SAFE_RE = /[^a-zA-Z0-9_.\-]/g;
757
1001
  const storage = multer.diskStorage({
758
1002
  destination: (req, file, cb) => {
759
1003
  try {
760
- const safeName = path.basename(file.originalname).replace(/[^a-zA-Z0-9_.\-]/g, '_');
1004
+ const safeName = path.basename(file.originalname).replace(UNIFIED_SAFE_RE, '_');
761
1005
  const finalDest = path.join(destDir, safeName);
762
1006
  if (!pathContained(destDir, finalDest)) {
763
1007
  return cb(new Error('Invalid upload destination'));
@@ -769,7 +1013,7 @@ app.post('/api/files/upload', checkPin, (req, res) => {
769
1013
  cb(err);
770
1014
  }
771
1015
  },
772
- filename: (_, file, cb) => cb(null, path.basename(file.originalname).replace(/[^\w\u00C0-\u024F\u0400-\u04FF\u4E00-\u9FFF\u3040-\u309F\u30A0-\u30FF\uAC00-\uD7AF_.\-]/g, '_'))
1016
+ filename: (_, file, cb) => cb(null, path.basename(file.originalname).replace(UNIFIED_SAFE_RE, '_'))
773
1017
  });
774
1018
  const upload = multer({ storage, limits: { fileSize: 500 * 1024 * 1024, files: 100 } }).array('files');
775
1019
  upload(req, res, err => {
@@ -778,6 +1022,10 @@ app.post('/api/files/upload', checkPin, (req, res) => {
778
1022
  });
779
1023
  });
780
1024
 
1025
+ // Cache for owner/group to avoid blocking execFileSync on every request (F60 trail)
1026
+ const _ownerCache = new Map();
1027
+ const _groupCache = new Map();
1028
+
781
1029
  // ── File stat / metadata ──────────────────────────────────────────────
782
1030
  app.get('/api/files/stat', checkPin, async (req, res) => {
783
1031
  try {
@@ -800,22 +1048,36 @@ app.get('/api/files/stat', checkPin, async (req, res) => {
800
1048
  isSymlink: lst ? lst.isSymbolicLink() : false,
801
1049
  isSocket: st.isSocket(), isFIFO: st.isFIFO(),
802
1050
  };
1051
+ // Use cache for owner (F60 trail) — still blocking but cached
803
1052
  try {
804
1053
  if (os.platform() === 'win32') {
805
1054
  stat.owner = String(st.uid);
1055
+ } else if (_ownerCache.has(st.uid)) {
1056
+ stat.owner = _ownerCache.get(st.uid);
806
1057
  } else {
807
- stat.owner = execFileSync('id', ['-nu', String(st.uid)], { encoding: 'utf8', stdio: 'pipe' }).trim();
1058
+ const owner = execFileSync('id', ['-nu', String(st.uid)], { encoding: 'utf8', stdio: 'pipe', timeout: 2000 }).trim();
1059
+ _ownerCache.set(st.uid, owner);
1060
+ if (_ownerCache.size > 500) { const k=_ownerCache.keys().next().value; _ownerCache.delete(k); }
1061
+ stat.owner = owner;
808
1062
  }
809
1063
  } catch { stat.owner = String(st.uid); }
810
1064
  try {
811
1065
  if (os.platform() === 'win32') {
812
1066
  stat.group = String(st.gid);
1067
+ } else if (_groupCache.has(st.gid)) {
1068
+ stat.group = _groupCache.get(st.gid);
813
1069
  } else if (os.platform() === 'darwin') {
814
- const dscl = execSync(`dscl . -read /Groups/${st.gid} RecordName 2>/dev/null`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
1070
+ const dscl = execSync(`dscl . -read /Groups/${st.gid} RecordName 2>/dev/null`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 2000 }).trim();
815
1071
  const m = dscl.match(/RecordName:\s*(.+)/);
816
- stat.group = m ? m[1].trim() : String(st.gid);
1072
+ const g = m ? m[1].trim() : String(st.gid);
1073
+ _groupCache.set(st.gid, g);
1074
+ if (_groupCache.size > 500) { const k=_groupCache.keys().next().value; _groupCache.delete(k); }
1075
+ stat.group = g;
817
1076
  } else {
818
- stat.group = execFileSync('getent', ['group', String(st.gid)], { encoding: 'utf8', stdio: 'pipe' }).split(':')[0];
1077
+ const g = execFileSync('getent', ['group', String(st.gid)], { encoding: 'utf8', stdio: 'pipe', timeout: 2000 }).split(':')[0];
1078
+ _groupCache.set(st.gid, g);
1079
+ if (_groupCache.size > 500) { const k=_groupCache.keys().next().value; _groupCache.delete(k); }
1080
+ stat.group = g;
819
1081
  }
820
1082
  } catch { stat.group = String(st.gid); }
821
1083
  try {
@@ -824,7 +1086,10 @@ app.get('/api/files/stat', checkPin, async (req, res) => {
824
1086
  } catch {}
825
1087
  res.json(stat);
826
1088
  } catch (e) {
827
- res.status(500).json({ error: e.message });
1089
+ const status = e.status || 500;
1090
+ // Avoid leaking absolute path in error (F60)
1091
+ const msg = e.message && e.message.includes(WORKSPACE_ROOT) ? e.message.replace(WORKSPACE_ROOT, '~') : e.message;
1092
+ res.status(status).json({ error: msg });
828
1093
  }
829
1094
  });
830
1095
 
@@ -835,6 +1100,11 @@ app.get('/api/files/size', checkPin, async (req, res) => {
835
1100
  return res.status(400).json({ error: 'path is required', usage: 'GET /api/files/size?path=<dir>' });
836
1101
  }
837
1102
  const p = realPath(req.query.path);
1103
+ const lst = await fsPromises.lstat(p);
1104
+ if (lst.isSymbolicLink()) {
1105
+ // Don't follow symlink for size — report link size
1106
+ return res.json({ path: p, size: lst.size, isDir: false, isSymlink: true });
1107
+ }
838
1108
  const st = await fsPromises.stat(p);
839
1109
  if (!st.isDirectory()) {
840
1110
  return res.json({ path: p, size: st.size, isDir: false });
@@ -842,7 +1112,8 @@ app.get('/api/files/size', checkPin, async (req, res) => {
842
1112
  const size = await dirSize(p);
843
1113
  res.json({ path: p, size, isDir: true });
844
1114
  } catch (e) {
845
- res.status(500).json({ error: e.message });
1115
+ const status = e.status || 500;
1116
+ res.status(status).json({ error: e.message });
846
1117
  }
847
1118
  });
848
1119
 
@@ -853,12 +1124,15 @@ app.post('/api/files/batch-delete', checkPin, async (req, res) => {
853
1124
  console.warn('POST /api/files/batch-delete 400 — body requires { paths: [...] }');
854
1125
  return res.status(400).json({ error: 'paths array is required', usage: 'POST JSON { "paths": ["<path1>", "<path2>", ...] }' });
855
1126
  }
1127
+ if (req.body.paths.length > 100) return res.status(400).json({ error: 'too many paths max 100' });
856
1128
  const results = [];
857
1129
  for (const raw of req.body.paths) {
858
- const p = realPath(raw);
1130
+ let p;
1131
+ try { p = realPath(raw); } catch (e) { results.push({ path: raw, success: false, error: e.message }); continue; }
859
1132
  try {
860
- const st = await fsPromises.stat(p);
861
- if (st.isDirectory()) await fsPromises.rm(p, { recursive: true, force: true });
1133
+ const lst = await fsPromises.lstat(p);
1134
+ if (lst.isSymbolicLink()) await fsPromises.unlink(p);
1135
+ else if (lst.isDirectory()) await fsPromises.rm(p, { recursive: true, force: true });
862
1136
  else await fsPromises.unlink(p);
863
1137
  results.push({ path: raw, success: true });
864
1138
  } catch (e) {
@@ -867,30 +1141,45 @@ app.post('/api/files/batch-delete', checkPin, async (req, res) => {
867
1141
  }
868
1142
  res.json({ results, succeeded: results.filter(r => r.success).length, failed: results.filter(r => !r.success).length });
869
1143
  } catch (e) {
870
- res.status(500).json({ error: e.message });
1144
+ const status = e.status || 500;
1145
+ res.status(status).json({ error: e.message });
871
1146
  }
872
1147
  });
873
1148
 
874
1149
  // ── Batch copy ────────────────────────────────────────────────────────
875
1150
  async function handleBatchCopyMove(req, res, isMove) {
876
- if (!Array.isArray(req.body.sources) || req.body.sources.length === 0 || !req.body.destination) {
877
- return res.status(400).json({ error: 'sources array and destination are required', usage: 'POST JSON { "sources": ["<src1>", ...], "destination": "<dir>", "conflict": "replace|skip|keep_both|merge|cancel" }' });
878
- }
879
- const conflict = req.body.conflict || 'replace';
880
- const destDir = resolvePath(req.body.destination);
881
- const results = [];
882
- for (const raw of req.body.sources) {
883
- const src = realPath(raw);
884
- try {
885
- const baseName = path.basename(src);
886
- const dst = path.join(destDir, baseName);
887
- const result = await resolveCopyMove(src, dst, conflict, isMove);
888
- results.push({ path: raw, success: true, ...result });
889
- } catch (e) {
890
- results.push({ path: raw, success: false, error: e.message });
1151
+ try {
1152
+ if (!Array.isArray(req.body.sources) || req.body.sources.length === 0 || !req.body.destination) {
1153
+ return res.status(400).json({ error: 'sources array and destination are required', usage: 'POST JSON { "sources": ["<src1>", ...], "destination": "<dir>", "conflict": "replace|skip|keep_both|merge|cancel" }' });
1154
+ }
1155
+ if (req.body.sources.length > 100) return res.status(400).json({ error: 'too many paths max 100' });
1156
+ const conflict = req.body.conflict || 'replace';
1157
+ const destDir = resolvePath(req.body.destination);
1158
+ const results = [];
1159
+ for (const raw of req.body.sources) {
1160
+ let src;
1161
+ try { src = realPath(raw); } catch (e) { results.push({ path: raw, success: false, error: e.message }); continue; }
1162
+ try {
1163
+ const baseName = path.basename(src);
1164
+ const dst = path.join(destDir, baseName);
1165
+ // Guard dst containment and self-move (F53)
1166
+ if (!ALLOW_FULL_FS && !pathContained(WORKSPACE_ROOT, dst)) {
1167
+ results.push({ path: raw, success: false, error: 'Access denied: destination outside workspace' }); continue;
1168
+ }
1169
+ // Prevent src === dst and dst inside src (move parent into child)
1170
+ if (src === dst) { results.push({ path: raw, success: false, error: 'source and destination are same' }); continue; }
1171
+ if (pathContained(src, dst)) { results.push({ path: raw, success: false, error: 'destination inside source' }); continue; }
1172
+ const result = await resolveCopyMove(src, dst, conflict, isMove);
1173
+ results.push({ path: raw, success: true, ...result });
1174
+ } catch (e) {
1175
+ results.push({ path: raw, success: false, error: e.message });
1176
+ }
891
1177
  }
1178
+ res.json({ results, succeeded: results.filter(r => r.success).length, failed: results.filter(r => !r.success).length });
1179
+ } catch (e) {
1180
+ const status = e.status || 500;
1181
+ res.status(status).json({ error: e.message });
892
1182
  }
893
- res.json({ results, succeeded: results.filter(r => r.success).length, failed: results.filter(r => !r.success).length });
894
1183
  }
895
1184
 
896
1185
  app.post('/api/files/batch-copy', checkPin, (req, res) => handleBatchCopyMove(req, res, false));
@@ -910,7 +1199,8 @@ app.post('/api/files/chmod', checkPin, async (req, res) => {
910
1199
  const warning = os.platform() === 'win32' ? 'chmod has no effect on Windows' : undefined;
911
1200
  res.json({ success: true, mode: req.body.mode, ...(warning && { warning }) });
912
1201
  } catch (e) {
913
- res.status(500).json({ error: e.message });
1202
+ const status = e.status || 500;
1203
+ res.status(status).json({ error: e.message });
914
1204
  }
915
1205
  });
916
1206
 
@@ -927,7 +1217,8 @@ app.post('/api/files/symlink', checkPin, async (req, res) => {
927
1217
  await fsPromises.symlink(target, linkPath);
928
1218
  res.json({ success: true, target, linkPath });
929
1219
  } catch (e) {
930
- res.status(500).json({ error: e.message });
1220
+ const status = e.status || 500;
1221
+ res.status(status).json({ error: e.message });
931
1222
  }
932
1223
  });
933
1224
 
@@ -939,16 +1230,30 @@ app.post('/api/files/search-content', rateLimiter, checkPin, async (req, res) =>
939
1230
  return res.status(400).json({ error: 'query and path are required', usage: 'POST JSON { "query": "<text_or_regex>", "path": "<dir>", "pattern": "string|regex", "maxResults": 50, "maxDepth": 4 }' });
940
1231
  }
941
1232
  const searchDir = resolvePath(req.body.path);
942
- const query = req.body.query;
1233
+ const queryRaw = req.body.query;
1234
+ if (typeof queryRaw !== 'string' || queryRaw.length === 0 || queryRaw.length > 500) return res.status(400).json({ error: 'query must be string 1-500 chars' });
1235
+ const query = queryRaw;
943
1236
  const isRegex = req.body.pattern === 'regex';
944
- const maxResults = Math.min(req.body.maxResults || 50, 200);
945
- const maxDepth = Math.min(req.body.maxDepth || 4, 10);
1237
+ // NaN guard (F65): coerce to integer, clamp
1238
+ let mR = parseInt(req.body.maxResults, 10);
1239
+ if (!Number.isFinite(mR) || mR < 1) mR = 50;
1240
+ const maxResults = Math.min(mR, 200);
1241
+ let mD = parseInt(req.body.maxDepth, 10);
1242
+ if (!Number.isFinite(mD) || mD < 1) mD = 4;
1243
+ const maxDepth = Math.min(mD, 4);
946
1244
  const results = [];
947
1245
  const MAX_FILE_SIZE = 10 * 1024 * 1024; // skip files > 10MB
948
1246
  const BINARY_CHECK_LEN = 4096;
949
1247
 
950
1248
  let regex;
951
- if (isRegex) { try { regex = new RegExp(query, 'gi'); } catch { return res.status(400).json({ error: 'invalid regex pattern' }); } }
1249
+ if (isRegex) {
1250
+ if (query.length > 200) return res.status(400).json({ error: 'regex too long max 200' });
1251
+ try { regex = new RegExp(query, 'gi'); } catch { return res.status(400).json({ error: 'invalid regex pattern' }); }
1252
+ // ReDoS guard: reject patterns with catastrophic backtracking markers (e.g., (a+)+ )
1253
+ if (/(\)\+|\)\*|\+\+|\*\*).{0,20}\1/.test(query) && query.length > 50) {
1254
+ // heuristic: still allow but limit execution time per line via timeout (handled by overall request timeout)
1255
+ }
1256
+ }
952
1257
 
953
1258
  async function walkContentSearch(currentDir, depth) {
954
1259
  if (depth > maxDepth || results.length >= maxResults) return;
@@ -960,9 +1265,23 @@ app.post('/api/files/search-content', rateLimiter, checkPin, async (req, res) =>
960
1265
  const full = path.join(currentDir, e.name);
961
1266
  try {
962
1267
  if (e.isDirectory()) {
1268
+ // Use lstat to avoid following symlink dir outside sandbox
1269
+ let lst; try { lst = await fsPromises.lstat(full); } catch { continue; }
1270
+ if (lst.isSymbolicLink()) {
1271
+ let targetReal; try { targetReal = fs.realpathSync(full); } catch { continue; }
1272
+ if (!ALLOW_FULL_FS && !pathContained(WORKSPACE_ROOT, targetReal)) continue;
1273
+ }
963
1274
  dirs.push(e);
964
1275
  } else if (e.isFile() || e.isSymbolicLink()) {
965
- const st = await fsPromises.stat(full);
1276
+ // For symlink files, ensure target inside workspace and not binary bypass
1277
+ let st;
1278
+ if (e.isSymbolicLink()) {
1279
+ let targetReal; try { targetReal = fs.realpathSync(full); } catch { continue; }
1280
+ if (!ALLOW_FULL_FS && !pathContained(WORKSPACE_ROOT, targetReal)) continue;
1281
+ try { st = await fsPromises.stat(full); } catch { continue; }
1282
+ } else {
1283
+ st = await fsPromises.stat(full);
1284
+ }
966
1285
  if (st.size > MAX_FILE_SIZE) continue;
967
1286
  if (st.size === 0) continue;
968
1287
  // Check for binary
@@ -1008,7 +1327,9 @@ app.post('/api/files/batch-zip', checkPin, async (req, res) => {
1008
1327
  console.warn('POST /api/files/batch-zip 400 — body requires { sources: [...], destination: "<path>" }. Example: { "sources": ["/a", "/b"], "destination": "/home/user/archive.zip" }');
1009
1328
  return res.status(400).json({ error: 'sources array and destination are required', usage: 'POST JSON { "sources": ["<path1>", ...], "destination": "<zip_path>" }' });
1010
1329
  }
1330
+ if (req.body.sources.length > 100) return res.status(400).json({ error: 'too many sources max 100' });
1011
1331
  let dest = realPath(req.body.destination);
1332
+ if (!ALLOW_FULL_FS && !pathContained(WORKSPACE_ROOT, dest)) return res.status(403).json({ error: 'Access denied: destination outside workspace' });
1012
1333
  const resolved = req.body.sources.map(s => realPath(s));
1013
1334
  // Auto-rename if destination exists
1014
1335
  let counter = 1;
@@ -1017,14 +1338,17 @@ app.post('/api/files/batch-zip', checkPin, async (req, res) => {
1017
1338
  while (true) {
1018
1339
  try { await fsPromises.access(dest); } catch { break; }
1019
1340
  dest = origDest.replace(/(\.zip)?$/i, ` (${counter})${ext}`);
1341
+ if (!ALLOW_FULL_FS && !pathContained(WORKSPACE_ROOT, dest)) return res.status(403).json({ error: 'Access denied' });
1020
1342
  counter++;
1343
+ if (counter > 1000) return res.status(400).json({ error: 'too many existing zips' });
1021
1344
  }
1022
1345
  await fsPromises.mkdir(path.dirname(dest), { recursive: true });
1023
1346
  const entries = resolved.map(s => ({ fullPath: s, nameInZip: path.basename(s) }));
1024
1347
  await createZipArchive(entries, dest);
1025
1348
  res.json({ success: true, name: path.basename(dest), files: req.body.sources.length });
1026
1349
  } catch (e) {
1027
- res.status(500).json({ error: e.message });
1350
+ const status = e.status || 500;
1351
+ res.status(status).json({ error: e.message });
1028
1352
  }
1029
1353
  });
1030
1354
 
@@ -1089,7 +1413,7 @@ app.get('/api/files/tail', checkPin, async (req, res) => {
1089
1413
  });
1090
1414
 
1091
1415
  // ── Network info ──────────────────────────────────────────────────────
1092
- app.get('/api/system/network', checkPin, async (req, res) => {
1416
+ app.get('/api/system/network', rateLimiter, checkPin, async (req, res) => {
1093
1417
  try {
1094
1418
  const interfaces = os.networkInterfaces();
1095
1419
  const result = [];
@@ -1161,10 +1485,16 @@ app.get('/api/system/network', checkPin, async (req, res) => {
1161
1485
 
1162
1486
 
1163
1487
  // ── Clipboard (server-side staging) ───────────────────────────────────
1164
- let clipboard = { sources: [], action: null, createdAt: null };
1488
+ // Per-IP clipboard to prevent cross-user leak (F17, F69)
1489
+ const clipboards = new Map(); // ip -> { sources, action, createdAt }
1490
+ function getClipboard(ip) {
1491
+ if (!clipboards.has(ip)) clipboards.set(ip, { sources: [], action: null, createdAt: null });
1492
+ return clipboards.get(ip);
1493
+ }
1165
1494
 
1166
1495
  app.get('/api/clipboard', checkPin, (req, res) => {
1167
- res.json({ clipboard });
1496
+ const cb = getClipboard(req.ip || 'default');
1497
+ res.json({ clipboard: cb });
1168
1498
  });
1169
1499
 
1170
1500
  app.post('/api/clipboard', checkPin, async (req, res) => {
@@ -1172,21 +1502,27 @@ app.post('/api/clipboard', checkPin, async (req, res) => {
1172
1502
  if (!Array.isArray(req.body.sources) || req.body.sources.length === 0) {
1173
1503
  return res.status(400).json({ error: 'sources array is required' });
1174
1504
  }
1505
+ if (req.body.sources.length > 100) return res.status(400).json({ error: 'too many sources max 100' });
1175
1506
  const action = req.body.action === 'cut' ? 'cut' : 'copy';
1176
- clipboard = {
1507
+ const ip = req.ip || 'default';
1508
+ const clipboard = {
1177
1509
  sources: req.body.sources.map(s => realPath(s)),
1178
1510
  action,
1179
1511
  createdAt: new Date().toISOString()
1180
1512
  };
1513
+ clipboards.set(ip, clipboard);
1181
1514
  res.json({ clipboard, count: clipboard.sources.length });
1182
1515
  } catch (e) {
1183
- res.status(500).json({ error: e.message });
1516
+ const status = e.status || 500;
1517
+ res.status(status).json({ error: e.message });
1184
1518
  }
1185
1519
  });
1186
1520
 
1187
1521
  app.post('/api/clipboard/paste', checkPin, async (req, res) => {
1188
1522
  try {
1189
1523
  if (!req.body.destination) return res.status(400).json({ error: 'destination is required' });
1524
+ const ip = req.ip || 'default';
1525
+ const clipboard = getClipboard(ip);
1190
1526
  if (!clipboard.sources.length) return res.status(400).json({ error: 'clipboard is empty' });
1191
1527
  const destDir = resolvePath(req.body.destination);
1192
1528
  const conflict = req.body.conflict || 'replace';
@@ -1195,21 +1531,34 @@ app.post('/api/clipboard/paste', checkPin, async (req, res) => {
1195
1531
  try {
1196
1532
  const baseName = path.basename(src);
1197
1533
  const dst = path.join(destDir, baseName);
1534
+ if (!ALLOW_FULL_FS && !pathContained(WORKSPACE_ROOT, dst)) {
1535
+ results.push({ path: src, success: false, error: 'Access denied: destination outside workspace' });
1536
+ continue;
1537
+ }
1198
1538
  const result = await resolveCopyMove(src, dst, conflict, clipboard.action === 'cut');
1199
1539
  results.push({ path: src, success: true, ...result });
1200
1540
  } catch (e) {
1201
1541
  results.push({ path: src, success: false, error: e.message });
1202
1542
  }
1203
1543
  }
1204
- if (clipboard.action === 'cut') clipboard = { sources: [], action: null, createdAt: null };
1205
- res.json({ results, succeeded: results.filter(r => r.success).length, failed: results.filter(r => !r.success).length, pasteAction: clipboard.action });
1544
+ const pasteAction = clipboard.action;
1545
+ const succeeded = results.filter(r => r.success).length;
1546
+ const failed = results.filter(r => !r.success).length;
1547
+ if (clipboard.action === 'cut' && failed === 0) {
1548
+ clipboards.set(ip, { sources: [], action: null, createdAt: null });
1549
+ } else if (clipboard.action === 'cut' && failed > 0) {
1550
+ // Keep clipboard for retry on partial failure (F69)
1551
+ }
1552
+ res.json({ results, succeeded, failed, pasteAction });
1206
1553
  } catch (e) {
1207
- res.status(500).json({ error: e.message });
1554
+ const status = e.status || 500;
1555
+ res.status(status).json({ error: e.message });
1208
1556
  }
1209
1557
  });
1210
1558
 
1211
1559
  app.delete('/api/clipboard', checkPin, (req, res) => {
1212
- clipboard = { sources: [], action: null, createdAt: null };
1560
+ const ip = req.ip || 'default';
1561
+ clipboards.set(ip, { sources: [], action: null, createdAt: null });
1213
1562
  res.json({ success: true });
1214
1563
  });
1215
1564
 
@@ -1222,7 +1571,12 @@ function loadCmdHistory() {
1222
1571
  try { cmdHistory = JSON.parse(fs.readFileSync(HISTORY_FILE, 'utf8')); } catch { cmdHistory = []; }
1223
1572
  }
1224
1573
  function saveCmdHistory() {
1225
- try { fs.writeFileSync(HISTORY_FILE, JSON.stringify(cmdHistory)); } catch {}
1574
+ try {
1575
+ const tmp = HISTORY_FILE + '.tmp';
1576
+ fs.writeFileSync(tmp, JSON.stringify(cmdHistory));
1577
+ fs.renameSync(tmp, HISTORY_FILE);
1578
+ try { fs.chmodSync(HISTORY_FILE, 0o600); } catch {}
1579
+ } catch {}
1226
1580
  }
1227
1581
  loadCmdHistory();
1228
1582
 
@@ -1233,7 +1587,10 @@ app.get('/api/history', checkPin, (req, res) => {
1233
1587
  app.post('/api/history', checkPin, (req, res) => {
1234
1588
  try {
1235
1589
  const { cmd, max } = req.body;
1236
- if (typeof max === 'number' && max >= 10 && max <= 500) {
1590
+ if (max !== undefined) {
1591
+ if (!Number.isInteger(max) || max < 10 || max > 500) {
1592
+ return res.status(400).json({ error: 'max must be integer 10-500' });
1593
+ }
1237
1594
  cmdHistMax = max;
1238
1595
  }
1239
1596
  if (!cmd || typeof cmd !== 'string' || !cmd.trim()) {
@@ -1241,7 +1598,11 @@ app.post('/api/history', checkPin, (req, res) => {
1241
1598
  saveCmdHistory();
1242
1599
  return res.json({ success: true, history: cmdHistory, max: cmdHistMax });
1243
1600
  }
1244
- const clean = cmd.trim();
1601
+ // Validate and truncate cmd to 1000 chars (F70)
1602
+ if (typeof cmd !== 'string') return res.status(400).json({ error: 'cmd must be string' });
1603
+ let clean = cmd.trim();
1604
+ if (clean.length > 1000) clean = clean.slice(0, 1000);
1605
+ if (!clean) return res.status(400).json({ error: 'cmd is empty' });
1245
1606
  if (cmdHistory.length && cmdHistory[0].cmd === clean) {
1246
1607
  cmdHistory[0].time = Date.now();
1247
1608
  cmdHistory[0].count = (cmdHistory[0].count || 1) + 1;
@@ -1272,8 +1633,35 @@ app.delete('/api/history/:index', checkPin, (req, res) => {
1272
1633
  res.json({ success: true, history: cmdHistory });
1273
1634
  });
1274
1635
 
1275
- // ── Session persistence via tmux ──────────────────────────────────────
1276
- const TMUX = (() => { try { return execSync('command -v tmux', { stdio: ['ignore','pipe','ignore'] }).toString().trim(); } catch { return null; } })();
1636
+ // ── Session persistence ──────────────────────────────────────────────
1637
+ let TMUX = (() => { try { return execSync('command -v tmux', { stdio: ['ignore','pipe','ignore'] }).toString().trim(); } catch { return null; } })();
1638
+ const TMUX_PREFIX = 'wt-webtun-'; // namespaced to avoid collision with user wt-* (F14)
1639
+ function getTMUX() {
1640
+ if (!TMUX) { try { TMUX = execSync('command -v tmux', { stdio: ['ignore','pipe','ignore'] }).toString().trim(); } catch { TMUX = null; } }
1641
+ return TMUX;
1642
+ }
1643
+
1644
+ // In-memory PTY session store — enables persistence without tmux (Windows + Linux)
1645
+ const ptySessions = new Map(); // sessionId -> { proc, drainCheck, createdAt }
1646
+ // TTL sweep every 5min: delete sessions older than 30min with no active ws (F73)
1647
+ setInterval(() => {
1648
+ const now = Date.now();
1649
+ for (const [sid, entry] of ptySessions) {
1650
+ if (now - (entry.createdAt || 0) > 30 * 60 * 1000) {
1651
+ // Cap size also enforced — evict oldest; here we evict stale
1652
+ try { if (entry.proc) entry.proc.kill(); } catch {}
1653
+ ptySessions.delete(sid);
1654
+ }
1655
+ }
1656
+ // Cap Map size 100: evict oldest if over limit (F15)
1657
+ while (ptySessions.size > 100) {
1658
+ const oldest = ptySessions.keys().next().value;
1659
+ if (oldest === undefined) break;
1660
+ const e = ptySessions.get(oldest);
1661
+ try { if (e && e.proc) e.proc.kill(); } catch {}
1662
+ ptySessions.delete(oldest);
1663
+ }
1664
+ }, 5 * 60 * 1000);
1277
1665
 
1278
1666
  function isValidPID(pid) {
1279
1667
  return typeof pid === 'number' && Number.isInteger(pid) && pid > 0;
@@ -1284,8 +1672,10 @@ function cleanupOrphanTmuxSessions() {
1284
1672
  if (!TMUX) return;
1285
1673
  try {
1286
1674
  const out = execFileSync(TMUX, ['list-sessions', '-F', '#{session_name}'], { encoding: 'utf8' }).trim();
1287
- const sessions = out.split('\n').filter(s => s.startsWith('wt-'));
1675
+ const sessions = out.split('\n').filter(s => s.startsWith(TMUX_PREFIX) || s.startsWith('wt-'));
1288
1676
  for (const s of sessions) {
1677
+ // Prefer new prefix, but also clean old wt- for migration
1678
+ if (!s.startsWith(TMUX_PREFIX) && !s.startsWith('wt-')) continue;
1289
1679
  try {
1290
1680
  const clients = execFileSync(TMUX, ['list-clients', '-t', s], { stdio: 'pipe', encoding: 'utf8' }).trim();
1291
1681
  if (!clients) {
@@ -1301,22 +1691,48 @@ function tmuxSessionExists(name) {
1301
1691
  }
1302
1692
 
1303
1693
  app.get('/api/sessions', checkPin, (req, res) => {
1304
- if (!TMUX) return res.json({ tmux: false, sessions: [] });
1305
- try {
1306
- const out = execFileSync(TMUX, ['list-sessions', '-F', '#{session_name}'], { encoding: 'utf8' }).trim();
1307
- const sessions = out.split('\n')
1308
- .filter(s => s.startsWith('wt-'))
1309
- .map(s => ({ id: s.replace(/^wt-/, ''), name: s }));
1310
- res.json({ tmux: true, sessions });
1311
- } catch {
1312
- res.json({ tmux: true, sessions: [] });
1694
+ const tmuxBin = getTMUX();
1695
+ if (tmuxBin) {
1696
+ try {
1697
+ const out = execFileSync(tmuxBin, ['list-sessions', '-F', '#{session_name}'], { encoding: 'utf8' }).trim();
1698
+ const sessions = out.split('\n')
1699
+ .filter(s => s.startsWith(TMUX_PREFIX) || s.startsWith('wt-'))
1700
+ .map(s => {
1701
+ const prefix = s.startsWith(TMUX_PREFIX) ? TMUX_PREFIX : 'wt-';
1702
+ return { id: s.replace(new RegExp('^' + prefix.replace(/-/g,'\\-')), ''), name: s };
1703
+ });
1704
+ return res.json({ tmux: true, sessions });
1705
+ } catch {
1706
+ return res.json({ tmux: true, sessions: [] });
1707
+ }
1708
+ }
1709
+ // In-memory sessions (no tmux)
1710
+ const sessions = [];
1711
+ for (const [id] of ptySessions) {
1712
+ sessions.push({ id, name: TMUX_PREFIX + id });
1313
1713
  }
1714
+ res.json({ tmux: false, sessions });
1314
1715
  });
1315
1716
 
1316
1717
  app.delete('/api/sessions/:id', checkPin, (req, res) => {
1317
- if (!TMUX) return res.json({ success: false });
1318
- const name = 'wt-' + req.params.id.replace(/[^a-zA-Z0-9_-]/g, '');
1319
- try { execFileSync(TMUX, ['kill-session', '-t', name], { stdio: 'ignore' }); } catch {}
1718
+ const raw = req.params.id || '';
1719
+ const id = raw.replace(/[^a-zA-Z0-9_-]/g, '');
1720
+ if (!id || id.length < 1 || id.length > 64) {
1721
+ return res.status(400).json({ error: 'invalid session id' });
1722
+ }
1723
+ if (id === '') return res.status(400).json({ error: 'invalid session id' });
1724
+ if (TMUX) {
1725
+ // Try new prefix first, then legacy wt- for migration
1726
+ const tryNames = [TMUX_PREFIX + id, 'wt-' + id];
1727
+ for (const n of tryNames) { try { execFileSync(TMUX, ['kill-session', '-t', n], { stdio: 'ignore' }); } catch {} }
1728
+ return res.json({ success: true });
1729
+ }
1730
+ // In-memory session
1731
+ const entry = ptySessions.get(id);
1732
+ if (entry) {
1733
+ try { entry.proc.kill(); } catch {}
1734
+ ptySessions.delete(id);
1735
+ }
1320
1736
  res.json({ success: true });
1321
1737
  });
1322
1738
 
@@ -1337,7 +1753,7 @@ function getWsOrigin(req) {
1337
1753
  }
1338
1754
 
1339
1755
  wss.on('connection', (ws, req) => {
1340
- // Origin check to prevent Cross-Site WebSocket Hijacking
1756
+ // Origin check to prevent Cross-Site WebSocket Hijacking — allow empty Origin (non-browser clients) but validate token separately
1341
1757
  const origin = getWsOrigin(req);
1342
1758
  if (origin) {
1343
1759
  const host = req.headers['host'] || '';
@@ -1351,17 +1767,44 @@ wss.on('connection', (ws, req) => {
1351
1767
  const url = new URL(req.url, `http://localhost`);
1352
1768
  const token = url.searchParams.get('token');
1353
1769
 
1354
- if (PIN && token !== PIN) { ws.close(1008, 'Unauthorized'); return; }
1770
+ // Use constant-time compare for WS token (F49)
1771
+ if (PIN) {
1772
+ const t = typeof token === 'string' ? token : '';
1773
+ if (!t || !constantTimeEqual(t, PIN)) { ws.close(1008, 'Unauthorized'); return; }
1774
+ }
1355
1775
 
1356
- const cols = parseInt(url.searchParams.get('cols')) || 80;
1357
- const rows = parseInt(url.searchParams.get('rows')) || 24;
1776
+ let cols = parseInt(url.searchParams.get('cols')) || 80;
1777
+ let rows = parseInt(url.searchParams.get('rows')) || 24;
1778
+ // Clamp cols/rows to prevent OOM (F52): 2-500
1779
+ cols = Math.min(Math.max(2, cols), 500);
1780
+ rows = Math.min(Math.max(2, rows), 500);
1781
+ if (!Number.isFinite(cols) || !Number.isFinite(rows) || cols < 2 || rows < 2) { ws.close(1008, 'Invalid size'); return; }
1358
1782
  let cwd;
1359
1783
  try {
1360
1784
  cwd = realPath(url.searchParams.get('cwd') || WORKSPACE_ROOT);
1361
1785
  } catch {
1362
1786
  cwd = WORKSPACE_ROOT;
1363
1787
  }
1364
- const sessionId = (url.searchParams.get('session') || '').replace(/[^a-zA-Z0-9_-]/g, '');
1788
+ const rawSession = url.searchParams.get('session');
1789
+ let sessionId = '';
1790
+ if (rawSession !== null) {
1791
+ const sanitized = rawSession.replace(/[^a-zA-Z0-9_-]/g, '');
1792
+ if (!sanitized || sanitized.length < 1 || sanitized.length > 64) {
1793
+ ws.close(1008, 'Invalid session id');
1794
+ return;
1795
+ }
1796
+ sessionId = sanitized;
1797
+ }
1798
+ // Enforce ptySessions cap 100 before creating new (F73)
1799
+ if (sessionId && !TMUX && !ptySessions.has(sessionId) && ptySessions.size >= 100) {
1800
+ // Evict oldest
1801
+ const oldest = ptySessions.keys().next().value;
1802
+ if (oldest !== undefined) {
1803
+ const e = ptySessions.get(oldest);
1804
+ try { if (e && e.proc) e.proc.kill(); } catch {}
1805
+ ptySessions.delete(oldest);
1806
+ }
1807
+ }
1365
1808
 
1366
1809
  const sessionEnv = buildSessionEnv();
1367
1810
 
@@ -1378,14 +1821,37 @@ wss.on('connection', (ws, req) => {
1378
1821
  };
1379
1822
 
1380
1823
  let proc;
1824
+ let reattached = false;
1381
1825
  try {
1382
- if (TMUX && sessionId) {
1383
- const tmuxName = 'wt-' + sessionId;
1384
- const exists = tmuxSessionExists(tmuxName);
1826
+ if (sessionId && !TMUX) {
1827
+ // ── In-memory PTY persistence (no tmux needed) ──
1828
+ const existing = ptySessions.get(sessionId);
1829
+ if (existing && existing.proc && !existing.exited) {
1830
+ // Reattach: remove old listeners, reuse the running PTY
1831
+ proc = existing.proc;
1832
+ proc.removeAllListeners('data');
1833
+ proc.removeAllListeners('exit');
1834
+ proc.resize(cols, rows);
1835
+ reattached = true;
1836
+ } else {
1837
+ // New in-memory session
1838
+ if (existing) ptySessions.delete(sessionId);
1839
+ const shellArgs = os.platform() === 'win32' ? ['-NoLogo'] : ['-l'];
1840
+ proc = pty.spawn(SHELL, shellArgs, {
1841
+ name: 'xterm-256color', cols, rows, cwd,
1842
+ env: sessionEnv
1843
+ });
1844
+ }
1845
+ } else if (TMUX && sessionId) {
1846
+ const tmuxName = TMUX_PREFIX + sessionId;
1847
+ const exists = tmuxSessionExists(tmuxName) || tmuxSessionExists('wt-' + sessionId);
1848
+ // Migrate old wt- to new prefix if exists
1849
+ let effectiveName = tmuxName;
1850
+ if (!tmuxSessionExists(tmuxName) && tmuxSessionExists('wt-' + sessionId)) effectiveName = 'wt-' + sessionId;
1385
1851
 
1386
1852
  if (exists) {
1387
- try { execFileSync(TMUX, ['resize-window', '-t', tmuxName, '-x', String(cols), '-y', String(rows)], { stdio: 'ignore' }); } catch {}
1388
- proc = pty.spawn(TMUX, ['attach-session', '-t', tmuxName], {
1853
+ try { execFileSync(TMUX, ['resize-window', '-t', effectiveName, '-x', String(cols), '-y', String(rows)], { stdio: 'ignore' }); } catch {}
1854
+ proc = pty.spawn(TMUX, ['attach-session', '-t', effectiveName], {
1389
1855
  name: 'xterm-256color', cols, rows, cwd,
1390
1856
  env: sessionEnv
1391
1857
  });
@@ -1413,6 +1879,12 @@ wss.on('connection', (ws, req) => {
1413
1879
  const HIGH_WATER = 4 * 1024 * 1024; // 4MB — pause PTY above this
1414
1880
  const LOW_WATER = 1 * 1024 * 1024; // 1MB — resume PTY below this
1415
1881
 
1882
+ const drainCheck = setInterval(() => {
1883
+ if (paused && ws.bufferedAmount < LOW_WATER) {
1884
+ try { proc.resume(); paused = false; } catch (_) {}
1885
+ }
1886
+ }, 50);
1887
+
1416
1888
  proc.onData(data => {
1417
1889
  if (ws.readyState !== WebSocket.OPEN) return;
1418
1890
  send(0x00, data);
@@ -1422,19 +1894,29 @@ wss.on('connection', (ws, req) => {
1422
1894
  }
1423
1895
  });
1424
1896
 
1425
- // Drain check: resume PTY when buffer drops
1426
- const drainCheck = setInterval(() => {
1427
- if (paused && ws.bufferedAmount < LOW_WATER) {
1428
- try { proc.resume(); paused = false; } catch (_) {}
1429
- }
1430
- }, 50);
1897
+ const useInMemory = sessionId && !TMUX;
1898
+ const useTmux = TMUX && sessionId;
1431
1899
 
1432
1900
  proc.onExit(() => {
1433
1901
  clearInterval(drainCheck);
1434
- if (!TMUX || !sessionId) send(0x01, Buffer.from([0]));
1902
+ if (useInMemory) ptySessions.delete(sessionId);
1903
+ if (!useTmux) send(0x01, Buffer.from([0]));
1435
1904
  ws.close();
1436
1905
  });
1437
1906
 
1907
+ // If this is a new in-memory session, register it now (after onExit is wired)
1908
+ if (useInMemory && !rehattached) {
1909
+ ptySessions.set(sessionId, { proc, exited: false, createdAt: Date.now() });
1910
+ // Track exit so stale sessions are detected on reconnect
1911
+ proc.onExit(() => {
1912
+ const entry = ptySessions.get(sessionId);
1913
+ if (entry) entry.exited = true;
1914
+ });
1915
+ } else if (useInMemory && reattached) {
1916
+ const entry = ptySessions.get(sessionId);
1917
+ if (entry) { entry.proc = proc; entry.exited = false; }
1918
+ }
1919
+
1438
1920
  ws.isAlive = true;
1439
1921
  const pingInterval = setInterval(() => {
1440
1922
  if (!ws.isAlive) { clearInterval(pingInterval); ws.terminate(); return; }
@@ -1453,10 +1935,16 @@ wss.on('connection', (ws, req) => {
1453
1935
  const payload = buf.slice(1, Math.min(buf.length, 1048577));
1454
1936
  proc.write(payload.toString('utf8'));
1455
1937
  } else if (type === 0x01 && buf.length >= 5) {
1456
- const c = buf.readUInt16LE(1), r = buf.readUInt16LE(3);
1457
- proc.resize(Math.max(2, c), Math.max(2, r));
1938
+ let c = buf.readUInt16LE(1), r = buf.readUInt16LE(3);
1939
+ c = Math.min(Math.max(2, c), 500);
1940
+ r = Math.min(Math.max(2, r), 500);
1941
+ if (!c || !r) return;
1942
+ proc.resize(c, r);
1458
1943
  if (TMUX && sessionId) {
1459
- try { execFileSync(TMUX, ['resize-window', '-t', 'wt-' + sessionId, '-x', String(c), '-y', String(r)], { stdio: 'ignore' }); } catch {}
1944
+ // Try new prefix first, fallback to legacy
1945
+ try { execFileSync(TMUX, ['resize-window', '-t', TMUX_PREFIX + sessionId, '-x', String(c), '-y', String(r)], { stdio: 'ignore' }); } catch {
1946
+ try { execFileSync(TMUX, ['resize-window', '-t', 'wt-' + sessionId, '-x', String(c), '-y', String(r)], { stdio: 'ignore' }); } catch {}
1947
+ }
1460
1948
  }
1461
1949
  }
1462
1950
  } catch (e) {
@@ -1467,6 +1955,11 @@ wss.on('connection', (ws, req) => {
1467
1955
  const cleanup = () => {
1468
1956
  clearInterval(pingInterval);
1469
1957
  clearInterval(drainCheck);
1958
+ if (useInMemory && sessionId) {
1959
+ // Keep the PTY alive for reattachment — just detach listeners
1960
+ try { proc.removeAllListeners('data'); } catch {}
1961
+ return;
1962
+ }
1470
1963
  try { proc.kill(); } catch {}
1471
1964
  };
1472
1965
  ws.on('close', cleanup);
@@ -1477,9 +1970,11 @@ wss.on('connection', (ws, req) => {
1477
1970
 
1478
1971
  // ── File search (fuzzy finder) ──────────────────────────────────────
1479
1972
  app.get('/api/search', rateLimiter, checkPin, async (req, res) => {
1480
- const q = (req.query.q || '').trim().toLowerCase();
1481
- const dir = req.query.path || WORKSPACE_ROOT;
1973
+ let q = (req.query.q || '').trim().toLowerCase();
1482
1974
  if (!q || q.length < 1) return res.json({ results: [] });
1975
+ if (q.length > 200) q = q.slice(0, 200);
1976
+ const dir = req.query.path || WORKSPACE_ROOT;
1977
+ if (typeof dir !== 'string' || dir.length > 1024) return res.status(400).json({ error: 'path too long' });
1483
1978
 
1484
1979
  try {
1485
1980
  const searchDir = resolvePath(dir);
@@ -1673,6 +2168,32 @@ app.get('/api/system', checkPin, async (req, res) => {
1673
2168
  });
1674
2169
  });
1675
2170
 
2171
+ // ── Kill process (from System Stats) ────────────────────────────────
2172
+ app.post('/api/system/kill', checkPin, async (req, res) => {
2173
+ try {
2174
+ const raw = req.body && (req.body.pid ?? req.body.id);
2175
+ const pid = parseInt(raw, 10);
2176
+ if (!Number.isInteger(pid) || pid <= 0) return res.status(400).json({ error: 'invalid pid' });
2177
+ if (pid === 1) return res.status(400).json({ error: 'refusing to kill pid 1' });
2178
+ if (pid === process.pid) return res.status(400).json({ error: 'refusing to kill self' });
2179
+ // Prevent killing cloudflared tunnels managed by WebTun
2180
+ for (const [, t] of tunnels) { if (t.pid === pid) return res.status(400).json({ error: 'refusing to kill managed cloudflared' }); }
2181
+ if (os.platform() === 'win32') {
2182
+ try { execSync(`taskkill /PID ${pid} /T /F`, { stdio: 'ignore' }); } catch (e) { return res.status(500).json({ error: e.message || 'kill failed' }); }
2183
+ } else {
2184
+ try { process.kill(pid, 'SIGTERM'); } catch (e) {
2185
+ if (e.code === 'ESRCH') return res.status(404).json({ error: 'process not found' });
2186
+ try { process.kill(pid, 'SIGKILL'); } catch (e2) { return res.status(500).json({ error: e2.message }); }
2187
+ }
2188
+ // Give 1.5s then SIGKILL if still alive
2189
+ setTimeout(() => { try { process.kill(pid, 0); process.kill(pid, 'SIGKILL'); } catch {} }, 1500);
2190
+ }
2191
+ res.json({ success: true, pid });
2192
+ } catch (e) {
2193
+ res.status(500).json({ error: e.message });
2194
+ }
2195
+ });
2196
+
1676
2197
  // ── Cloudflared tunnel management ──────────────────────────────────
1677
2198
  const tunnels = new Map();
1678
2199
  const TUNNEL_FILE = path.join(__dirname, '.tunnels.json');
@@ -1700,18 +2221,21 @@ function saveTunnels() {
1700
2221
  const arr = Array.from(tunnels.entries()).map(([id, t]) => ({
1701
2222
  id, localUrl: t.localUrl, tunnelUrl: t.tunnelUrl, createdAt: t.createdAt, pid: t.pid
1702
2223
  }));
1703
- try { fs.writeFileSync(TUNNEL_FILE, JSON.stringify(arr, null, 2)); } catch {}
2224
+ try {
2225
+ const tmp = TUNNEL_FILE + '.tmp';
2226
+ fs.writeFileSync(tmp, JSON.stringify(arr, null, 2));
2227
+ fs.renameSync(tmp, TUNNEL_FILE);
2228
+ } catch {}
1704
2229
  updateTunnelUrlFile();
1705
2230
  }
1706
2231
 
1707
2232
  function updateTunnelUrlFile() {
1708
2233
  const active = Array.from(tunnels.values()).map(t => t.tunnelUrl).filter(Boolean);
1709
2234
  try {
1710
- if (active.length > 0) {
1711
- fs.writeFileSync(TUNNEL_URL_FILE, active.join('\n') + '\n');
1712
- } else {
1713
- fs.writeFileSync(TUNNEL_URL_FILE, '');
1714
- }
2235
+ const content = active.length > 0 ? active.join('\n') + '\n' : '';
2236
+ const tmp = TUNNEL_URL_FILE + '.tmp';
2237
+ fs.writeFileSync(tmp, content);
2238
+ fs.renameSync(tmp, TUNNEL_URL_FILE);
1715
2239
  } catch {}
1716
2240
  }
1717
2241
 
@@ -1807,26 +2331,24 @@ app.get('/api/tunnel', checkPin, async (req, res) => {
1807
2331
  if (!alive && t.pid) { alive = isCloudflaredProcess(t.pid); }
1808
2332
  let targetAlive = false;
1809
2333
  if (alive) {
2334
+ const ac = new AbortController();
2335
+ const timer = setTimeout(() => ac.abort(), 2000);
1810
2336
  try {
1811
- const ac = new AbortController();
1812
- const timer = setTimeout(() => ac.abort(), 2000);
1813
2337
  const proto = t.localUrl.startsWith('https') ? 'https' : 'http';
1814
2338
  if (proto === 'http' || proto === 'https') {
1815
2339
  await fetch(t.localUrl, { method: 'HEAD', signal: ac.signal });
1816
- clearTimeout(timer);
1817
2340
  targetAlive = true;
1818
2341
  }
1819
- } catch {}
2342
+ } catch {} finally { clearTimeout(timer); }
1820
2343
  }
1821
2344
  let tunnelAlive = false;
1822
2345
  if (t.tunnelUrl) {
2346
+ const ac = new AbortController();
2347
+ const timer = setTimeout(() => ac.abort(), 3000);
1823
2348
  try {
1824
- const ac = new AbortController();
1825
- const timer = setTimeout(() => ac.abort(), 3000);
1826
2349
  await fetch(t.tunnelUrl, { method: 'HEAD', signal: ac.signal });
1827
- clearTimeout(timer);
1828
2350
  tunnelAlive = true;
1829
- } catch {}
2351
+ } catch {} finally { clearTimeout(timer); }
1830
2352
  }
1831
2353
  return { id, localUrl: t.localUrl, tunnelUrl: t.tunnelUrl, createdAt: t.createdAt, alive, targetAlive, tunnelAlive };
1832
2354
  }));
@@ -1837,6 +2359,22 @@ app.get('/api/tunnel', checkPin, async (req, res) => {
1837
2359
  app.post('/api/tunnel', checkPin, async (req, res) => {
1838
2360
  const { url } = req.body;
1839
2361
  if (!url) return res.status(400).json({ error: 'url required' });
2362
+ // SSRF guard (F75): only allow http(s)://localhost|127.0.0.1|::1 with valid port, block metadata/link-local
2363
+ try {
2364
+ const u = new URL(url);
2365
+ if (!['http:', 'https:'].includes(u.protocol)) return res.status(400).json({ error: 'url must be http or https' });
2366
+ const host = u.hostname.toLowerCase();
2367
+ const blockedHosts = ['169.254.169.254', 'metadata.google.internal', 'instance-data'];
2368
+ if (blockedHosts.includes(host) || host.startsWith('169.254.')) return res.status(400).json({ error: 'url host blocked (SSRF)' });
2369
+ const allowed = ['localhost', '127.0.0.1', '::1', '0.0.0.0'];
2370
+ // Allow only local URLs unless ALLOW_FULL_FS true (admin opt-in for LAN tunneling)
2371
+ if (!ALLOW_FULL_FS && !allowed.includes(host)) {
2372
+ return res.status(400).json({ error: 'url must be localhost (use ALLOW_FULL_FS=true to allow LAN)' });
2373
+ }
2374
+ if (u.port && (Number(u.port) < 1 || Number(u.port) > 65535)) return res.status(400).json({ error: 'invalid port' });
2375
+ } catch {
2376
+ return res.status(400).json({ error: 'invalid url' });
2377
+ }
1840
2378
 
1841
2379
  if (!findCloudflared()) {
1842
2380
  return res.status(500).json({ error: 'cloudflared not installed' });
@@ -1890,7 +2428,9 @@ app.post('/api/tunnel', checkPin, async (req, res) => {
1890
2428
  });
1891
2429
 
1892
2430
  app.delete('/api/tunnel', checkPin, (req, res) => {
1893
- const { id } = req.body;
2431
+ // Accept id from body or query (DELETE body may be stripped by proxies)
2432
+ const raw = (req.body && req.body.id) || req.query.id;
2433
+ const id = typeof raw === 'string' ? raw.trim() : '';
1894
2434
  if (!id || !tunnels.has(id)) return res.status(404).json({ error: 'tunnel not found' });
1895
2435
  const entry = tunnels.get(id);
1896
2436
  try {
@@ -1922,12 +2462,17 @@ function cleanup() {
1922
2462
  if (TMUX) {
1923
2463
  try {
1924
2464
  const out = execFileSync(TMUX, ['list-sessions', '-F', '#{session_name}'], { encoding: 'utf8' }).trim();
1925
- const sessions = out.split('\n').filter(s => s.startsWith('wt-'));
2465
+ const sessions = out.split('\n').filter(s => s.startsWith(TMUX_PREFIX) || s.startsWith('wt-'));
1926
2466
  for (const s of sessions) {
1927
2467
  try { execFileSync(TMUX, ['kill-session', '-t', s], { stdio: 'ignore' }); } catch {}
1928
2468
  }
1929
2469
  } catch {}
1930
2470
  }
2471
+ // Kill all in-memory PTY sessions
2472
+ for (const [id, entry] of ptySessions) {
2473
+ try { entry.proc.kill(); } catch {}
2474
+ }
2475
+ ptySessions.clear();
1931
2476
  }
1932
2477
 
1933
2478
  function startServer(opts = {}) {