@chatpanel/bridge 0.10.21 → 0.10.23

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/src/server.js CHANGED
@@ -26,10 +26,11 @@ import { join } from 'node:path';
26
26
  import * as claude from './engines/claude.js';
27
27
  import * as codex from './engines/codex.js';
28
28
  import * as antigravity from './engines/antigravity.js';
29
- import { pi, opencode, kiro } from './engines/cli-agents.js';
29
+ import { pi, opencode, kiro, copilot, deepseek } from './engines/cli-agents.js';
30
30
  import * as custom from './engines/custom.js';
31
31
  import { installService, uninstallService, serviceStatus, restartService } from './service.js';
32
32
  import { AGENT_CLIS, enrichPath, findAgentBin, resolveCommand } from './env.js';
33
+ import { stripHidden } from './sanitize.js';
33
34
  import { checkForUpdate, selfUpdate } from './update.js';
34
35
  import { callLocalMcp } from './mcp-local.js';
35
36
  import { assertPublicHttpUrl, assertPublicWebUrl } from './ssrf.js';
@@ -37,7 +38,7 @@ import { assertPublicHttpUrl, assertPublicWebUrl } from './ssrf.js';
37
38
  // Hardcoded (not read from package.json) so it survives Bun's single-file
38
39
  // --compile, where package.json isn't on a readable FS. CI fails the publish if
39
40
  // this drifts from package.json, so the two can't silently diverge.
40
- const VERSION = '0.10.21';
41
+ const VERSION = '0.10.23';
41
42
  const HOST = process.env.CHATPANEL_BRIDGE_HOST || '127.0.0.1';
42
43
  const PORT = Number(process.env.CHATPANEL_BRIDGE_PORT) || 4319;
43
44
 
@@ -48,6 +49,8 @@ const ENGINES = {
48
49
  pi: { engine: pi, label: 'Pi' },
49
50
  opencode: { engine: opencode, label: 'OpenCode' },
50
51
  kiro: { engine: kiro, label: 'Kiro' },
52
+ copilot: { engine: copilot, label: 'GitHub Copilot' },
53
+ deepseek: { engine: deepseek, label: 'DeepSeek Harness' },
51
54
  // "Bring your own" — one engine drives any user-onboarded CLI (Pro). Hidden
52
55
  // from /health (it's not a single installable agent; the extension manages the
53
56
  // list and validates commands via /agent-check).
@@ -108,10 +111,13 @@ function relayToolCall(session, name, input) {
108
111
 
109
112
  // The extension returns a string OR { text, image(dataURL) }; map to MCP content.
110
113
  function toMcpContent(result) {
114
+ // L5: de-steganographize tool-result TEXT before it flows back to the CLI/model —
115
+ // the bridge is a public localhost endpoint, so (like the prompt path) it must strip
116
+ // ASCII-smuggled / bidi Unicode from relayed results, not assume the caller did.
111
117
  if (result == null) return { content: [{ type: 'text', text: 'ok' }] };
112
- if (typeof result === 'string') return { content: [{ type: 'text', text: result }] };
118
+ if (typeof result === 'string') return { content: [{ type: 'text', text: stripHidden(result) }] };
113
119
  const content = [];
114
- if (result.text) content.push({ type: 'text', text: String(result.text) });
120
+ if (result.text) content.push({ type: 'text', text: stripHidden(String(result.text)) });
115
121
  if (typeof result.image === 'string') {
116
122
  const m = /^data:([^;]+);base64,(.+)$/s.exec(result.image);
117
123
  if (m) content.push({ type: 'image', data: m[2], mimeType: m[1] });
@@ -330,9 +336,13 @@ async function handleChat(req, res) {
330
336
  if (!res.writableEnded) res.write(`data: ${JSON.stringify(obj)}\n\n`);
331
337
  };
332
338
 
333
- // If the client disconnects, stop caring about late writes.
339
+ // If the client disconnects (Stop, or the panel closes), stop caring about late
340
+ // writes AND abort the run so the engine kills its CLI child instead of letting it
341
+ // finish in the background. Older engines ignore the signal (harmless); the spawn
342
+ // engines honor it via killOnAbort.
334
343
  let closed = false;
335
- req.on('close', () => (closed = true));
344
+ const ac = new AbortController();
345
+ req.on('close', () => { closed = true; ac.abort(); });
336
346
 
337
347
  const safeEmit = (obj) => { if (!closed) emit(obj); };
338
348
 
@@ -358,6 +368,7 @@ async function handleChat(req, res) {
358
368
  images: Array.isArray(body.images) ? body.images : [],
359
369
  },
360
370
  safeEmit,
371
+ { signal: ac.signal },
361
372
  );
362
373
  } catch (e) {
363
374
  log('error', `${body.agent} chat failed: ${e?.message || e}`);
@@ -718,11 +729,16 @@ const server = createServer(async (req, res) => {
718
729
  try {
719
730
  if (req.method === 'GET' && url.pathname === '/health') return handleHealth(res);
720
731
  if (req.method === 'GET' && url.pathname === '/debug') {
732
+ // L6: by default expose only version + agent AVAILABILITY (a boolean) — enough
733
+ // to diagnose "is codex installed?". The full home dir, $PATH, and resolved
734
+ // binary paths (which embed the username / home) leak environment detail, so
735
+ // they're opt-in behind CHATPANEL_BRIDGE_DEBUG=1. The extension doesn't read
736
+ // this route, so trimming it by default breaks nothing.
737
+ const verbose = /^(1|true|yes|on)$/i.test(process.env.CHATPANEL_BRIDGE_DEBUG || '');
721
738
  return json(res, 200, {
722
739
  version: VERSION,
723
- home: os.homedir(),
724
- agents: Object.fromEntries(AGENT_CLIS.map((name) => [name, findAgentBin(name) || null])),
725
- path: process.env.PATH,
740
+ agents: Object.fromEntries(AGENT_CLIS.map((name) => [name, verbose ? (findAgentBin(name) || null) : !!findAgentBin(name)])),
741
+ ...(verbose ? { home: os.homedir(), path: process.env.PATH } : {}),
726
742
  });
727
743
  }
728
744
  if (req.method === 'POST' && url.pathname === '/chat') return handleChat(req, res);
@@ -826,6 +842,12 @@ function startServer() {
826
842
  });
827
843
  server.listen(PORT, HOST, async () => {
828
844
  log('info', `listening on http://${HOST}:${PORT}`);
845
+ // M7: a non-loopback bind disables the anti-DNS-rebinding Host check (hostAllowed
846
+ // returns true for any Host), so the only inbound guard left is the per-install
847
+ // token / extension Origin. Make that trade-off LOUD — it's rarely what you want.
848
+ if (!LOOPBACK_HOSTNAMES.has(HOST)) {
849
+ log('error', `⚠ SECURITY: bound to NON-LOOPBACK host ${HOST}. The anti-rebinding Host check is OFF, so any device that reaches this port (and any web page via a spoofed Host header) can drive local agents — gated only by the bridge token. Only do this on a trusted, firewalled network; prefer 127.0.0.1.`);
850
+ }
829
851
  for (const [, { engine, label, hidden }] of Object.entries(ENGINES)) {
830
852
  if (hidden) continue;
831
853
  const a = await engine.available().catch(() => ({ ok: false }));
package/src/ssrf.js CHANGED
@@ -1,88 +1,35 @@
1
- // SSRF guard for the /mcp-remote proxy.
1
+ // SSRF guard for the bridge's outbound proxies (/mcp-remote, /fetch-title).
2
2
  //
3
- // The bridge proxies ONE JSON-RPC message to a remote MCP server *from this
4
- // machine* (no browser Origin header), so the extension can reach servers that
5
- // reject browser origins. That route is already privileged it requires the
6
- // extension origin or the per-install bridge token, so a random web page cannot
7
- // drive it. This guard is the second layer: even when driven by the extension,
8
- // the bridge must not become a relay that a prompt-injected agent could point at
9
- // cloud metadata or use to sweep the LAN.
3
+ // The host CLASSIFICATION (what is loopback / cloud-metadata / RFC1918 / CGNAT /
4
+ // ULA / link-local / .local) now lives in ONE shared place — src/net.js, a vendored
5
+ // copy of @chatpanel/pii/net.js, the same classifier the gateway and extension use.
6
+ // This file keeps only the bridge's two POLICIES + their exact error messages, so a
7
+ // security guard can't drift between the direct client path and the proxied path.
8
+ // See docs/secure-data-plane.md.
10
9
  //
11
- // Policy:
12
- // • Loopback (127.0.0.0/8, ::1, localhost, *.localhost) ALLOWED.
13
- // It's the user's own host — the same place the bridge runs, and a place the
14
- // extension can already fetch DIRECTLY. Proxying it grants no new reach; it
15
- // only drops the browser Origin header (the whole point of "via bridge").
16
- // Localhost MCP servers are the common case.
17
- // Cloud instance metadata (169.254.169.254) ALWAYS BLOCKED.
18
- // This is the sharpest SSRF target (credential theft) and is blocked even
19
- // when private hosts are opted in.
20
- // • Everything else private/internal (RFC1918, CGNAT, link-local, IPv6 ULA,
21
- // 0.0.0.0, ::, *.local) → BLOCKED, unless the operator opts in with
22
- // CHATPANEL_BRIDGE_ALLOW_PRIVATE_HOSTS=1 (for reaching an MCP server on
23
- // another machine on a trusted LAN).
24
- // • Non-http(s) schemes → BLOCKED.
10
+ // Two policies:
11
+ // • /mcp-remote (assertPublicHttpUrl): loopback ALLOWED (the user's own MCP
12
+ // servers — the whole point of "via bridge"), cloud metadata ALWAYS blocked,
13
+ // every other private/LAN range blocked UNLESS the operator opts in with
14
+ // CHATPANEL_BRIDGE_ALLOW_PRIVATE_HOSTS=1 (reaching an MCP server on a trusted LAN).
15
+ // /fetch-title (assertPublicWebUrl): STRICTER a page fetch has no business
16
+ // touching loopback OR any private host, so loopback + metadata + every private
17
+ // range are blocked unconditionally (the opt-in is deliberately NOT honored).
25
18
  //
26
- // The same checks run on the initial URL AND after any redirect.
19
+ // Non-http(s) schemes are blocked in both. Run the assert on the initial URL AND
20
+ // after every redirect hop.
21
+
22
+ import { isLoopbackHost, isBlockedHost } from './net.js';
23
+
24
+ export { isLoopbackHost };
27
25
 
28
26
  const ALLOW_PRIVATE_HOSTS = /^(1|true|yes|on)$/i.test(
29
27
  process.env.CHATPANEL_BRIDGE_ALLOW_PRIVATE_HOSTS || '',
30
28
  );
31
29
 
32
- function ipv4(h) {
33
- const m = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
34
- if (!m) return null;
35
- const o = m.slice(1).map(Number);
36
- if (o.some((n) => n > 255)) return null;
37
- return o;
38
- }
39
-
40
- // Loopback = this host's own services. Reachable by the extension directly, so
41
- // allowing the bridge to reach it adds no capability.
42
- export function isLoopbackHost(hostname) {
43
- const h = String(hostname || '').toLowerCase().replace(/^\[|\]$/g, '');
44
- if (!h) return false;
45
- if (h === 'localhost' || h.endsWith('.localhost')) return true;
46
- if (h === '::1') return true;
47
- const o = ipv4(h);
48
- return !!(o && o[0] === 127);
49
- }
50
-
51
- // Cloud instance metadata — credential-theft vector. Always blocked.
52
- function isMetadataHost(hostname) {
53
- const o = ipv4(hostname);
54
- return !!(o && o[0] === 169 && o[1] === 254);
55
- }
56
-
30
+ // MCP-proxy policy: loopback ok, metadata never, other private only when opted in.
57
31
  export function isBlockedHttpHost(hostname) {
58
- const h = String(hostname || '').toLowerCase().replace(/^\[|\]$/g, '');
59
- if (!h) return true;
60
- if (isLoopbackHost(h)) return false; // user's own host — allowed
61
- if (isMetadataHost(h)) return true; // never proxy cloud metadata, even when private is opted in
62
- if (ALLOW_PRIVATE_HOSTS) return false; // operator opted in to LAN/private targets
63
-
64
- // Default deny for the rest of the private/internal space.
65
- if (h.endsWith('.local')) return true; // mDNS / LAN
66
- if (
67
- h === '::' ||
68
- h.startsWith('fc') ||
69
- h.startsWith('fd') || // IPv6 ULA
70
- h.startsWith('fe8') ||
71
- h.startsWith('fe9') ||
72
- h.startsWith('fea') ||
73
- h.startsWith('feb') // IPv6 link-local
74
- ) {
75
- return true;
76
- }
77
- const o = ipv4(h);
78
- if (o) {
79
- const [a, b] = o;
80
- if (a === 0 || a === 10) return true; // this-host / RFC1918
81
- if (a === 172 && b >= 16 && b <= 31) return true; // RFC1918
82
- if (a === 192 && b === 168) return true; // RFC1918
83
- if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT
84
- }
85
- return false;
32
+ return isBlockedHost(hostname, { allowLoopback: true, allowPrivate: ALLOW_PRIVATE_HOSTS });
86
33
  }
87
34
 
88
35
  export function assertPublicHttpUrl(u) {
@@ -101,41 +48,9 @@ export function assertPublicHttpUrl(u) {
101
48
  return parsed;
102
49
  }
103
50
 
104
- // STRICTER guard for fetching arbitrary WEB pages (the /fetch-title route). Unlike the MCP proxy,
105
- // a page fetch has NO legitimate reason to reach the user's own loopback services or any LAN /
106
- // private host — those would be pure SSRF (port-scan the LAN, hit a localhost admin panel, read
107
- // cloud metadata). So loopback + metadata + every private/internal range are blocked
108
- // UNCONDITIONALLY here; the CHATPANEL_BRIDGE_ALLOW_PRIVATE_HOSTS opt-in (meant for reaching a LAN
109
- // MCP server, a different trust context) is deliberately NOT honored. Only genuinely public
110
- // http(s) hosts pass. Re-run this on every redirect hop, not just the initial URL.
51
+ // Web-fetch policy (stricter): block loopback + metadata + all private, always.
111
52
  export function isDisallowedWebHost(hostname) {
112
- const h = String(hostname || '').toLowerCase().replace(/^\[|\]$/g, '');
113
- if (!h) return true;
114
- if (isLoopbackHost(h)) return true; // localhost/127.x — a page fetch must never touch it
115
- if (isMetadataHost(h)) return true; // 169.254.169.254 — credential theft
116
- if (h.endsWith('.local')) return true; // mDNS / LAN
117
- if (
118
- h === '::' ||
119
- h.startsWith('fc') ||
120
- h.startsWith('fd') || // IPv6 ULA
121
- h.startsWith('fe8') ||
122
- h.startsWith('fe9') ||
123
- h.startsWith('fea') ||
124
- h.startsWith('feb') // IPv6 link-local
125
- ) {
126
- return true;
127
- }
128
- const o = ipv4(h);
129
- if (o) {
130
- const [a, b] = o;
131
- if (a === 0 || a === 10) return true; // this-host / RFC1918
132
- if (a === 127) return true; // loopback (also caught above; explicit for clarity)
133
- if (a === 169 && b === 254) return true; // link-local (incl. metadata)
134
- if (a === 172 && b >= 16 && b <= 31) return true; // RFC1918
135
- if (a === 192 && b === 168) return true; // RFC1918
136
- if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT
137
- }
138
- return false;
53
+ return isBlockedHost(hostname, { allowLoopback: false, allowPrivate: false });
139
54
  }
140
55
 
141
56
  export function assertPublicWebUrl(u) {