webtun 1.5.3 → 1.5.5

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 !== 'false';
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
  });
@@ -447,7 +613,7 @@ app.get('/api/files', checkPin, async (req, res) => {
447
613
  const full = path.join(dir, e.name);
448
614
  const st = await safeStat(full);
449
615
  files.push({
450
- name: e.name, path: full, isDir: e.isDirectory(),
616
+ name: e.name, path: full, isDir: st ? st.isDirectory() : e.isDirectory(),
451
617
  isSymlink: e.isSymbolicLink(), size: st ? st.size : 0,
452
618
  modified: st ? st.mtime : null, ext: path.extname(e.name).toLowerCase()
453
619
  });
@@ -467,11 +633,14 @@ app.get('/api/files', checkPin, async (req, res) => {
467
633
  entries.map(async e => {
468
634
  const full = path.join(dir, e.name);
469
635
  const st = await safeStat(full);
636
+ const isSymlink = e.isSymbolicLink();
637
+ // Use stat result for isDir so symlink→dir is navigable; Dirent.isDirectory() is false for symlink
638
+ const isDir = st ? st.isDirectory() : e.isDirectory();
470
639
  return {
471
640
  name: e.name,
472
641
  path: full,
473
- isDir: e.isDirectory(),
474
- isSymlink: e.isSymbolicLink(),
642
+ isDir,
643
+ isSymlink,
475
644
  size: st ? st.size : 0,
476
645
  modified: st ? st.mtime : null,
477
646
  ext: path.extname(e.name).toLowerCase()
@@ -482,10 +651,13 @@ app.get('/api/files', checkPin, async (req, res) => {
482
651
  if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
483
652
  return a.name.localeCompare(b.name);
484
653
  });
485
- const parent = path.dirname(dir);
486
- res.json({ path: dir, parent: parent !== dir ? parent : null, files });
654
+ let parent = path.dirname(dir);
655
+ if (parent === dir) parent = null;
656
+ else if (!ALLOW_FULL_FS && !pathContained(WORKSPACE_ROOT, parent)) parent = null;
657
+ res.json({ path: dir, parent, files });
487
658
  } catch (e) {
488
- res.status(500).json({ error: e.message });
659
+ const status = e.status || 500;
660
+ res.status(status).json({ error: e.message });
489
661
  }
490
662
  });
491
663
 
@@ -495,12 +667,19 @@ app.post('/api/files/rename', checkPin, async (req, res) => {
495
667
  console.warn('POST /api/files/rename 400 — body requires { oldPath, newName }. Example: { "oldPath": "/home/user/file.txt", "newName": "renamed.txt" }');
496
668
  return res.status(400).json({ error: 'oldPath and newName are required', usage: 'POST JSON { "oldPath": "<path>", "newName": "<name>" }' });
497
669
  }
670
+ const newName = req.body.newName;
671
+ if (typeof newName !== 'string' || !newName.trim() || newName === '.' || newName.length > 255 || newName.includes('/') || newName.includes('\\') || newName.includes('..')) {
672
+ return res.status(400).json({ error: 'Invalid newName: must not contain / \\ .. , be empty, "." or >255 chars' });
673
+ }
674
+ // also reject if newName contains null byte
675
+ if (newName.includes('\0')) return res.status(400).json({ error: 'Invalid newName: null byte' });
498
676
  const oldPath = realPath(req.body.oldPath);
499
- const newPath = realPath(path.join(path.dirname(oldPath), req.body.newName));
677
+ const newPath = realPath(path.join(path.dirname(oldPath), newName));
500
678
  await renameWithFallback(oldPath, newPath);
501
679
  res.json({ success: true, newPath });
502
680
  } catch (e) {
503
- res.status(500).json({ error: e.message });
681
+ const status = e.status || 500;
682
+ res.status(status).json({ error: e.message });
504
683
  }
505
684
  });
506
685
 
@@ -568,13 +747,29 @@ async function resolveCopyMove(src, dst, conflict, isMove) {
568
747
  }
569
748
 
570
749
  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" }' });
750
+ try {
751
+ if (!req.body.source || !req.body.destination) {
752
+ 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" }' });
753
+ }
754
+ const src = realPath(req.body.source);
755
+ const dst = resolvePath(req.body.destination);
756
+ // Sandbox guards (F53)
757
+ if (!ALLOW_FULL_FS) {
758
+ if (!pathContained(WORKSPACE_ROOT, dst)) return res.status(403).json({ error: 'Access denied: destination outside workspace' });
759
+ // If dst exists via symlink, check realpath as well
760
+ try {
761
+ const realDst = fs.realpathSync(dst);
762
+ if (!pathContained(WORKSPACE_ROOT, realDst)) return res.status(403).json({ error: 'Access denied: destination symlink outside workspace' });
763
+ } catch {}
764
+ }
765
+ if (src === dst) return res.status(400).json({ error: 'source and destination are same' });
766
+ if (pathContained(src, dst)) return res.status(400).json({ error: 'destination inside source' });
767
+ const result = await resolveCopyMove(src, dst, req.body.conflict || '', isMove);
768
+ res.json(result);
769
+ } catch (e) {
770
+ const status = e.status || 500;
771
+ res.status(status).json({ error: e.message });
573
772
  }
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
773
  }
579
774
 
580
775
  app.post('/api/files/copy', checkPin, (req, res) => handleCopyMove(req, res, false));
@@ -587,15 +782,18 @@ app.delete('/api/files', checkPin, async (req, res) => {
587
782
  return res.status(400).json({ error: 'path is required', usage: 'DELETE /api/files?path=<path>' });
588
783
  }
589
784
  const p = realPath(req.query.path);
590
- const st = await fsPromises.stat(p);
591
- if (st.isDirectory()) {
785
+ const lst = await fsPromises.lstat(p);
786
+ if (lst.isSymbolicLink()) {
787
+ await fsPromises.unlink(p);
788
+ } else if (lst.isDirectory()) {
592
789
  await fsPromises.rm(p, { recursive: true, force: true });
593
790
  } else {
594
791
  await fsPromises.unlink(p);
595
792
  }
596
793
  res.json({ success: true });
597
794
  } catch (e) {
598
- res.status(500).json({ error: e.message });
795
+ const status = e.status || 500;
796
+ res.status(status).json({ error: e.message });
599
797
  }
600
798
  });
601
799
 
@@ -609,7 +807,8 @@ app.post('/api/files/mkdir', checkPin, async (req, res) => {
609
807
  await fsPromises.mkdir(p, { recursive: true });
610
808
  res.json({ success: true });
611
809
  } catch (e) {
612
- res.status(500).json({ error: e.message });
810
+ const status = e.status || 500;
811
+ res.status(status).json({ error: e.message });
613
812
  }
614
813
  });
615
814
 
@@ -623,7 +822,8 @@ app.post('/api/files/touch', checkPin, async (req, res) => {
623
822
  await fsPromises.writeFile(p, '', { flag: 'a' });
624
823
  res.json({ success: true });
625
824
  } catch (e) {
626
- res.status(500).json({ error: e.message });
825
+ const status = e.status || 500;
826
+ res.status(status).json({ error: e.message });
627
827
  }
628
828
  });
629
829
 
@@ -634,21 +834,32 @@ app.post('/api/files/zip', checkPin, async (req, res) => {
634
834
  return res.status(400).json({ error: 'path is required', usage: 'POST JSON { "path": "<file_or_dir>" }' });
635
835
  }
636
836
  const p = realPath(req.body.path);
637
- await fsPromises.stat(p);
837
+ const st = await fsPromises.stat(p);
838
+ // Dest dir size guard (F63) — reject if dir >1GB
839
+ if (st.isDirectory()) {
840
+ const sz = await dirSize(p);
841
+ if (sz > 1 * 1024 * 1024 * 1024) return res.status(413).json({ error: 'Directory too large to zip (max 1GB)' });
842
+ } else if (st.size > 1 * 1024 * 1024 * 1024) {
843
+ return res.status(413).json({ error: 'File too large to zip (max 1GB)' });
844
+ }
638
845
  const baseName = path.basename(p);
639
846
  let zipName = baseName + '.zip';
640
847
  let zipPath = path.join(path.dirname(p), zipName);
848
+ // Ensure zipPath inside workspace
849
+ if (!ALLOW_FULL_FS && !pathContained(WORKSPACE_ROOT, zipPath)) return res.status(403).json({ error: 'Access denied: zip destination outside workspace' });
641
850
  let counter = 1;
642
851
  while (true) {
643
852
  try { await fsPromises.access(zipPath); } catch { break; }
644
853
  zipName = baseName + ' (' + counter + ').zip';
645
854
  zipPath = path.join(path.dirname(p), zipName);
855
+ if (!ALLOW_FULL_FS && !pathContained(WORKSPACE_ROOT, zipPath)) return res.status(403).json({ error: 'Access denied' });
646
856
  counter++;
647
857
  }
648
858
  await createZipArchive([{ fullPath: p, nameInZip: baseName }], zipPath);
649
859
  res.json({ success: true, name: zipName });
650
860
  } catch (e) {
651
- res.status(500).json({ error: e.message });
861
+ const status = e.status || 500;
862
+ res.status(status).json({ error: e.message });
652
863
  }
653
864
  });
654
865
 
@@ -661,29 +872,45 @@ app.post('/api/files/unzip', checkPin, async (req, res) => {
661
872
  const p = realPath(req.body.path);
662
873
  const ext = path.extname(p).toLowerCase();
663
874
  if (ext !== '.zip') return res.status(400).json({ error: 'Not a zip file' });
875
+ // Validate zip magic (F64) — extractZip also checks, but early check here for 400 vs 500
876
+ try {
877
+ const fd = fs.openSync(p, 'r');
878
+ const buf = Buffer.alloc(4);
879
+ const bytes = fs.readSync(fd, buf, 0, 4, 0);
880
+ fs.closeSync(fd);
881
+ if (bytes < 4 || !(buf[0] === 0x50 && buf[1] === 0x4B)) {
882
+ return res.status(400).json({ error: 'Not a zip file (bad magic)' });
883
+ }
884
+ } catch {}
664
885
  const destDir = path.join(path.dirname(p), path.basename(p, '.zip'));
886
+ if (!ALLOW_FULL_FS && !pathContained(WORKSPACE_ROOT, destDir)) return res.status(403).json({ error: 'Access denied: destination outside workspace' });
665
887
  await fsPromises.mkdir(destDir, { recursive: true });
666
888
  try {
667
889
  await extractZip(p, destDir);
668
890
  } catch (e) {
669
- return res.status(500).json({ error: e.message });
891
+ // Rollback partial on failure (F64)
892
+ try { await fsPromises.rm(destDir, { recursive: true, force: true }); } catch {}
893
+ const status = e.status || 500;
894
+ return res.status(status).json({ error: e.message });
670
895
  }
671
896
  res.json({ success: true, dir: destDir });
672
897
  } catch (e) {
673
- res.status(500).json({ error: e.message });
898
+ const status = e.status || 500;
899
+ res.status(status).json({ error: e.message });
674
900
  }
675
901
  });
676
902
 
677
903
  app.get('/api/files/read', checkPin, async (req, res) => {
678
904
  try {
679
905
  const p = resolvePath(req.query.path);
680
- const [content, st] = await Promise.all([
681
- fsPromises.readFile(p, 'utf8'),
682
- fsPromises.stat(p)
683
- ]);
906
+ const st = await fsPromises.stat(p);
907
+ if (st.isDirectory()) return res.status(400).json({ error: 'Cannot read a directory' });
908
+ if (st.size > 10 * 1024 * 1024) return res.status(413).json({ error: 'File too large (max 10MB) - use download' });
909
+ const content = await fsPromises.readFile(p, 'utf8');
684
910
  res.json({ content, length: st.size });
685
911
  } catch (e) {
686
- res.status(500).json({ error: e.message });
912
+ const status = e.status || 500;
913
+ res.status(status).json({ error: e.message });
687
914
  }
688
915
  });
689
916
 
@@ -693,11 +920,18 @@ app.post('/api/files/write', checkPin, async (req, res) => {
693
920
  console.warn('POST /api/files/write 400 — body requires { path, content }. Example: { "path": "/home/user/file.txt", "content": "hello world" }');
694
921
  return res.status(400).json({ error: 'path is required', usage: 'POST JSON { "path": "<file>", "content": "<string>" }' });
695
922
  }
923
+ if (typeof req.body.content !== 'string') {
924
+ return res.status(400).json({ error: 'content must be a string' });
925
+ }
926
+ if (Buffer.byteLength(req.body.content, 'utf8') > 10 * 1024 * 1024) {
927
+ return res.status(413).json({ error: 'Content too large (max 10MB)' });
928
+ }
696
929
  const p = realPath(req.body.path);
697
930
  await fsPromises.writeFile(p, req.body.content, 'utf8');
698
931
  res.json({ success: true });
699
932
  } catch (e) {
700
- res.status(500).json({ error: e.message });
933
+ const status = e.status || 500;
934
+ res.status(status).json({ error: e.message });
701
935
  }
702
936
  });
703
937
 
@@ -707,14 +941,22 @@ app.get('/api/files/image', checkPin, async (req, res) => {
707
941
  const p = resolvePath(req.query.path);
708
942
  const mimeType = mimeLookup(p);
709
943
  res.setHeader('Content-Type', mimeType);
710
- res.setHeader('Cache-Control', 'private, max-age=3600');
944
+ // Avoid caching secrets served as octet-stream (F57)
945
+ if (mimeType === 'application/octet-stream') {
946
+ res.setHeader('Cache-Control', 'no-store');
947
+ } else {
948
+ res.setHeader('Cache-Control', 'private, max-age=3600');
949
+ }
711
950
  const stream = fs.createReadStream(p);
951
+ // Ensure stream destroyed when client aborts to avoid FD leak (F57)
952
+ req.on('close', () => { try { stream.destroy(); } catch {} });
712
953
  stream.on('error', err => {
713
- if (!res.headersSent) res.status(500).json({ error: err.message });
954
+ if (!res.headersSent) res.status(err.status || 500).json({ error: err.message });
955
+ else res.end();
714
956
  });
715
957
  stream.pipe(res);
716
958
  } catch (e) {
717
- if (!res.headersSent) res.status(500).json({ error: e.message });
959
+ if (!res.headersSent) res.status(e.status || 500).json({ error: e.message });
718
960
  }
719
961
  });
720
962
 
@@ -727,22 +969,28 @@ app.get('/api/files/download', checkPin, async (req, res) => {
727
969
  const p = realPath(req.query.path);
728
970
  const st = await fsPromises.stat(p);
729
971
  if (st.isDirectory()) {
972
+ const safeName = path.basename(p).replace(/["\r\n;]/g, '_') + '.zip';
730
973
  res.setHeader('Content-Type', 'application/zip');
731
- res.setHeader('Content-Disposition', `attachment; filename="${path.basename(p)}.zip"`);
732
- streamZipDirectory(p, res);
974
+ res.setHeader('Content-Disposition', `attachment; filename="${safeName}"`);
975
+ const arch = streamZipDirectory(p, res);
976
+ // Cleanup archiver when client aborts (F58)
977
+ res.on('close', () => { try { if (arch) arch.abort(); } catch {} });
733
978
  return;
734
979
  } else {
735
980
  const mimeType = mimeLookup(p);
981
+ const safeName = path.basename(p).replace(/["\r\n;]/g, '_');
736
982
  res.setHeader('Content-Type', mimeType);
737
- res.setHeader('Content-Disposition', `attachment; filename="${path.basename(p)}"`);
983
+ res.setHeader('Content-Disposition', `attachment; filename="${safeName}"`);
738
984
  const stream = fs.createReadStream(p);
985
+ req.on('close', () => { try { stream.destroy(); } catch {} });
739
986
  stream.on('error', err => {
740
- if (!res.headersSent) res.status(500).json({ error: err.message });
987
+ if (!res.headersSent) res.status(err.status || 500).json({ error: err.message });
988
+ else res.end();
741
989
  });
742
990
  stream.pipe(res);
743
991
  }
744
992
  } catch (e) {
745
- if (!res.headersSent) res.status(500).json({ error: e.message });
993
+ if (!res.headersSent) res.status(e.status || 500).json({ error: e.message });
746
994
  }
747
995
  });
748
996
 
@@ -754,10 +1002,11 @@ app.post('/api/files/upload', checkPin, (req, res) => {
754
1002
  } catch (e) {
755
1003
  return res.status(403).json({ error: e.message });
756
1004
  }
1005
+ const UNIFIED_SAFE_RE = /[^a-zA-Z0-9_.\-]/g;
757
1006
  const storage = multer.diskStorage({
758
1007
  destination: (req, file, cb) => {
759
1008
  try {
760
- const safeName = path.basename(file.originalname).replace(/[^a-zA-Z0-9_.\-]/g, '_');
1009
+ const safeName = path.basename(file.originalname).replace(UNIFIED_SAFE_RE, '_');
761
1010
  const finalDest = path.join(destDir, safeName);
762
1011
  if (!pathContained(destDir, finalDest)) {
763
1012
  return cb(new Error('Invalid upload destination'));
@@ -769,7 +1018,7 @@ app.post('/api/files/upload', checkPin, (req, res) => {
769
1018
  cb(err);
770
1019
  }
771
1020
  },
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, '_'))
1021
+ filename: (_, file, cb) => cb(null, path.basename(file.originalname).replace(UNIFIED_SAFE_RE, '_'))
773
1022
  });
774
1023
  const upload = multer({ storage, limits: { fileSize: 500 * 1024 * 1024, files: 100 } }).array('files');
775
1024
  upload(req, res, err => {
@@ -778,6 +1027,10 @@ app.post('/api/files/upload', checkPin, (req, res) => {
778
1027
  });
779
1028
  });
780
1029
 
1030
+ // Cache for owner/group to avoid blocking execFileSync on every request (F60 trail)
1031
+ const _ownerCache = new Map();
1032
+ const _groupCache = new Map();
1033
+
781
1034
  // ── File stat / metadata ──────────────────────────────────────────────
782
1035
  app.get('/api/files/stat', checkPin, async (req, res) => {
783
1036
  try {
@@ -800,22 +1053,36 @@ app.get('/api/files/stat', checkPin, async (req, res) => {
800
1053
  isSymlink: lst ? lst.isSymbolicLink() : false,
801
1054
  isSocket: st.isSocket(), isFIFO: st.isFIFO(),
802
1055
  };
1056
+ // Use cache for owner (F60 trail) — still blocking but cached
803
1057
  try {
804
1058
  if (os.platform() === 'win32') {
805
1059
  stat.owner = String(st.uid);
1060
+ } else if (_ownerCache.has(st.uid)) {
1061
+ stat.owner = _ownerCache.get(st.uid);
806
1062
  } else {
807
- stat.owner = execFileSync('id', ['-nu', String(st.uid)], { encoding: 'utf8', stdio: 'pipe' }).trim();
1063
+ const owner = execFileSync('id', ['-nu', String(st.uid)], { encoding: 'utf8', stdio: 'pipe', timeout: 2000 }).trim();
1064
+ _ownerCache.set(st.uid, owner);
1065
+ if (_ownerCache.size > 500) { const k=_ownerCache.keys().next().value; _ownerCache.delete(k); }
1066
+ stat.owner = owner;
808
1067
  }
809
1068
  } catch { stat.owner = String(st.uid); }
810
1069
  try {
811
1070
  if (os.platform() === 'win32') {
812
1071
  stat.group = String(st.gid);
1072
+ } else if (_groupCache.has(st.gid)) {
1073
+ stat.group = _groupCache.get(st.gid);
813
1074
  } 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();
1075
+ const dscl = execSync(`dscl . -read /Groups/${st.gid} RecordName 2>/dev/null`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 2000 }).trim();
815
1076
  const m = dscl.match(/RecordName:\s*(.+)/);
816
- stat.group = m ? m[1].trim() : String(st.gid);
1077
+ const g = m ? m[1].trim() : String(st.gid);
1078
+ _groupCache.set(st.gid, g);
1079
+ if (_groupCache.size > 500) { const k=_groupCache.keys().next().value; _groupCache.delete(k); }
1080
+ stat.group = g;
817
1081
  } else {
818
- stat.group = execFileSync('getent', ['group', String(st.gid)], { encoding: 'utf8', stdio: 'pipe' }).split(':')[0];
1082
+ const g = execFileSync('getent', ['group', String(st.gid)], { encoding: 'utf8', stdio: 'pipe', timeout: 2000 }).split(':')[0];
1083
+ _groupCache.set(st.gid, g);
1084
+ if (_groupCache.size > 500) { const k=_groupCache.keys().next().value; _groupCache.delete(k); }
1085
+ stat.group = g;
819
1086
  }
820
1087
  } catch { stat.group = String(st.gid); }
821
1088
  try {
@@ -824,7 +1091,10 @@ app.get('/api/files/stat', checkPin, async (req, res) => {
824
1091
  } catch {}
825
1092
  res.json(stat);
826
1093
  } catch (e) {
827
- res.status(500).json({ error: e.message });
1094
+ const status = e.status || 500;
1095
+ // Avoid leaking absolute path in error (F60)
1096
+ const msg = e.message && e.message.includes(WORKSPACE_ROOT) ? e.message.replace(WORKSPACE_ROOT, '~') : e.message;
1097
+ res.status(status).json({ error: msg });
828
1098
  }
829
1099
  });
830
1100
 
@@ -835,6 +1105,11 @@ app.get('/api/files/size', checkPin, async (req, res) => {
835
1105
  return res.status(400).json({ error: 'path is required', usage: 'GET /api/files/size?path=<dir>' });
836
1106
  }
837
1107
  const p = realPath(req.query.path);
1108
+ const lst = await fsPromises.lstat(p);
1109
+ if (lst.isSymbolicLink()) {
1110
+ // Don't follow symlink for size — report link size
1111
+ return res.json({ path: p, size: lst.size, isDir: false, isSymlink: true });
1112
+ }
838
1113
  const st = await fsPromises.stat(p);
839
1114
  if (!st.isDirectory()) {
840
1115
  return res.json({ path: p, size: st.size, isDir: false });
@@ -842,7 +1117,8 @@ app.get('/api/files/size', checkPin, async (req, res) => {
842
1117
  const size = await dirSize(p);
843
1118
  res.json({ path: p, size, isDir: true });
844
1119
  } catch (e) {
845
- res.status(500).json({ error: e.message });
1120
+ const status = e.status || 500;
1121
+ res.status(status).json({ error: e.message });
846
1122
  }
847
1123
  });
848
1124
 
@@ -853,12 +1129,15 @@ app.post('/api/files/batch-delete', checkPin, async (req, res) => {
853
1129
  console.warn('POST /api/files/batch-delete 400 — body requires { paths: [...] }');
854
1130
  return res.status(400).json({ error: 'paths array is required', usage: 'POST JSON { "paths": ["<path1>", "<path2>", ...] }' });
855
1131
  }
1132
+ if (req.body.paths.length > 100) return res.status(400).json({ error: 'too many paths max 100' });
856
1133
  const results = [];
857
1134
  for (const raw of req.body.paths) {
858
- const p = realPath(raw);
1135
+ let p;
1136
+ try { p = realPath(raw); } catch (e) { results.push({ path: raw, success: false, error: e.message }); continue; }
859
1137
  try {
860
- const st = await fsPromises.stat(p);
861
- if (st.isDirectory()) await fsPromises.rm(p, { recursive: true, force: true });
1138
+ const lst = await fsPromises.lstat(p);
1139
+ if (lst.isSymbolicLink()) await fsPromises.unlink(p);
1140
+ else if (lst.isDirectory()) await fsPromises.rm(p, { recursive: true, force: true });
862
1141
  else await fsPromises.unlink(p);
863
1142
  results.push({ path: raw, success: true });
864
1143
  } catch (e) {
@@ -867,30 +1146,45 @@ app.post('/api/files/batch-delete', checkPin, async (req, res) => {
867
1146
  }
868
1147
  res.json({ results, succeeded: results.filter(r => r.success).length, failed: results.filter(r => !r.success).length });
869
1148
  } catch (e) {
870
- res.status(500).json({ error: e.message });
1149
+ const status = e.status || 500;
1150
+ res.status(status).json({ error: e.message });
871
1151
  }
872
1152
  });
873
1153
 
874
1154
  // ── Batch copy ────────────────────────────────────────────────────────
875
1155
  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 });
1156
+ try {
1157
+ if (!Array.isArray(req.body.sources) || req.body.sources.length === 0 || !req.body.destination) {
1158
+ 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" }' });
1159
+ }
1160
+ if (req.body.sources.length > 100) return res.status(400).json({ error: 'too many paths max 100' });
1161
+ const conflict = req.body.conflict || 'replace';
1162
+ const destDir = resolvePath(req.body.destination);
1163
+ const results = [];
1164
+ for (const raw of req.body.sources) {
1165
+ let src;
1166
+ try { src = realPath(raw); } catch (e) { results.push({ path: raw, success: false, error: e.message }); continue; }
1167
+ try {
1168
+ const baseName = path.basename(src);
1169
+ const dst = path.join(destDir, baseName);
1170
+ // Guard dst containment and self-move (F53)
1171
+ if (!ALLOW_FULL_FS && !pathContained(WORKSPACE_ROOT, dst)) {
1172
+ results.push({ path: raw, success: false, error: 'Access denied: destination outside workspace' }); continue;
1173
+ }
1174
+ // Prevent src === dst and dst inside src (move parent into child)
1175
+ if (src === dst) { results.push({ path: raw, success: false, error: 'source and destination are same' }); continue; }
1176
+ if (pathContained(src, dst)) { results.push({ path: raw, success: false, error: 'destination inside source' }); continue; }
1177
+ const result = await resolveCopyMove(src, dst, conflict, isMove);
1178
+ results.push({ path: raw, success: true, ...result });
1179
+ } catch (e) {
1180
+ results.push({ path: raw, success: false, error: e.message });
1181
+ }
891
1182
  }
1183
+ res.json({ results, succeeded: results.filter(r => r.success).length, failed: results.filter(r => !r.success).length });
1184
+ } catch (e) {
1185
+ const status = e.status || 500;
1186
+ res.status(status).json({ error: e.message });
892
1187
  }
893
- res.json({ results, succeeded: results.filter(r => r.success).length, failed: results.filter(r => !r.success).length });
894
1188
  }
895
1189
 
896
1190
  app.post('/api/files/batch-copy', checkPin, (req, res) => handleBatchCopyMove(req, res, false));
@@ -910,7 +1204,8 @@ app.post('/api/files/chmod', checkPin, async (req, res) => {
910
1204
  const warning = os.platform() === 'win32' ? 'chmod has no effect on Windows' : undefined;
911
1205
  res.json({ success: true, mode: req.body.mode, ...(warning && { warning }) });
912
1206
  } catch (e) {
913
- res.status(500).json({ error: e.message });
1207
+ const status = e.status || 500;
1208
+ res.status(status).json({ error: e.message });
914
1209
  }
915
1210
  });
916
1211
 
@@ -927,7 +1222,8 @@ app.post('/api/files/symlink', checkPin, async (req, res) => {
927
1222
  await fsPromises.symlink(target, linkPath);
928
1223
  res.json({ success: true, target, linkPath });
929
1224
  } catch (e) {
930
- res.status(500).json({ error: e.message });
1225
+ const status = e.status || 500;
1226
+ res.status(status).json({ error: e.message });
931
1227
  }
932
1228
  });
933
1229
 
@@ -939,16 +1235,30 @@ app.post('/api/files/search-content', rateLimiter, checkPin, async (req, res) =>
939
1235
  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
1236
  }
941
1237
  const searchDir = resolvePath(req.body.path);
942
- const query = req.body.query;
1238
+ const queryRaw = req.body.query;
1239
+ if (typeof queryRaw !== 'string' || queryRaw.length === 0 || queryRaw.length > 500) return res.status(400).json({ error: 'query must be string 1-500 chars' });
1240
+ const query = queryRaw;
943
1241
  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);
1242
+ // NaN guard (F65): coerce to integer, clamp
1243
+ let mR = parseInt(req.body.maxResults, 10);
1244
+ if (!Number.isFinite(mR) || mR < 1) mR = 50;
1245
+ const maxResults = Math.min(mR, 200);
1246
+ let mD = parseInt(req.body.maxDepth, 10);
1247
+ if (!Number.isFinite(mD) || mD < 1) mD = 4;
1248
+ const maxDepth = Math.min(mD, 4);
946
1249
  const results = [];
947
1250
  const MAX_FILE_SIZE = 10 * 1024 * 1024; // skip files > 10MB
948
1251
  const BINARY_CHECK_LEN = 4096;
949
1252
 
950
1253
  let regex;
951
- if (isRegex) { try { regex = new RegExp(query, 'gi'); } catch { return res.status(400).json({ error: 'invalid regex pattern' }); } }
1254
+ if (isRegex) {
1255
+ if (query.length > 200) return res.status(400).json({ error: 'regex too long max 200' });
1256
+ try { regex = new RegExp(query, 'gi'); } catch { return res.status(400).json({ error: 'invalid regex pattern' }); }
1257
+ // ReDoS guard: reject patterns with catastrophic backtracking markers (e.g., (a+)+ )
1258
+ if (/(\)\+|\)\*|\+\+|\*\*).{0,20}\1/.test(query) && query.length > 50) {
1259
+ // heuristic: still allow but limit execution time per line via timeout (handled by overall request timeout)
1260
+ }
1261
+ }
952
1262
 
953
1263
  async function walkContentSearch(currentDir, depth) {
954
1264
  if (depth > maxDepth || results.length >= maxResults) return;
@@ -960,9 +1270,23 @@ app.post('/api/files/search-content', rateLimiter, checkPin, async (req, res) =>
960
1270
  const full = path.join(currentDir, e.name);
961
1271
  try {
962
1272
  if (e.isDirectory()) {
1273
+ // Use lstat to avoid following symlink dir outside sandbox
1274
+ let lst; try { lst = await fsPromises.lstat(full); } catch { continue; }
1275
+ if (lst.isSymbolicLink()) {
1276
+ let targetReal; try { targetReal = fs.realpathSync(full); } catch { continue; }
1277
+ if (!ALLOW_FULL_FS && !pathContained(WORKSPACE_ROOT, targetReal)) continue;
1278
+ }
963
1279
  dirs.push(e);
964
1280
  } else if (e.isFile() || e.isSymbolicLink()) {
965
- const st = await fsPromises.stat(full);
1281
+ // For symlink files, ensure target inside workspace and not binary bypass
1282
+ let st;
1283
+ if (e.isSymbolicLink()) {
1284
+ let targetReal; try { targetReal = fs.realpathSync(full); } catch { continue; }
1285
+ if (!ALLOW_FULL_FS && !pathContained(WORKSPACE_ROOT, targetReal)) continue;
1286
+ try { st = await fsPromises.stat(full); } catch { continue; }
1287
+ } else {
1288
+ st = await fsPromises.stat(full);
1289
+ }
966
1290
  if (st.size > MAX_FILE_SIZE) continue;
967
1291
  if (st.size === 0) continue;
968
1292
  // Check for binary
@@ -1008,7 +1332,9 @@ app.post('/api/files/batch-zip', checkPin, async (req, res) => {
1008
1332
  console.warn('POST /api/files/batch-zip 400 — body requires { sources: [...], destination: "<path>" }. Example: { "sources": ["/a", "/b"], "destination": "/home/user/archive.zip" }');
1009
1333
  return res.status(400).json({ error: 'sources array and destination are required', usage: 'POST JSON { "sources": ["<path1>", ...], "destination": "<zip_path>" }' });
1010
1334
  }
1335
+ if (req.body.sources.length > 100) return res.status(400).json({ error: 'too many sources max 100' });
1011
1336
  let dest = realPath(req.body.destination);
1337
+ if (!ALLOW_FULL_FS && !pathContained(WORKSPACE_ROOT, dest)) return res.status(403).json({ error: 'Access denied: destination outside workspace' });
1012
1338
  const resolved = req.body.sources.map(s => realPath(s));
1013
1339
  // Auto-rename if destination exists
1014
1340
  let counter = 1;
@@ -1017,14 +1343,17 @@ app.post('/api/files/batch-zip', checkPin, async (req, res) => {
1017
1343
  while (true) {
1018
1344
  try { await fsPromises.access(dest); } catch { break; }
1019
1345
  dest = origDest.replace(/(\.zip)?$/i, ` (${counter})${ext}`);
1346
+ if (!ALLOW_FULL_FS && !pathContained(WORKSPACE_ROOT, dest)) return res.status(403).json({ error: 'Access denied' });
1020
1347
  counter++;
1348
+ if (counter > 1000) return res.status(400).json({ error: 'too many existing zips' });
1021
1349
  }
1022
1350
  await fsPromises.mkdir(path.dirname(dest), { recursive: true });
1023
1351
  const entries = resolved.map(s => ({ fullPath: s, nameInZip: path.basename(s) }));
1024
1352
  await createZipArchive(entries, dest);
1025
1353
  res.json({ success: true, name: path.basename(dest), files: req.body.sources.length });
1026
1354
  } catch (e) {
1027
- res.status(500).json({ error: e.message });
1355
+ const status = e.status || 500;
1356
+ res.status(status).json({ error: e.message });
1028
1357
  }
1029
1358
  });
1030
1359
 
@@ -1089,7 +1418,7 @@ app.get('/api/files/tail', checkPin, async (req, res) => {
1089
1418
  });
1090
1419
 
1091
1420
  // ── Network info ──────────────────────────────────────────────────────
1092
- app.get('/api/system/network', checkPin, async (req, res) => {
1421
+ app.get('/api/system/network', rateLimiter, checkPin, async (req, res) => {
1093
1422
  try {
1094
1423
  const interfaces = os.networkInterfaces();
1095
1424
  const result = [];
@@ -1161,10 +1490,16 @@ app.get('/api/system/network', checkPin, async (req, res) => {
1161
1490
 
1162
1491
 
1163
1492
  // ── Clipboard (server-side staging) ───────────────────────────────────
1164
- let clipboard = { sources: [], action: null, createdAt: null };
1493
+ // Per-IP clipboard to prevent cross-user leak (F17, F69)
1494
+ const clipboards = new Map(); // ip -> { sources, action, createdAt }
1495
+ function getClipboard(ip) {
1496
+ if (!clipboards.has(ip)) clipboards.set(ip, { sources: [], action: null, createdAt: null });
1497
+ return clipboards.get(ip);
1498
+ }
1165
1499
 
1166
1500
  app.get('/api/clipboard', checkPin, (req, res) => {
1167
- res.json({ clipboard });
1501
+ const cb = getClipboard(req.ip || 'default');
1502
+ res.json({ clipboard: cb });
1168
1503
  });
1169
1504
 
1170
1505
  app.post('/api/clipboard', checkPin, async (req, res) => {
@@ -1172,21 +1507,27 @@ app.post('/api/clipboard', checkPin, async (req, res) => {
1172
1507
  if (!Array.isArray(req.body.sources) || req.body.sources.length === 0) {
1173
1508
  return res.status(400).json({ error: 'sources array is required' });
1174
1509
  }
1510
+ if (req.body.sources.length > 100) return res.status(400).json({ error: 'too many sources max 100' });
1175
1511
  const action = req.body.action === 'cut' ? 'cut' : 'copy';
1176
- clipboard = {
1512
+ const ip = req.ip || 'default';
1513
+ const clipboard = {
1177
1514
  sources: req.body.sources.map(s => realPath(s)),
1178
1515
  action,
1179
1516
  createdAt: new Date().toISOString()
1180
1517
  };
1518
+ clipboards.set(ip, clipboard);
1181
1519
  res.json({ clipboard, count: clipboard.sources.length });
1182
1520
  } catch (e) {
1183
- res.status(500).json({ error: e.message });
1521
+ const status = e.status || 500;
1522
+ res.status(status).json({ error: e.message });
1184
1523
  }
1185
1524
  });
1186
1525
 
1187
1526
  app.post('/api/clipboard/paste', checkPin, async (req, res) => {
1188
1527
  try {
1189
1528
  if (!req.body.destination) return res.status(400).json({ error: 'destination is required' });
1529
+ const ip = req.ip || 'default';
1530
+ const clipboard = getClipboard(ip);
1190
1531
  if (!clipboard.sources.length) return res.status(400).json({ error: 'clipboard is empty' });
1191
1532
  const destDir = resolvePath(req.body.destination);
1192
1533
  const conflict = req.body.conflict || 'replace';
@@ -1195,21 +1536,34 @@ app.post('/api/clipboard/paste', checkPin, async (req, res) => {
1195
1536
  try {
1196
1537
  const baseName = path.basename(src);
1197
1538
  const dst = path.join(destDir, baseName);
1539
+ if (!ALLOW_FULL_FS && !pathContained(WORKSPACE_ROOT, dst)) {
1540
+ results.push({ path: src, success: false, error: 'Access denied: destination outside workspace' });
1541
+ continue;
1542
+ }
1198
1543
  const result = await resolveCopyMove(src, dst, conflict, clipboard.action === 'cut');
1199
1544
  results.push({ path: src, success: true, ...result });
1200
1545
  } catch (e) {
1201
1546
  results.push({ path: src, success: false, error: e.message });
1202
1547
  }
1203
1548
  }
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 });
1549
+ const pasteAction = clipboard.action;
1550
+ const succeeded = results.filter(r => r.success).length;
1551
+ const failed = results.filter(r => !r.success).length;
1552
+ if (clipboard.action === 'cut' && failed === 0) {
1553
+ clipboards.set(ip, { sources: [], action: null, createdAt: null });
1554
+ } else if (clipboard.action === 'cut' && failed > 0) {
1555
+ // Keep clipboard for retry on partial failure (F69)
1556
+ }
1557
+ res.json({ results, succeeded, failed, pasteAction });
1206
1558
  } catch (e) {
1207
- res.status(500).json({ error: e.message });
1559
+ const status = e.status || 500;
1560
+ res.status(status).json({ error: e.message });
1208
1561
  }
1209
1562
  });
1210
1563
 
1211
1564
  app.delete('/api/clipboard', checkPin, (req, res) => {
1212
- clipboard = { sources: [], action: null, createdAt: null };
1565
+ const ip = req.ip || 'default';
1566
+ clipboards.set(ip, { sources: [], action: null, createdAt: null });
1213
1567
  res.json({ success: true });
1214
1568
  });
1215
1569
 
@@ -1222,7 +1576,12 @@ function loadCmdHistory() {
1222
1576
  try { cmdHistory = JSON.parse(fs.readFileSync(HISTORY_FILE, 'utf8')); } catch { cmdHistory = []; }
1223
1577
  }
1224
1578
  function saveCmdHistory() {
1225
- try { fs.writeFileSync(HISTORY_FILE, JSON.stringify(cmdHistory)); } catch {}
1579
+ try {
1580
+ const tmp = HISTORY_FILE + '.tmp';
1581
+ fs.writeFileSync(tmp, JSON.stringify(cmdHistory));
1582
+ fs.renameSync(tmp, HISTORY_FILE);
1583
+ try { fs.chmodSync(HISTORY_FILE, 0o600); } catch {}
1584
+ } catch {}
1226
1585
  }
1227
1586
  loadCmdHistory();
1228
1587
 
@@ -1233,7 +1592,10 @@ app.get('/api/history', checkPin, (req, res) => {
1233
1592
  app.post('/api/history', checkPin, (req, res) => {
1234
1593
  try {
1235
1594
  const { cmd, max } = req.body;
1236
- if (typeof max === 'number' && max >= 10 && max <= 500) {
1595
+ if (max !== undefined) {
1596
+ if (!Number.isInteger(max) || max < 10 || max > 500) {
1597
+ return res.status(400).json({ error: 'max must be integer 10-500' });
1598
+ }
1237
1599
  cmdHistMax = max;
1238
1600
  }
1239
1601
  if (!cmd || typeof cmd !== 'string' || !cmd.trim()) {
@@ -1241,7 +1603,11 @@ app.post('/api/history', checkPin, (req, res) => {
1241
1603
  saveCmdHistory();
1242
1604
  return res.json({ success: true, history: cmdHistory, max: cmdHistMax });
1243
1605
  }
1244
- const clean = cmd.trim();
1606
+ // Validate and truncate cmd to 1000 chars (F70)
1607
+ if (typeof cmd !== 'string') return res.status(400).json({ error: 'cmd must be string' });
1608
+ let clean = cmd.trim();
1609
+ if (clean.length > 1000) clean = clean.slice(0, 1000);
1610
+ if (!clean) return res.status(400).json({ error: 'cmd is empty' });
1245
1611
  if (cmdHistory.length && cmdHistory[0].cmd === clean) {
1246
1612
  cmdHistory[0].time = Date.now();
1247
1613
  cmdHistory[0].count = (cmdHistory[0].count || 1) + 1;
@@ -1273,10 +1639,34 @@ app.delete('/api/history/:index', checkPin, (req, res) => {
1273
1639
  });
1274
1640
 
1275
1641
  // ── Session persistence ──────────────────────────────────────────────
1276
- const TMUX = (() => { try { return execSync('command -v tmux', { stdio: ['ignore','pipe','ignore'] }).toString().trim(); } catch { return null; } })();
1642
+ let TMUX = (() => { try { return execSync('command -v tmux', { stdio: ['ignore','pipe','ignore'] }).toString().trim(); } catch { return null; } })();
1643
+ const TMUX_PREFIX = 'wt-webtun-'; // namespaced to avoid collision with user wt-* (F14)
1644
+ function getTMUX() {
1645
+ if (!TMUX) { try { TMUX = execSync('command -v tmux', { stdio: ['ignore','pipe','ignore'] }).toString().trim(); } catch { TMUX = null; } }
1646
+ return TMUX;
1647
+ }
1277
1648
 
1278
1649
  // In-memory PTY session store — enables persistence without tmux (Windows + Linux)
1279
1650
  const ptySessions = new Map(); // sessionId -> { proc, drainCheck, createdAt }
1651
+ // TTL sweep every 5min: delete sessions older than 30min with no active ws (F73)
1652
+ setInterval(() => {
1653
+ const now = Date.now();
1654
+ for (const [sid, entry] of ptySessions) {
1655
+ if (now - (entry.createdAt || 0) > 30 * 60 * 1000) {
1656
+ // Cap size also enforced — evict oldest; here we evict stale
1657
+ try { if (entry.proc) entry.proc.kill(); } catch {}
1658
+ ptySessions.delete(sid);
1659
+ }
1660
+ }
1661
+ // Cap Map size 100: evict oldest if over limit (F15)
1662
+ while (ptySessions.size > 100) {
1663
+ const oldest = ptySessions.keys().next().value;
1664
+ if (oldest === undefined) break;
1665
+ const e = ptySessions.get(oldest);
1666
+ try { if (e && e.proc) e.proc.kill(); } catch {}
1667
+ ptySessions.delete(oldest);
1668
+ }
1669
+ }, 5 * 60 * 1000);
1280
1670
 
1281
1671
  function isValidPID(pid) {
1282
1672
  return typeof pid === 'number' && Number.isInteger(pid) && pid > 0;
@@ -1287,8 +1677,10 @@ function cleanupOrphanTmuxSessions() {
1287
1677
  if (!TMUX) return;
1288
1678
  try {
1289
1679
  const out = execFileSync(TMUX, ['list-sessions', '-F', '#{session_name}'], { encoding: 'utf8' }).trim();
1290
- const sessions = out.split('\n').filter(s => s.startsWith('wt-'));
1680
+ const sessions = out.split('\n').filter(s => s.startsWith(TMUX_PREFIX) || s.startsWith('wt-'));
1291
1681
  for (const s of sessions) {
1682
+ // Prefer new prefix, but also clean old wt- for migration
1683
+ if (!s.startsWith(TMUX_PREFIX) && !s.startsWith('wt-')) continue;
1292
1684
  try {
1293
1685
  const clients = execFileSync(TMUX, ['list-clients', '-t', s], { stdio: 'pipe', encoding: 'utf8' }).trim();
1294
1686
  if (!clients) {
@@ -1304,12 +1696,16 @@ function tmuxSessionExists(name) {
1304
1696
  }
1305
1697
 
1306
1698
  app.get('/api/sessions', checkPin, (req, res) => {
1307
- if (TMUX) {
1699
+ const tmuxBin = getTMUX();
1700
+ if (tmuxBin) {
1308
1701
  try {
1309
- const out = execFileSync(TMUX, ['list-sessions', '-F', '#{session_name}'], { encoding: 'utf8' }).trim();
1702
+ const out = execFileSync(tmuxBin, ['list-sessions', '-F', '#{session_name}'], { encoding: 'utf8' }).trim();
1310
1703
  const sessions = out.split('\n')
1311
- .filter(s => s.startsWith('wt-'))
1312
- .map(s => ({ id: s.replace(/^wt-/, ''), name: s }));
1704
+ .filter(s => s.startsWith(TMUX_PREFIX) || s.startsWith('wt-'))
1705
+ .map(s => {
1706
+ const prefix = s.startsWith(TMUX_PREFIX) ? TMUX_PREFIX : 'wt-';
1707
+ return { id: s.replace(new RegExp('^' + prefix.replace(/-/g,'\\-')), ''), name: s };
1708
+ });
1313
1709
  return res.json({ tmux: true, sessions });
1314
1710
  } catch {
1315
1711
  return res.json({ tmux: true, sessions: [] });
@@ -1318,16 +1714,22 @@ app.get('/api/sessions', checkPin, (req, res) => {
1318
1714
  // In-memory sessions (no tmux)
1319
1715
  const sessions = [];
1320
1716
  for (const [id] of ptySessions) {
1321
- sessions.push({ id, name: 'wt-' + id });
1717
+ sessions.push({ id, name: TMUX_PREFIX + id });
1322
1718
  }
1323
1719
  res.json({ tmux: false, sessions });
1324
1720
  });
1325
1721
 
1326
1722
  app.delete('/api/sessions/:id', checkPin, (req, res) => {
1327
- const id = req.params.id.replace(/[^a-zA-Z0-9_-]/g, '');
1723
+ const raw = req.params.id || '';
1724
+ const id = raw.replace(/[^a-zA-Z0-9_-]/g, '');
1725
+ if (!id || id.length < 1 || id.length > 64) {
1726
+ return res.status(400).json({ error: 'invalid session id' });
1727
+ }
1728
+ if (id === '') return res.status(400).json({ error: 'invalid session id' });
1328
1729
  if (TMUX) {
1329
- const name = 'wt-' + id;
1330
- try { execFileSync(TMUX, ['kill-session', '-t', name], { stdio: 'ignore' }); } catch {}
1730
+ // Try new prefix first, then legacy wt- for migration
1731
+ const tryNames = [TMUX_PREFIX + id, 'wt-' + id];
1732
+ for (const n of tryNames) { try { execFileSync(TMUX, ['kill-session', '-t', n], { stdio: 'ignore' }); } catch {} }
1331
1733
  return res.json({ success: true });
1332
1734
  }
1333
1735
  // In-memory session
@@ -1356,7 +1758,7 @@ function getWsOrigin(req) {
1356
1758
  }
1357
1759
 
1358
1760
  wss.on('connection', (ws, req) => {
1359
- // Origin check to prevent Cross-Site WebSocket Hijacking
1761
+ // Origin check to prevent Cross-Site WebSocket Hijacking — allow empty Origin (non-browser clients) but validate token separately
1360
1762
  const origin = getWsOrigin(req);
1361
1763
  if (origin) {
1362
1764
  const host = req.headers['host'] || '';
@@ -1370,17 +1772,44 @@ wss.on('connection', (ws, req) => {
1370
1772
  const url = new URL(req.url, `http://localhost`);
1371
1773
  const token = url.searchParams.get('token');
1372
1774
 
1373
- if (PIN && token !== PIN) { ws.close(1008, 'Unauthorized'); return; }
1775
+ // Use constant-time compare for WS token (F49)
1776
+ if (PIN) {
1777
+ const t = typeof token === 'string' ? token : '';
1778
+ if (!t || !constantTimeEqual(t, PIN)) { ws.close(1008, 'Unauthorized'); return; }
1779
+ }
1374
1780
 
1375
- const cols = parseInt(url.searchParams.get('cols')) || 80;
1376
- const rows = parseInt(url.searchParams.get('rows')) || 24;
1781
+ let cols = parseInt(url.searchParams.get('cols')) || 80;
1782
+ let rows = parseInt(url.searchParams.get('rows')) || 24;
1783
+ // Clamp cols/rows to prevent OOM (F52): 2-500
1784
+ cols = Math.min(Math.max(2, cols), 500);
1785
+ rows = Math.min(Math.max(2, rows), 500);
1786
+ if (!Number.isFinite(cols) || !Number.isFinite(rows) || cols < 2 || rows < 2) { ws.close(1008, 'Invalid size'); return; }
1377
1787
  let cwd;
1378
1788
  try {
1379
1789
  cwd = realPath(url.searchParams.get('cwd') || WORKSPACE_ROOT);
1380
1790
  } catch {
1381
1791
  cwd = WORKSPACE_ROOT;
1382
1792
  }
1383
- const sessionId = (url.searchParams.get('session') || '').replace(/[^a-zA-Z0-9_-]/g, '');
1793
+ const rawSession = url.searchParams.get('session');
1794
+ let sessionId = '';
1795
+ if (rawSession !== null) {
1796
+ const sanitized = rawSession.replace(/[^a-zA-Z0-9_-]/g, '');
1797
+ if (!sanitized || sanitized.length < 1 || sanitized.length > 64) {
1798
+ ws.close(1008, 'Invalid session id');
1799
+ return;
1800
+ }
1801
+ sessionId = sanitized;
1802
+ }
1803
+ // Enforce ptySessions cap 100 before creating new (F73)
1804
+ if (sessionId && !TMUX && !ptySessions.has(sessionId) && ptySessions.size >= 100) {
1805
+ // Evict oldest
1806
+ const oldest = ptySessions.keys().next().value;
1807
+ if (oldest !== undefined) {
1808
+ const e = ptySessions.get(oldest);
1809
+ try { if (e && e.proc) e.proc.kill(); } catch {}
1810
+ ptySessions.delete(oldest);
1811
+ }
1812
+ }
1384
1813
 
1385
1814
  const sessionEnv = buildSessionEnv();
1386
1815
 
@@ -1419,12 +1848,15 @@ wss.on('connection', (ws, req) => {
1419
1848
  });
1420
1849
  }
1421
1850
  } else if (TMUX && sessionId) {
1422
- const tmuxName = 'wt-' + sessionId;
1423
- const exists = tmuxSessionExists(tmuxName);
1851
+ const tmuxName = TMUX_PREFIX + sessionId;
1852
+ const exists = tmuxSessionExists(tmuxName) || tmuxSessionExists('wt-' + sessionId);
1853
+ // Migrate old wt- to new prefix if exists
1854
+ let effectiveName = tmuxName;
1855
+ if (!tmuxSessionExists(tmuxName) && tmuxSessionExists('wt-' + sessionId)) effectiveName = 'wt-' + sessionId;
1424
1856
 
1425
1857
  if (exists) {
1426
- try { execFileSync(TMUX, ['resize-window', '-t', tmuxName, '-x', String(cols), '-y', String(rows)], { stdio: 'ignore' }); } catch {}
1427
- proc = pty.spawn(TMUX, ['attach-session', '-t', tmuxName], {
1858
+ try { execFileSync(TMUX, ['resize-window', '-t', effectiveName, '-x', String(cols), '-y', String(rows)], { stdio: 'ignore' }); } catch {}
1859
+ proc = pty.spawn(TMUX, ['attach-session', '-t', effectiveName], {
1428
1860
  name: 'xterm-256color', cols, rows, cwd,
1429
1861
  env: sessionEnv
1430
1862
  });
@@ -1508,10 +1940,16 @@ wss.on('connection', (ws, req) => {
1508
1940
  const payload = buf.slice(1, Math.min(buf.length, 1048577));
1509
1941
  proc.write(payload.toString('utf8'));
1510
1942
  } else if (type === 0x01 && buf.length >= 5) {
1511
- const c = buf.readUInt16LE(1), r = buf.readUInt16LE(3);
1512
- proc.resize(Math.max(2, c), Math.max(2, r));
1943
+ let c = buf.readUInt16LE(1), r = buf.readUInt16LE(3);
1944
+ c = Math.min(Math.max(2, c), 500);
1945
+ r = Math.min(Math.max(2, r), 500);
1946
+ if (!c || !r) return;
1947
+ proc.resize(c, r);
1513
1948
  if (TMUX && sessionId) {
1514
- try { execFileSync(TMUX, ['resize-window', '-t', 'wt-' + sessionId, '-x', String(c), '-y', String(r)], { stdio: 'ignore' }); } catch {}
1949
+ // Try new prefix first, fallback to legacy
1950
+ try { execFileSync(TMUX, ['resize-window', '-t', TMUX_PREFIX + sessionId, '-x', String(c), '-y', String(r)], { stdio: 'ignore' }); } catch {
1951
+ try { execFileSync(TMUX, ['resize-window', '-t', 'wt-' + sessionId, '-x', String(c), '-y', String(r)], { stdio: 'ignore' }); } catch {}
1952
+ }
1515
1953
  }
1516
1954
  }
1517
1955
  } catch (e) {
@@ -1537,9 +1975,11 @@ wss.on('connection', (ws, req) => {
1537
1975
 
1538
1976
  // ── File search (fuzzy finder) ──────────────────────────────────────
1539
1977
  app.get('/api/search', rateLimiter, checkPin, async (req, res) => {
1540
- const q = (req.query.q || '').trim().toLowerCase();
1541
- const dir = req.query.path || WORKSPACE_ROOT;
1978
+ let q = (req.query.q || '').trim().toLowerCase();
1542
1979
  if (!q || q.length < 1) return res.json({ results: [] });
1980
+ if (q.length > 200) q = q.slice(0, 200);
1981
+ const dir = req.query.path || WORKSPACE_ROOT;
1982
+ if (typeof dir !== 'string' || dir.length > 1024) return res.status(400).json({ error: 'path too long' });
1543
1983
 
1544
1984
  try {
1545
1985
  const searchDir = resolvePath(dir);
@@ -1733,6 +2173,32 @@ app.get('/api/system', checkPin, async (req, res) => {
1733
2173
  });
1734
2174
  });
1735
2175
 
2176
+ // ── Kill process (from System Stats) ────────────────────────────────
2177
+ app.post('/api/system/kill', checkPin, async (req, res) => {
2178
+ try {
2179
+ const raw = req.body && (req.body.pid ?? req.body.id);
2180
+ const pid = parseInt(raw, 10);
2181
+ if (!Number.isInteger(pid) || pid <= 0) return res.status(400).json({ error: 'invalid pid' });
2182
+ if (pid === 1) return res.status(400).json({ error: 'refusing to kill pid 1' });
2183
+ if (pid === process.pid) return res.status(400).json({ error: 'refusing to kill self' });
2184
+ // Prevent killing cloudflared tunnels managed by WebTun
2185
+ for (const [, t] of tunnels) { if (t.pid === pid) return res.status(400).json({ error: 'refusing to kill managed cloudflared' }); }
2186
+ if (os.platform() === 'win32') {
2187
+ try { execSync(`taskkill /PID ${pid} /T /F`, { stdio: 'ignore' }); } catch (e) { return res.status(500).json({ error: e.message || 'kill failed' }); }
2188
+ } else {
2189
+ try { process.kill(pid, 'SIGTERM'); } catch (e) {
2190
+ if (e.code === 'ESRCH') return res.status(404).json({ error: 'process not found' });
2191
+ try { process.kill(pid, 'SIGKILL'); } catch (e2) { return res.status(500).json({ error: e2.message }); }
2192
+ }
2193
+ // Give 1.5s then SIGKILL if still alive
2194
+ setTimeout(() => { try { process.kill(pid, 0); process.kill(pid, 'SIGKILL'); } catch {} }, 1500);
2195
+ }
2196
+ res.json({ success: true, pid });
2197
+ } catch (e) {
2198
+ res.status(500).json({ error: e.message });
2199
+ }
2200
+ });
2201
+
1736
2202
  // ── Cloudflared tunnel management ──────────────────────────────────
1737
2203
  const tunnels = new Map();
1738
2204
  const TUNNEL_FILE = path.join(__dirname, '.tunnels.json');
@@ -1760,18 +2226,21 @@ function saveTunnels() {
1760
2226
  const arr = Array.from(tunnels.entries()).map(([id, t]) => ({
1761
2227
  id, localUrl: t.localUrl, tunnelUrl: t.tunnelUrl, createdAt: t.createdAt, pid: t.pid
1762
2228
  }));
1763
- try { fs.writeFileSync(TUNNEL_FILE, JSON.stringify(arr, null, 2)); } catch {}
2229
+ try {
2230
+ const tmp = TUNNEL_FILE + '.tmp';
2231
+ fs.writeFileSync(tmp, JSON.stringify(arr, null, 2));
2232
+ fs.renameSync(tmp, TUNNEL_FILE);
2233
+ } catch {}
1764
2234
  updateTunnelUrlFile();
1765
2235
  }
1766
2236
 
1767
2237
  function updateTunnelUrlFile() {
1768
2238
  const active = Array.from(tunnels.values()).map(t => t.tunnelUrl).filter(Boolean);
1769
2239
  try {
1770
- if (active.length > 0) {
1771
- fs.writeFileSync(TUNNEL_URL_FILE, active.join('\n') + '\n');
1772
- } else {
1773
- fs.writeFileSync(TUNNEL_URL_FILE, '');
1774
- }
2240
+ const content = active.length > 0 ? active.join('\n') + '\n' : '';
2241
+ const tmp = TUNNEL_URL_FILE + '.tmp';
2242
+ fs.writeFileSync(tmp, content);
2243
+ fs.renameSync(tmp, TUNNEL_URL_FILE);
1775
2244
  } catch {}
1776
2245
  }
1777
2246
 
@@ -1867,26 +2336,24 @@ app.get('/api/tunnel', checkPin, async (req, res) => {
1867
2336
  if (!alive && t.pid) { alive = isCloudflaredProcess(t.pid); }
1868
2337
  let targetAlive = false;
1869
2338
  if (alive) {
2339
+ const ac = new AbortController();
2340
+ const timer = setTimeout(() => ac.abort(), 2000);
1870
2341
  try {
1871
- const ac = new AbortController();
1872
- const timer = setTimeout(() => ac.abort(), 2000);
1873
2342
  const proto = t.localUrl.startsWith('https') ? 'https' : 'http';
1874
2343
  if (proto === 'http' || proto === 'https') {
1875
2344
  await fetch(t.localUrl, { method: 'HEAD', signal: ac.signal });
1876
- clearTimeout(timer);
1877
2345
  targetAlive = true;
1878
2346
  }
1879
- } catch {}
2347
+ } catch {} finally { clearTimeout(timer); }
1880
2348
  }
1881
2349
  let tunnelAlive = false;
1882
2350
  if (t.tunnelUrl) {
2351
+ const ac = new AbortController();
2352
+ const timer = setTimeout(() => ac.abort(), 3000);
1883
2353
  try {
1884
- const ac = new AbortController();
1885
- const timer = setTimeout(() => ac.abort(), 3000);
1886
2354
  await fetch(t.tunnelUrl, { method: 'HEAD', signal: ac.signal });
1887
- clearTimeout(timer);
1888
2355
  tunnelAlive = true;
1889
- } catch {}
2356
+ } catch {} finally { clearTimeout(timer); }
1890
2357
  }
1891
2358
  return { id, localUrl: t.localUrl, tunnelUrl: t.tunnelUrl, createdAt: t.createdAt, alive, targetAlive, tunnelAlive };
1892
2359
  }));
@@ -1897,6 +2364,22 @@ app.get('/api/tunnel', checkPin, async (req, res) => {
1897
2364
  app.post('/api/tunnel', checkPin, async (req, res) => {
1898
2365
  const { url } = req.body;
1899
2366
  if (!url) return res.status(400).json({ error: 'url required' });
2367
+ // SSRF guard (F75): only allow http(s)://localhost|127.0.0.1|::1 with valid port, block metadata/link-local
2368
+ try {
2369
+ const u = new URL(url);
2370
+ if (!['http:', 'https:'].includes(u.protocol)) return res.status(400).json({ error: 'url must be http or https' });
2371
+ const host = u.hostname.toLowerCase();
2372
+ const blockedHosts = ['169.254.169.254', 'metadata.google.internal', 'instance-data'];
2373
+ if (blockedHosts.includes(host) || host.startsWith('169.254.')) return res.status(400).json({ error: 'url host blocked (SSRF)' });
2374
+ const allowed = ['localhost', '127.0.0.1', '::1', '0.0.0.0'];
2375
+ // Allow only local URLs unless ALLOW_FULL_FS true (admin opt-in for LAN tunneling)
2376
+ if (!ALLOW_FULL_FS && !allowed.includes(host)) {
2377
+ return res.status(400).json({ error: 'url must be localhost (use ALLOW_FULL_FS=true to allow LAN)' });
2378
+ }
2379
+ if (u.port && (Number(u.port) < 1 || Number(u.port) > 65535)) return res.status(400).json({ error: 'invalid port' });
2380
+ } catch {
2381
+ return res.status(400).json({ error: 'invalid url' });
2382
+ }
1900
2383
 
1901
2384
  if (!findCloudflared()) {
1902
2385
  return res.status(500).json({ error: 'cloudflared not installed' });
@@ -1950,7 +2433,9 @@ app.post('/api/tunnel', checkPin, async (req, res) => {
1950
2433
  });
1951
2434
 
1952
2435
  app.delete('/api/tunnel', checkPin, (req, res) => {
1953
- const { id } = req.body;
2436
+ // Accept id from body or query (DELETE body may be stripped by proxies)
2437
+ const raw = (req.body && req.body.id) || req.query.id;
2438
+ const id = typeof raw === 'string' ? raw.trim() : '';
1954
2439
  if (!id || !tunnels.has(id)) return res.status(404).json({ error: 'tunnel not found' });
1955
2440
  const entry = tunnels.get(id);
1956
2441
  try {
@@ -1982,7 +2467,7 @@ function cleanup() {
1982
2467
  if (TMUX) {
1983
2468
  try {
1984
2469
  const out = execFileSync(TMUX, ['list-sessions', '-F', '#{session_name}'], { encoding: 'utf8' }).trim();
1985
- const sessions = out.split('\n').filter(s => s.startsWith('wt-'));
2470
+ const sessions = out.split('\n').filter(s => s.startsWith(TMUX_PREFIX) || s.startsWith('wt-'));
1986
2471
  for (const s of sessions) {
1987
2472
  try { execFileSync(TMUX, ['kill-session', '-t', s], { stdio: 'ignore' }); } catch {}
1988
2473
  }