webtun 1.5.3 → 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/README.md +18 -4
- package/bin/webtun.js +12 -0
- package/electron/main.js +176 -0
- package/electron/preload.js +7 -0
- package/package.json +43 -4
- package/postinstall.js +38 -20
- package/public/commands.js +1 -1
- package/public/index.html +544 -175
- package/public/manifest.json +1 -1
- package/public/sw.js +16 -3
- package/server.js +641 -161
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
|
-
|
|
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
|
|
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
|
-
|
|
156
|
-
|
|
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
|
-
|
|
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 (
|
|
194
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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),
|
|
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
|
-
|
|
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
|
-
|
|
572
|
-
|
|
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
|
|
591
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
681
|
-
|
|
682
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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="${
|
|
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="${
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
861
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
const
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
945
|
-
|
|
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) {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1205
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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 (
|
|
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
|
-
|
|
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;
|
|
@@ -1273,10 +1634,34 @@ app.delete('/api/history/:index', checkPin, (req, res) => {
|
|
|
1273
1634
|
});
|
|
1274
1635
|
|
|
1275
1636
|
// ── Session persistence ──────────────────────────────────────────────
|
|
1276
|
-
|
|
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
|
+
}
|
|
1277
1643
|
|
|
1278
1644
|
// In-memory PTY session store — enables persistence without tmux (Windows + Linux)
|
|
1279
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);
|
|
1280
1665
|
|
|
1281
1666
|
function isValidPID(pid) {
|
|
1282
1667
|
return typeof pid === 'number' && Number.isInteger(pid) && pid > 0;
|
|
@@ -1287,8 +1672,10 @@ function cleanupOrphanTmuxSessions() {
|
|
|
1287
1672
|
if (!TMUX) return;
|
|
1288
1673
|
try {
|
|
1289
1674
|
const out = execFileSync(TMUX, ['list-sessions', '-F', '#{session_name}'], { encoding: 'utf8' }).trim();
|
|
1290
|
-
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-'));
|
|
1291
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;
|
|
1292
1679
|
try {
|
|
1293
1680
|
const clients = execFileSync(TMUX, ['list-clients', '-t', s], { stdio: 'pipe', encoding: 'utf8' }).trim();
|
|
1294
1681
|
if (!clients) {
|
|
@@ -1304,12 +1691,16 @@ function tmuxSessionExists(name) {
|
|
|
1304
1691
|
}
|
|
1305
1692
|
|
|
1306
1693
|
app.get('/api/sessions', checkPin, (req, res) => {
|
|
1307
|
-
|
|
1694
|
+
const tmuxBin = getTMUX();
|
|
1695
|
+
if (tmuxBin) {
|
|
1308
1696
|
try {
|
|
1309
|
-
const out = execFileSync(
|
|
1697
|
+
const out = execFileSync(tmuxBin, ['list-sessions', '-F', '#{session_name}'], { encoding: 'utf8' }).trim();
|
|
1310
1698
|
const sessions = out.split('\n')
|
|
1311
|
-
.filter(s => s.startsWith('wt-'))
|
|
1312
|
-
.map(s =>
|
|
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
|
+
});
|
|
1313
1704
|
return res.json({ tmux: true, sessions });
|
|
1314
1705
|
} catch {
|
|
1315
1706
|
return res.json({ tmux: true, sessions: [] });
|
|
@@ -1318,16 +1709,22 @@ app.get('/api/sessions', checkPin, (req, res) => {
|
|
|
1318
1709
|
// In-memory sessions (no tmux)
|
|
1319
1710
|
const sessions = [];
|
|
1320
1711
|
for (const [id] of ptySessions) {
|
|
1321
|
-
sessions.push({ id, name:
|
|
1712
|
+
sessions.push({ id, name: TMUX_PREFIX + id });
|
|
1322
1713
|
}
|
|
1323
1714
|
res.json({ tmux: false, sessions });
|
|
1324
1715
|
});
|
|
1325
1716
|
|
|
1326
1717
|
app.delete('/api/sessions/:id', checkPin, (req, res) => {
|
|
1327
|
-
const
|
|
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' });
|
|
1328
1724
|
if (TMUX) {
|
|
1329
|
-
|
|
1330
|
-
|
|
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 {} }
|
|
1331
1728
|
return res.json({ success: true });
|
|
1332
1729
|
}
|
|
1333
1730
|
// In-memory session
|
|
@@ -1356,7 +1753,7 @@ function getWsOrigin(req) {
|
|
|
1356
1753
|
}
|
|
1357
1754
|
|
|
1358
1755
|
wss.on('connection', (ws, req) => {
|
|
1359
|
-
// 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
|
|
1360
1757
|
const origin = getWsOrigin(req);
|
|
1361
1758
|
if (origin) {
|
|
1362
1759
|
const host = req.headers['host'] || '';
|
|
@@ -1370,17 +1767,44 @@ wss.on('connection', (ws, req) => {
|
|
|
1370
1767
|
const url = new URL(req.url, `http://localhost`);
|
|
1371
1768
|
const token = url.searchParams.get('token');
|
|
1372
1769
|
|
|
1373
|
-
|
|
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
|
+
}
|
|
1374
1775
|
|
|
1375
|
-
|
|
1376
|
-
|
|
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; }
|
|
1377
1782
|
let cwd;
|
|
1378
1783
|
try {
|
|
1379
1784
|
cwd = realPath(url.searchParams.get('cwd') || WORKSPACE_ROOT);
|
|
1380
1785
|
} catch {
|
|
1381
1786
|
cwd = WORKSPACE_ROOT;
|
|
1382
1787
|
}
|
|
1383
|
-
const
|
|
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
|
+
}
|
|
1384
1808
|
|
|
1385
1809
|
const sessionEnv = buildSessionEnv();
|
|
1386
1810
|
|
|
@@ -1419,12 +1843,15 @@ wss.on('connection', (ws, req) => {
|
|
|
1419
1843
|
});
|
|
1420
1844
|
}
|
|
1421
1845
|
} else if (TMUX && sessionId) {
|
|
1422
|
-
const tmuxName =
|
|
1423
|
-
const exists = tmuxSessionExists(tmuxName);
|
|
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;
|
|
1424
1851
|
|
|
1425
1852
|
if (exists) {
|
|
1426
|
-
try { execFileSync(TMUX, ['resize-window', '-t',
|
|
1427
|
-
proc = pty.spawn(TMUX, ['attach-session', '-t',
|
|
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], {
|
|
1428
1855
|
name: 'xterm-256color', cols, rows, cwd,
|
|
1429
1856
|
env: sessionEnv
|
|
1430
1857
|
});
|
|
@@ -1508,10 +1935,16 @@ wss.on('connection', (ws, req) => {
|
|
|
1508
1935
|
const payload = buf.slice(1, Math.min(buf.length, 1048577));
|
|
1509
1936
|
proc.write(payload.toString('utf8'));
|
|
1510
1937
|
} else if (type === 0x01 && buf.length >= 5) {
|
|
1511
|
-
|
|
1512
|
-
|
|
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);
|
|
1513
1943
|
if (TMUX && sessionId) {
|
|
1514
|
-
|
|
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
|
+
}
|
|
1515
1948
|
}
|
|
1516
1949
|
}
|
|
1517
1950
|
} catch (e) {
|
|
@@ -1537,9 +1970,11 @@ wss.on('connection', (ws, req) => {
|
|
|
1537
1970
|
|
|
1538
1971
|
// ── File search (fuzzy finder) ──────────────────────────────────────
|
|
1539
1972
|
app.get('/api/search', rateLimiter, checkPin, async (req, res) => {
|
|
1540
|
-
|
|
1541
|
-
const dir = req.query.path || WORKSPACE_ROOT;
|
|
1973
|
+
let q = (req.query.q || '').trim().toLowerCase();
|
|
1542
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' });
|
|
1543
1978
|
|
|
1544
1979
|
try {
|
|
1545
1980
|
const searchDir = resolvePath(dir);
|
|
@@ -1733,6 +2168,32 @@ app.get('/api/system', checkPin, async (req, res) => {
|
|
|
1733
2168
|
});
|
|
1734
2169
|
});
|
|
1735
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
|
+
|
|
1736
2197
|
// ── Cloudflared tunnel management ──────────────────────────────────
|
|
1737
2198
|
const tunnels = new Map();
|
|
1738
2199
|
const TUNNEL_FILE = path.join(__dirname, '.tunnels.json');
|
|
@@ -1760,18 +2221,21 @@ function saveTunnels() {
|
|
|
1760
2221
|
const arr = Array.from(tunnels.entries()).map(([id, t]) => ({
|
|
1761
2222
|
id, localUrl: t.localUrl, tunnelUrl: t.tunnelUrl, createdAt: t.createdAt, pid: t.pid
|
|
1762
2223
|
}));
|
|
1763
|
-
try {
|
|
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 {}
|
|
1764
2229
|
updateTunnelUrlFile();
|
|
1765
2230
|
}
|
|
1766
2231
|
|
|
1767
2232
|
function updateTunnelUrlFile() {
|
|
1768
2233
|
const active = Array.from(tunnels.values()).map(t => t.tunnelUrl).filter(Boolean);
|
|
1769
2234
|
try {
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
}
|
|
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);
|
|
1775
2239
|
} catch {}
|
|
1776
2240
|
}
|
|
1777
2241
|
|
|
@@ -1867,26 +2331,24 @@ app.get('/api/tunnel', checkPin, async (req, res) => {
|
|
|
1867
2331
|
if (!alive && t.pid) { alive = isCloudflaredProcess(t.pid); }
|
|
1868
2332
|
let targetAlive = false;
|
|
1869
2333
|
if (alive) {
|
|
2334
|
+
const ac = new AbortController();
|
|
2335
|
+
const timer = setTimeout(() => ac.abort(), 2000);
|
|
1870
2336
|
try {
|
|
1871
|
-
const ac = new AbortController();
|
|
1872
|
-
const timer = setTimeout(() => ac.abort(), 2000);
|
|
1873
2337
|
const proto = t.localUrl.startsWith('https') ? 'https' : 'http';
|
|
1874
2338
|
if (proto === 'http' || proto === 'https') {
|
|
1875
2339
|
await fetch(t.localUrl, { method: 'HEAD', signal: ac.signal });
|
|
1876
|
-
clearTimeout(timer);
|
|
1877
2340
|
targetAlive = true;
|
|
1878
2341
|
}
|
|
1879
|
-
} catch {}
|
|
2342
|
+
} catch {} finally { clearTimeout(timer); }
|
|
1880
2343
|
}
|
|
1881
2344
|
let tunnelAlive = false;
|
|
1882
2345
|
if (t.tunnelUrl) {
|
|
2346
|
+
const ac = new AbortController();
|
|
2347
|
+
const timer = setTimeout(() => ac.abort(), 3000);
|
|
1883
2348
|
try {
|
|
1884
|
-
const ac = new AbortController();
|
|
1885
|
-
const timer = setTimeout(() => ac.abort(), 3000);
|
|
1886
2349
|
await fetch(t.tunnelUrl, { method: 'HEAD', signal: ac.signal });
|
|
1887
|
-
clearTimeout(timer);
|
|
1888
2350
|
tunnelAlive = true;
|
|
1889
|
-
} catch {}
|
|
2351
|
+
} catch {} finally { clearTimeout(timer); }
|
|
1890
2352
|
}
|
|
1891
2353
|
return { id, localUrl: t.localUrl, tunnelUrl: t.tunnelUrl, createdAt: t.createdAt, alive, targetAlive, tunnelAlive };
|
|
1892
2354
|
}));
|
|
@@ -1897,6 +2359,22 @@ app.get('/api/tunnel', checkPin, async (req, res) => {
|
|
|
1897
2359
|
app.post('/api/tunnel', checkPin, async (req, res) => {
|
|
1898
2360
|
const { url } = req.body;
|
|
1899
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
|
+
}
|
|
1900
2378
|
|
|
1901
2379
|
if (!findCloudflared()) {
|
|
1902
2380
|
return res.status(500).json({ error: 'cloudflared not installed' });
|
|
@@ -1950,7 +2428,9 @@ app.post('/api/tunnel', checkPin, async (req, res) => {
|
|
|
1950
2428
|
});
|
|
1951
2429
|
|
|
1952
2430
|
app.delete('/api/tunnel', checkPin, (req, res) => {
|
|
1953
|
-
|
|
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() : '';
|
|
1954
2434
|
if (!id || !tunnels.has(id)) return res.status(404).json({ error: 'tunnel not found' });
|
|
1955
2435
|
const entry = tunnels.get(id);
|
|
1956
2436
|
try {
|
|
@@ -1982,7 +2462,7 @@ function cleanup() {
|
|
|
1982
2462
|
if (TMUX) {
|
|
1983
2463
|
try {
|
|
1984
2464
|
const out = execFileSync(TMUX, ['list-sessions', '-F', '#{session_name}'], { encoding: 'utf8' }).trim();
|
|
1985
|
-
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-'));
|
|
1986
2466
|
for (const s of sessions) {
|
|
1987
2467
|
try { execFileSync(TMUX, ['kill-session', '-t', s], { stdio: 'ignore' }); } catch {}
|
|
1988
2468
|
}
|