@chatpanel/bridge 0.10.20 → 0.10.21

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.20",
3
+ "version": "0.10.21",
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 Antigravity CLI (formerly Gemini CLI, which remains available for business/enterprise) — to the ChatPanel Chrome extension over a localhost SSE endpoint. Bring your own agent.",
6
6
  "keywords": [
package/src/server.js CHANGED
@@ -32,12 +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
+ import { assertPublicHttpUrl, assertPublicWebUrl } from './ssrf.js';
36
36
 
37
37
  // Hardcoded (not read from package.json) so it survives Bun's single-file
38
38
  // --compile, where package.json isn't on a readable FS. CI fails the publish if
39
39
  // this drifts from package.json, so the two can't silently diverge.
40
- const VERSION = '0.10.20';
40
+ const VERSION = '0.10.21';
41
41
  const HOST = process.env.CHATPANEL_BRIDGE_HOST || '127.0.0.1';
42
42
  const PORT = Number(process.env.CHATPANEL_BRIDGE_PORT) || 4319;
43
43
 
@@ -226,6 +226,7 @@ const PRIVILEGED_POST = new Set([
226
226
  '/chat',
227
227
  '/mcp-local',
228
228
  '/mcp-remote',
229
+ '/fetch-title',
229
230
  '/complete',
230
231
  '/list-models',
231
232
  '/agent-check',
@@ -503,6 +504,101 @@ async function handleMcpRemote(req, res) {
503
504
  }
504
505
  }
505
506
 
507
+ // POST /fetch-title — fetch a PUBLIC web page and return ONLY its <title>, so a client can turn
508
+ // a bare URL into a readable [Title](url) link without the browser's Origin/CORS limits and
509
+ // without any third-party title service. This is the canonical "secure web fetch" the bridge
510
+ // offers on behalf of clients:
511
+ // • Privileged route (extension origin or bridge token only) — a random page can't drive it.
512
+ // • STRICTER SSRF guard than /mcp-remote (assertPublicWebUrl): loopback / LAN / metadata are
513
+ // blocked unconditionally — a page fetch has no business touching internal hosts.
514
+ // • Redirects are followed MANUALLY, re-validating EVERY hop, so it can never be bounced onto
515
+ // an internal address (stronger than an after-the-fact final-URL check).
516
+ // • Response is read with a hard byte cap and stops at </title> — we only need the <head>.
517
+ // • No cookies / auth / referer are ever sent; only the title string is returned.
518
+ const FT_MAX_BYTES = 256 * 1024;
519
+ const FT_MAX_REDIRECTS = 5;
520
+ const FT_TIMEOUT_MS = 8000;
521
+
522
+ async function fetchTitleSafely(rawUrl) {
523
+ let current = assertPublicWebUrl(rawUrl); // throws on non-http(s) / private / loopback / metadata
524
+ const ac = new AbortController();
525
+ const timer = setTimeout(() => ac.abort(), FT_TIMEOUT_MS);
526
+ try {
527
+ for (let hop = 0; hop <= FT_MAX_REDIRECTS; hop++) {
528
+ const res = await fetch(current.href, {
529
+ method: 'GET',
530
+ redirect: 'manual', // follow hops ourselves so each target is re-validated before we call it
531
+ signal: ac.signal,
532
+ headers: { Accept: 'text/html,application/xhtml+xml', 'Accept-Language': 'en', 'User-Agent': `chatpanel-bridge/${VERSION} (+link-title)` },
533
+ });
534
+ if (res.status >= 300 && res.status < 400 && res.headers.get('location')) {
535
+ current = assertPublicWebUrl(new URL(res.headers.get('location'), current).href); // re-guard the redirect
536
+ if (res.body) { try { await res.body.cancel(); } catch { /* ignore */ } }
537
+ continue;
538
+ }
539
+ if (!res.ok) { if (res.body) { try { await res.body.cancel(); } catch { /* ignore */ } } return { title: null }; }
540
+ const ct = res.headers.get('content-type') || '';
541
+ if (!/text\/html|application\/xhtml/i.test(ct)) { if (res.body) { try { await res.body.cancel(); } catch { /* ignore */ } } return { title: null }; }
542
+ return { title: extractTitle(await readCapped(res)), url: current.href };
543
+ }
544
+ throw new Error('too many redirects');
545
+ } finally {
546
+ clearTimeout(timer);
547
+ }
548
+ }
549
+
550
+ // Read the response body up to FT_MAX_BYTES, stopping as soon as we've seen the closing </title>.
551
+ async function readCapped(res) {
552
+ if (!res.body?.getReader) return (await res.text().catch(() => '')).slice(0, FT_MAX_BYTES);
553
+ const reader = res.body.getReader();
554
+ const decoder = new TextDecoder('utf-8', { fatal: false });
555
+ let out = '';
556
+ let total = 0;
557
+ try {
558
+ for (;;) {
559
+ const { done, value } = await reader.read();
560
+ if (done) break;
561
+ total += value.byteLength;
562
+ out += decoder.decode(value, { stream: true });
563
+ if (/<\/title\s*>/i.test(out) || total >= FT_MAX_BYTES) break;
564
+ }
565
+ } finally {
566
+ try { await reader.cancel(); } catch { /* ignore */ }
567
+ }
568
+ return out;
569
+ }
570
+
571
+ function decodeEntities(s) {
572
+ return String(s)
573
+ .replace(/&amp;/g, '&')
574
+ .replace(/&lt;/g, '<')
575
+ .replace(/&gt;/g, '>')
576
+ .replace(/&quot;/g, '"')
577
+ .replace(/&(?:apos|#0*39|#x0*27);/gi, "'")
578
+ .replace(/&nbsp;/g, ' ')
579
+ .replace(/&#(\d+);/g, (_, d) => { try { return String.fromCodePoint(+d); } catch { return ''; } })
580
+ .replace(/&#x([0-9a-f]+);/gi, (_, h) => { try { return String.fromCodePoint(parseInt(h, 16)); } catch { return ''; } });
581
+ }
582
+
583
+ function extractTitle(html) {
584
+ const m = /<title[^>]*>([\s\S]*?)<\/title\s*>/i.exec(html);
585
+ return m ? decodeEntities(m[1]).replace(/\s+/g, ' ').trim().slice(0, 200) : '';
586
+ }
587
+
588
+ async function handleFetchTitle(req, res) {
589
+ let body;
590
+ try { body = await readBody(req); } catch (e) { return json(res, 400, { error: 'Bad JSON: ' + e.message }); }
591
+ const url = body && body.url;
592
+ if (!url || typeof url !== 'string') return json(res, 400, { error: 'need url' });
593
+ try {
594
+ const out = await fetchTitleSafely(url);
595
+ return json(res, 200, { title: out.title || null, url: out.url || url });
596
+ } catch (e) {
597
+ const why = e?.name === 'AbortError' ? 'timed out' : String(e?.message || e);
598
+ return json(res, 400, { error: `couldn't fetch title: ${why}` });
599
+ }
600
+ }
601
+
506
602
  // POST /tool-result — the extension returns a relayed tool's result.
507
603
  async function handleToolResult(req, res) {
508
604
  let body;
@@ -645,6 +741,7 @@ const server = createServer(async (req, res) => {
645
741
  if (req.method === 'POST' && url.pathname === '/tool-result') return handleToolResult(req, res);
646
742
  if (req.method === 'POST' && url.pathname === '/mcp-local') return handleMcpLocal(req, res);
647
743
  if (req.method === 'POST' && url.pathname === '/mcp-remote') return handleMcpRemote(req, res);
744
+ if (req.method === 'POST' && url.pathname === '/fetch-title') return handleFetchTitle(req, res);
648
745
  if (req.method === 'POST' && url.pathname === '/complete') return handleComplete(req, res);
649
746
  if (req.method === 'POST' && url.pathname === '/list-models') return handleListModels(req, res);
650
747
  if (req.method === 'POST' && url.pathname === '/agent-check') return handleAgentCheck(req, res);
package/src/ssrf.js CHANGED
@@ -100,3 +100,56 @@ export function assertPublicHttpUrl(u) {
100
100
  }
101
101
  return parsed;
102
102
  }
103
+
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.
111
+ 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;
139
+ }
140
+
141
+ export function assertPublicWebUrl(u) {
142
+ let parsed;
143
+ try {
144
+ parsed = new URL(u);
145
+ } catch {
146
+ throw new Error(`invalid URL: ${u}`);
147
+ }
148
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
149
+ throw new Error(`only http(s) URLs allowed (got "${parsed.protocol}")`);
150
+ }
151
+ if (isDisallowedWebHost(parsed.hostname)) {
152
+ throw new Error(`refusing to fetch a private/loopback/metadata address (${parsed.hostname})`);
153
+ }
154
+ return parsed;
155
+ }