@chatpanel/bridge 0.10.13 → 0.10.14

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chatpanel/bridge",
3
- "version": "0.10.13",
3
+ "version": "0.10.14",
4
4
  "type": "module",
5
5
  "description": "Local bridge that exposes the AI coding agents installed on your machine — Claude Code (CLI), Codex (CLI), and Gemini CLI — to the ChatPanel Chrome extension over a localhost SSE endpoint. Bring your own agent.",
6
6
  "keywords": [
package/src/server.js CHANGED
@@ -32,11 +32,12 @@ import { installService, uninstallService, serviceStatus, restartService } from
32
32
  import { AGENT_CLIS, enrichPath, findAgentBin, resolveCommand } from './env.js';
33
33
  import { checkForUpdate, selfUpdate } from './update.js';
34
34
  import { callLocalMcp } from './mcp-local.js';
35
+ import { assertPublicHttpUrl } from './ssrf.js';
35
36
 
36
37
  // Hardcoded (not read from package.json) so it survives Bun's single-file
37
38
  // --compile, where package.json isn't on a readable FS. CI fails the publish if
38
39
  // this drifts from package.json, so the two can't silently diverge.
39
- const VERSION = '0.10.13';
40
+ const VERSION = '0.10.14';
40
41
  const HOST = process.env.CHATPANEL_BRIDGE_HOST || '127.0.0.1';
41
42
  const PORT = Number(process.env.CHATPANEL_BRIDGE_PORT) || 4319;
42
43
 
@@ -233,31 +234,12 @@ const PRIVILEGED_POST = new Set([
233
234
  ]);
234
235
  const PRIVILEGED_GET = new Set(['/debug']);
235
236
 
236
- // SSRF guard for /mcp-remote: the bridge must not become an open relay into the
237
- // local network. Block non-http(s) schemes and private/loopback/link-local/
238
- // metadata hosts on the initial URL AND after any redirect.
239
- function isBlockedHttpHost(hostname) {
240
- const h = String(hostname || '').toLowerCase().replace(/^\[|\]$/g, '');
241
- if (!h || h === 'localhost' || h.endsWith('.localhost') || h.endsWith('.local')) return true;
242
- if (h === '::1' || h === '::' || h.startsWith('fc') || h.startsWith('fd') || h.startsWith('fe8') || h.startsWith('fe9') || h.startsWith('fea') || h.startsWith('feb')) return true;
243
- const m = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
244
- if (m) {
245
- const a = Number(m[1]), b = Number(m[2]);
246
- if (a === 0 || a === 127 || a === 10) return true; // this-host / loopback / RFC1918
247
- if (a === 169 && b === 254) return true; // link-local + cloud metadata
248
- if (a === 172 && b >= 16 && b <= 31) return true; // RFC1918
249
- if (a === 192 && b === 168) return true; // RFC1918
250
- if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT
251
- }
252
- return false;
253
- }
254
- function assertPublicHttpUrl(u) {
255
- let parsed;
256
- try { parsed = new URL(u); } catch { throw new Error(`invalid URL: ${u}`); }
257
- if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') throw new Error(`only http(s) URLs allowed (got "${parsed.protocol}")`);
258
- if (isBlockedHttpHost(parsed.hostname)) throw new Error(`refusing to proxy a private/loopback/metadata address (${parsed.hostname})`);
259
- return parsed;
260
- }
237
+ // SSRF guard for /mcp-remote lives in ./ssrf.js (assertPublicHttpUrl). Loopback
238
+ // is allowed (the user's own localhost MCP server — the common "via bridge"
239
+ // case; the extension can reach it directly anyway), cloud metadata is always
240
+ // blocked, and other private/LAN ranges are blocked unless the operator sets
241
+ // CHATPANEL_BRIDGE_ALLOW_PRIVATE_HOSTS=1. Checked on the initial URL AND after
242
+ // any redirect.
261
243
 
262
244
  // Returns an error code if the request must be blocked, else null.
263
245
  function guard(req, pathname) {
package/src/ssrf.js ADDED
@@ -0,0 +1,102 @@
1
+ // SSRF guard for the /mcp-remote proxy.
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.
10
+ //
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.
25
+ //
26
+ // The same checks run on the initial URL AND after any redirect.
27
+
28
+ const ALLOW_PRIVATE_HOSTS = /^(1|true|yes|on)$/i.test(
29
+ process.env.CHATPANEL_BRIDGE_ALLOW_PRIVATE_HOSTS || '',
30
+ );
31
+
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
+
57
+ 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;
86
+ }
87
+
88
+ export function assertPublicHttpUrl(u) {
89
+ let parsed;
90
+ try {
91
+ parsed = new URL(u);
92
+ } catch {
93
+ throw new Error(`invalid URL: ${u}`);
94
+ }
95
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
96
+ throw new Error(`only http(s) URLs allowed (got "${parsed.protocol}")`);
97
+ }
98
+ if (isBlockedHttpHost(parsed.hostname)) {
99
+ throw new Error(`refusing to proxy a private/metadata address (${parsed.hostname})`);
100
+ }
101
+ return parsed;
102
+ }