mnfst-run 1.0.10 → 1.0.13
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/package.json +1 -1
- package/serve.mjs +86 -55
package/package.json
CHANGED
package/serve.mjs
CHANGED
|
@@ -41,7 +41,7 @@ import {
|
|
|
41
41
|
existsSync, writeFileSync, unlinkSync,
|
|
42
42
|
mkdirSync, readdirSync,
|
|
43
43
|
} from 'fs';
|
|
44
|
-
import { join, extname, resolve, basename } from 'path';
|
|
44
|
+
import { join, extname, resolve, basename, sep } from 'path';
|
|
45
45
|
import { exec } from 'child_process';
|
|
46
46
|
import { tmpdir } from 'os';
|
|
47
47
|
import { createHash } from 'crypto';
|
|
@@ -126,7 +126,17 @@ let port = process.env.PORT ? parseInt(process.env.PORT, 10) : 5001;
|
|
|
126
126
|
// sit idle overnight if the tab is still open. Disabled by
|
|
127
127
|
// `--no-idle-shutdown` for CI / headless cases where no browser will connect.
|
|
128
128
|
let idleShutdownSec = 30;
|
|
129
|
-
|
|
129
|
+
// Auto-disable idle shutdown when running under CI or Claude Code, where the
|
|
130
|
+
// host browser (puppeteer / headless Chromium) does not produce the normal
|
|
131
|
+
// `pagehide` beacon + SSE heartbeats that the live-tab tracker relies on.
|
|
132
|
+
// Without this, the server would self-exit 30s after launch even while the
|
|
133
|
+
// automation is actively driving it. Manual override still works via the
|
|
134
|
+
// `--no-idle-shutdown` / `--idle-shutdown <sec>` flags below.
|
|
135
|
+
let idleShutdownEnabled = !(
|
|
136
|
+
process.env.CI === 'true' ||
|
|
137
|
+
process.env.CLAUDE_CODE_ENTRYPOINT ||
|
|
138
|
+
process.env.CLAUDECODE
|
|
139
|
+
);
|
|
130
140
|
|
|
131
141
|
let listMode = false;
|
|
132
142
|
|
|
@@ -320,29 +330,6 @@ function dropTab(tabId) {
|
|
|
320
330
|
if (t) { clearTimeout(t); staleTimers.delete(tabId); }
|
|
321
331
|
}
|
|
322
332
|
|
|
323
|
-
// --- .env support ---
|
|
324
|
-
// Minimal dotenv parser. Skips comments/blank lines, splits on first `=`,
|
|
325
|
-
// trims whitespace, strips wrapping single/double quotes. No multiline values,
|
|
326
|
-
// no `${VAR}` substitution within .env itself — we just want plain KEY=VALUE.
|
|
327
|
-
function parseDotenv(text) {
|
|
328
|
-
const out = {};
|
|
329
|
-
for (const rawLine of text.split(/\r?\n/)) {
|
|
330
|
-
const line = rawLine.trim();
|
|
331
|
-
if (!line || line.startsWith('#')) continue;
|
|
332
|
-
const eq = line.indexOf('=');
|
|
333
|
-
if (eq === -1) continue;
|
|
334
|
-
const key = line.slice(0, eq).trim();
|
|
335
|
-
if (!key) continue;
|
|
336
|
-
let value = line.slice(eq + 1).trim();
|
|
337
|
-
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
338
|
-
(value.startsWith("'") && value.endsWith("'"))) {
|
|
339
|
-
value = value.slice(1, -1);
|
|
340
|
-
}
|
|
341
|
-
out[key] = value;
|
|
342
|
-
}
|
|
343
|
-
return out;
|
|
344
|
-
}
|
|
345
|
-
|
|
346
333
|
// --- File watcher ---
|
|
347
334
|
const IGNORE = /node_modules|\.git/;
|
|
348
335
|
try {
|
|
@@ -351,10 +338,7 @@ try {
|
|
|
351
338
|
clearTimeout(debounce);
|
|
352
339
|
debounce = setTimeout(() => {
|
|
353
340
|
const ext = extname(filename).toLowerCase();
|
|
354
|
-
|
|
355
|
-
if (base === '.env') {
|
|
356
|
-
broadcast({ type: 'reload' });
|
|
357
|
-
} else if (ext === '.css') {
|
|
341
|
+
if (ext === '.css') {
|
|
358
342
|
broadcast({ type: 'css', file: '/' + filename.replace(/\\/g, '/') });
|
|
359
343
|
} else if (['.csv', '.json', '.yaml', '.yml', '.md'].includes(ext)) {
|
|
360
344
|
broadcast({ type: 'data' });
|
|
@@ -372,6 +356,18 @@ function isFile(p) {
|
|
|
372
356
|
try { return statSync(p).isFile(); } catch { return false; }
|
|
373
357
|
}
|
|
374
358
|
|
|
359
|
+
// Resolve a request path against `root` and refuse anything that escapes.
|
|
360
|
+
// `path.join` does NOT prevent `..` traversal — `join('/a/b', '/../../etc/passwd')`
|
|
361
|
+
// returns `/etc/passwd`. Use `path.resolve` + an explicit prefix check.
|
|
362
|
+
// Returns the absolute path on success, or null if the request would escape root
|
|
363
|
+
// (or contains a NUL byte).
|
|
364
|
+
function safeResolve(urlPath) {
|
|
365
|
+
if (urlPath.includes('\0')) return null;
|
|
366
|
+
const candidate = resolve(root, '.' + urlPath);
|
|
367
|
+
if (candidate !== root && !candidate.startsWith(root + sep)) return null;
|
|
368
|
+
return candidate;
|
|
369
|
+
}
|
|
370
|
+
|
|
375
371
|
function serveFile(res, filePath) {
|
|
376
372
|
const ext = extname(filePath).toLowerCase();
|
|
377
373
|
const mime = MIME[ext] || 'application/octet-stream';
|
|
@@ -391,10 +387,49 @@ function serveFile(res, filePath) {
|
|
|
391
387
|
res.end(body);
|
|
392
388
|
}
|
|
393
389
|
|
|
390
|
+
// DNS-rebinding defence. Even though the server binds 127.0.0.1, a malicious
|
|
391
|
+
// public page (attacker.com) can perform DNS rebinding: initial resolution
|
|
392
|
+
// returns the attacker's IP so the dev visits it, then DNS is flipped to
|
|
393
|
+
// 127.0.0.1 so subsequent fetches reach mnfst-run while the browser still
|
|
394
|
+
// treats the page origin as attacker.com (giving attacker JS read access).
|
|
395
|
+
// The browser sends `Host: attacker.com` after the rebind — so reject any
|
|
396
|
+
// request whose Host header isn't a known-local form on our listening port.
|
|
397
|
+
function isLocalHostHeader(host, port) {
|
|
398
|
+
if (!host || typeof host !== 'string') return false;
|
|
399
|
+
const allowed = new Set([
|
|
400
|
+
`127.0.0.1:${port}`,
|
|
401
|
+
`localhost:${port}`,
|
|
402
|
+
`[::1]:${port}`,
|
|
403
|
+
]);
|
|
404
|
+
return allowed.has(host.toLowerCase());
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Stricter check used by state-changing endpoints (close beacon). Same
|
|
408
|
+
// allowlist applied to the Origin header.
|
|
409
|
+
function isLocalOrigin(origin, port) {
|
|
410
|
+
if (!origin || typeof origin !== 'string') return false;
|
|
411
|
+
const allowed = new Set([
|
|
412
|
+
`http://127.0.0.1:${port}`,
|
|
413
|
+
`http://localhost:${port}`,
|
|
414
|
+
`http://[::1]:${port}`,
|
|
415
|
+
]);
|
|
416
|
+
return allowed.has(origin.toLowerCase());
|
|
417
|
+
}
|
|
418
|
+
|
|
394
419
|
// --- HTTP server ---
|
|
395
420
|
const server = createServer((req, res) => {
|
|
396
421
|
const urlPath = decodeURIComponent(req.url.split('?')[0]);
|
|
397
422
|
|
|
423
|
+
// Reject any request whose Host header doesn't match our listening origin —
|
|
424
|
+
// closes DNS-rebinding even though we're bound to loopback. server.address()
|
|
425
|
+
// is the source of truth for the actual port (auto-port may have shifted).
|
|
426
|
+
const listenPort = server.address()?.port;
|
|
427
|
+
if (listenPort && !isLocalHostHeader(req.headers.host, listenPort)) {
|
|
428
|
+
res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
429
|
+
res.end('403 Forbidden — invalid Host header');
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
|
|
398
433
|
// Identity endpoint: lets `mnfst-run` (and `--list`) confirm that a server
|
|
399
434
|
// on a registered port really is OUR server for the expected root, not
|
|
400
435
|
// some unrelated process that happened to inherit a recycled PID/port.
|
|
@@ -407,27 +442,6 @@ const server = createServer((req, res) => {
|
|
|
407
442
|
return;
|
|
408
443
|
}
|
|
409
444
|
|
|
410
|
-
// Virtual /env.js — generated from .env at the project root.
|
|
411
|
-
// Loaded by HTML before manifest.data.js so window.env is populated for
|
|
412
|
-
// ${VAR} interpolation in manifest.json. Returns an empty no-op if no
|
|
413
|
-
// .env exists, so the <script src="/env.js"> tag is always safe to include.
|
|
414
|
-
if (urlPath === '/env.js') {
|
|
415
|
-
const envPath = join(root, '.env');
|
|
416
|
-
let body = 'window.env = window.env || {};';
|
|
417
|
-
try {
|
|
418
|
-
if (isFile(envPath)) {
|
|
419
|
-
const env = parseDotenv(readFileSync(envPath, 'utf8'));
|
|
420
|
-
body = `window.env = Object.assign(window.env || {}, ${JSON.stringify(env)});`;
|
|
421
|
-
}
|
|
422
|
-
} catch { /* fall through to no-op */ }
|
|
423
|
-
res.writeHead(200, {
|
|
424
|
-
'Content-Type': 'application/javascript; charset=utf-8',
|
|
425
|
-
'Cache-Control': 'no-store',
|
|
426
|
-
});
|
|
427
|
-
res.end(body);
|
|
428
|
-
return;
|
|
429
|
-
}
|
|
430
|
-
|
|
431
445
|
// SSE endpoint for live reload
|
|
432
446
|
if (urlPath === '/__mnfst_sse__') {
|
|
433
447
|
const tabId = new URL(req.url, 'http://localhost').searchParams.get('tabId');
|
|
@@ -466,7 +480,21 @@ const server = createServer((req, res) => {
|
|
|
466
480
|
// Close beacon: fired by the injected script's pagehide handler when a tab
|
|
467
481
|
// is actually being closed/navigated away from. This is the only signal
|
|
468
482
|
// that drops a tab from openTabs in normal operation.
|
|
483
|
+
// Locked to POST + same-origin: the live-reload script already POSTs (both
|
|
484
|
+
// sendBeacon and the fetch fallback), so no DX cost; this closes the
|
|
485
|
+
// CSRF-via-GET surface where a third-party page could fire <img src=...>
|
|
486
|
+
// to nuke tabs (would still need an unguessable tabId, but defence in depth).
|
|
469
487
|
if (urlPath === '/__mnfst_close__') {
|
|
488
|
+
if (req.method !== 'POST') {
|
|
489
|
+
res.writeHead(405, { 'Allow': 'POST' });
|
|
490
|
+
res.end();
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
if (listenPort && !isLocalOrigin(req.headers.origin, listenPort)) {
|
|
494
|
+
res.writeHead(403);
|
|
495
|
+
res.end();
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
470
498
|
const tabId = new URL(req.url, 'http://localhost').searchParams.get('tabId');
|
|
471
499
|
dropTab(tabId);
|
|
472
500
|
res.writeHead(204);
|
|
@@ -475,11 +503,11 @@ const server = createServer((req, res) => {
|
|
|
475
503
|
return;
|
|
476
504
|
}
|
|
477
505
|
|
|
478
|
-
const exact =
|
|
479
|
-
if (isFile(exact)) return serveFile(res, exact);
|
|
506
|
+
const exact = safeResolve(urlPath);
|
|
507
|
+
if (exact && isFile(exact)) return serveFile(res, exact);
|
|
480
508
|
|
|
481
|
-
const
|
|
482
|
-
if (isFile(
|
|
509
|
+
const indexPath = safeResolve(urlPath.replace(/\/$/, '') + '/index.html');
|
|
510
|
+
if (indexPath && isFile(indexPath)) return serveFile(res, indexPath);
|
|
483
511
|
|
|
484
512
|
if (spa) {
|
|
485
513
|
const fallback = join(root, 'index.html');
|
|
@@ -525,7 +553,10 @@ function tryListen(p, attempt = 0) {
|
|
|
525
553
|
};
|
|
526
554
|
server.once('listening', onListening);
|
|
527
555
|
server.once('error', onError);
|
|
528
|
-
|
|
556
|
+
// Bind to loopback only. Without an explicit host Node listens on `::` (all
|
|
557
|
+
// interfaces), which exposes the dev server — and every file under `root` —
|
|
558
|
+
// to anyone sharing the network (café, hotel, conference, coworking).
|
|
559
|
+
server.listen(p, '127.0.0.1');
|
|
529
560
|
}
|
|
530
561
|
|
|
531
562
|
// Clean up the registry entry on graceful exit. process.exit() (used by
|