@chatpanel/bridge 0.10.19 → 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 +1 -1
- package/src/engines/claude.js +15 -0
- package/src/server.js +99 -2
- package/src/ssrf.js +53 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/bridge",
|
|
3
|
-
"version": "0.10.
|
|
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/engines/claude.js
CHANGED
|
@@ -185,6 +185,21 @@ export function handleMessage(msg, emit, alreadyStreamed) {
|
|
|
185
185
|
} else if (msg.type === 'result') {
|
|
186
186
|
if (msg.subtype === 'success') out.result = msg.result || '';
|
|
187
187
|
else emit({ type: 'status', text: `(${msg.subtype})` });
|
|
188
|
+
// Forward token usage so the client can account for CLI-agent turns. The
|
|
189
|
+
// result message carries cumulative usage (+ a real subscription cost).
|
|
190
|
+
// Additive event — older clients ignore an unknown SSE type.
|
|
191
|
+
const u = msg.usage || msg.message?.usage;
|
|
192
|
+
if (u) {
|
|
193
|
+
emit({
|
|
194
|
+
type: 'usage',
|
|
195
|
+
provider: 'bridge',
|
|
196
|
+
inputTokens: u.input_tokens || 0,
|
|
197
|
+
outputTokens: u.output_tokens || 0,
|
|
198
|
+
cacheReadTokens: u.cache_read_input_tokens || 0,
|
|
199
|
+
cacheWriteTokens: u.cache_creation_input_tokens || 0,
|
|
200
|
+
costUsd: msg.total_cost_usd != null ? msg.total_cost_usd : null,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
188
203
|
}
|
|
189
204
|
return out;
|
|
190
205
|
}
|
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.
|
|
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(/&/g, '&')
|
|
574
|
+
.replace(/</g, '<')
|
|
575
|
+
.replace(/>/g, '>')
|
|
576
|
+
.replace(/"/g, '"')
|
|
577
|
+
.replace(/&(?:apos|#0*39|#x0*27);/gi, "'")
|
|
578
|
+
.replace(/ /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
|
+
}
|