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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/serve.mjs +86 -55
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mnfst-run",
3
- "version": "1.0.10",
3
+ "version": "1.0.13",
4
4
  "description": "Zero-dependency dev server for Manifest projects",
5
5
  "type": "module",
6
6
  "bin": {
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
- let idleShutdownEnabled = true;
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
- const base = basename(filename);
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 = join(root, urlPath);
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 index = join(root, urlPath.replace(/\/$/, ''), 'index.html');
482
- if (isFile(index)) return serveFile(res, index);
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
- server.listen(p);
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