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/README.md +25 -5
- 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 +610 -214
- package/public/manifest.json +1 -1
- package/public/sw.js +16 -3
- package/server.js +651 -166
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 !== '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
|
-
|
|
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
|
});
|
|
@@ -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
|
|
474
|
-
isSymlink
|
|
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
|
-
|
|
486
|
-
|
|
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
|
-
|
|
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),
|
|
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
|
-
|
|
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
|
-
|
|
572
|
-
|
|
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
|
|
591
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
681
|
-
|
|
682
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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="${
|
|
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="${
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
861
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
const
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
945
|
-
|
|
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) {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1205
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1699
|
+
const tmuxBin = getTMUX();
|
|
1700
|
+
if (tmuxBin) {
|
|
1308
1701
|
try {
|
|
1309
|
-
const out = execFileSync(
|
|
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 =>
|
|
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:
|
|
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
|
|
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
|
-
|
|
1330
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1376
|
-
|
|
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
|
|
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 =
|
|
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',
|
|
1427
|
-
proc = pty.spawn(TMUX, ['attach-session', '-t',
|
|
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
|
-
|
|
1512
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
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
|
-
|
|
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
|
}
|