@chatpanel/bridge 0.10.21 → 0.10.22
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/antigravity.js +10 -2
- package/src/engines/args.js +42 -0
- package/src/engines/claude.js +32 -21
- package/src/engines/cli-agents.js +4 -6
- package/src/engines/codex.js +10 -2
- package/src/engines/custom.js +23 -16
- package/src/entitlement.js +8 -12
- package/src/net.js +107 -0
- package/src/proc.js +21 -0
- package/src/server.js +28 -8
- package/src/ssrf.js +25 -110
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/bridge",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.22",
|
|
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": [
|
|
@@ -16,6 +16,8 @@ import os from 'node:os';
|
|
|
16
16
|
import path from 'node:path';
|
|
17
17
|
import { findAgentBin } from '../env.js';
|
|
18
18
|
import { buildCliPrompt } from './prompt.js';
|
|
19
|
+
import { killOnAbort } from '../proc.js';
|
|
20
|
+
import { pushExtraArgs, FORBIDDEN } from './args.js';
|
|
19
21
|
|
|
20
22
|
const IDLE_MS = Number(process.env.CHATPANEL_AGY_TIMEOUT_MS) || 180_000;
|
|
21
23
|
const SCRATCH = path.join(os.tmpdir(), 'chatpanel-agy-scratch');
|
|
@@ -78,7 +80,7 @@ function writeImages(images, dir) {
|
|
|
78
80
|
return files;
|
|
79
81
|
}
|
|
80
82
|
|
|
81
|
-
export async function chat({ messages, system, options, images }, emit) {
|
|
83
|
+
export async function chat({ messages, system, options, images }, emit, { signal } = {}) {
|
|
82
84
|
try {
|
|
83
85
|
mkdirSync(SCRATCH, { recursive: true });
|
|
84
86
|
} catch {
|
|
@@ -102,7 +104,8 @@ export async function chat({ messages, system, options, images }, emit) {
|
|
|
102
104
|
const args = ['-p', prompt];
|
|
103
105
|
if (options.model) args.push('--model', options.model);
|
|
104
106
|
if (options.permissionMode === 'bypassPermissions') args.push('--dangerously-skip-permissions');
|
|
105
|
-
|
|
107
|
+
// Drop caller extras that would auto-approve tools (shared sanitizer).
|
|
108
|
+
pushExtraArgs(args, options.extraArgs, FORBIDDEN.antigravity, emit);
|
|
106
109
|
|
|
107
110
|
await new Promise((resolve, reject) => {
|
|
108
111
|
let child;
|
|
@@ -113,6 +116,8 @@ export async function chat({ messages, system, options, images }, emit) {
|
|
|
113
116
|
return reject(new Error(`Failed to start agy: ${e.message}`));
|
|
114
117
|
}
|
|
115
118
|
|
|
119
|
+
const detach = killOnAbort(child, signal); // Stop → terminate the agy child
|
|
120
|
+
|
|
116
121
|
let out = '';
|
|
117
122
|
let err = '';
|
|
118
123
|
let streamed = false;
|
|
@@ -137,12 +142,15 @@ export async function chat({ messages, system, options, images }, emit) {
|
|
|
137
142
|
child.stderr.on('data', (d) => { armIdle(); err += d.toString(); });
|
|
138
143
|
child.on('error', (e) => {
|
|
139
144
|
clearTimeout(idleTimer);
|
|
145
|
+
detach();
|
|
140
146
|
cleanup();
|
|
141
147
|
reject(new Error(`Failed to start agy: ${e.message}`));
|
|
142
148
|
});
|
|
143
149
|
child.on('close', (code) => {
|
|
144
150
|
clearTimeout(idleTimer);
|
|
151
|
+
detach();
|
|
145
152
|
cleanup();
|
|
153
|
+
if (signal?.aborted) { resolve(); return; } // Stop pressed — end quietly
|
|
146
154
|
if (code === 0) {
|
|
147
155
|
if (!streamed) emit({ type: 'delta', text: out.trim() || '(no output)' });
|
|
148
156
|
emit({ type: 'done', text: '' });
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Caller-supplied "extra CLI arguments" sanitizer (shared by every engine).
|
|
2
|
+
//
|
|
3
|
+
// The extension/model can pass free-form extraArgs to the agent CLI. A prompt-
|
|
4
|
+
// injected model must NOT be able to smuggle a flag that re-opens the sandbox /
|
|
5
|
+
// permission boundary the engine deliberately sets (e.g. Codex's
|
|
6
|
+
// --dangerously-bypass-approvals-and-sandbox, Claude's --permission-mode). Since
|
|
7
|
+
// these flags take values, partial filtering is unsafe — if ANY forbidden token is
|
|
8
|
+
// present we drop the WHOLE extraArgs. Previously only claude.js did this; codex,
|
|
9
|
+
// antigravity and custom pushed extraArgs unfiltered (this closes that gap).
|
|
10
|
+
|
|
11
|
+
export function splitArgs(raw) {
|
|
12
|
+
return String(raw || '').split(/\s+/).filter(Boolean);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Per-engine escalation flags. Long flags use \b so `--flag=value` is caught too;
|
|
16
|
+
// short flags are exact-matched so we don't over-block a benign token.
|
|
17
|
+
export const FORBIDDEN = {
|
|
18
|
+
claude: /^--?(permission-mode|allowed-?tools|disallowed-?tools|dangerously|add-dir|mcp-config|setting-sources|permission-prompt-tool)/i,
|
|
19
|
+
// Codex: sandbox / approval escalation + `-c key=val` (can set approval_policy or
|
|
20
|
+
// sandbox_mode in TOML) + `-C/--cd` (retarget the working dir).
|
|
21
|
+
codex: /^(-s|-a|-c|-C)$|^--(dangerously[\w-]*|sandbox|ask-for-approval|full-auto|yolo|config|cd)\b/i,
|
|
22
|
+
antigravity: /^--(dangerously[\w-]*|skip-permissions|trust-all-?tools|yolo|full-auto)\b/i,
|
|
23
|
+
// Custom runs an arbitrary CLI, so only clearly-dangerous LONG flags are blocked
|
|
24
|
+
// (no short-flag guesses that might collide with a benign tool option).
|
|
25
|
+
custom: /^--(dangerously[\w-]*|skip-permissions|trust-all-?tools|no-sandbox|bypass|yolo|full-auto|permission-mode|allowed-?tools|disallowed-?tools|mcp-config)\b/i,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// Returns { args, blocked }. `blocked:true` => the whole extraArgs was dropped.
|
|
29
|
+
export function sanitizeExtraArgs(raw, forbidden) {
|
|
30
|
+
const tokens = splitArgs(raw);
|
|
31
|
+
if (tokens.some((t) => forbidden.test(t))) return { args: [], blocked: true };
|
|
32
|
+
return { args: tokens, blocked: false };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Convenience for the engines: sanitize, push the safe tokens onto `args`, and emit
|
|
36
|
+
// a status when something was dropped.
|
|
37
|
+
export function pushExtraArgs(args, raw, forbidden, emit) {
|
|
38
|
+
if (!raw) return;
|
|
39
|
+
const { args: extra, blocked } = sanitizeExtraArgs(raw, forbidden);
|
|
40
|
+
if (blocked) { try { emit?.({ type: 'status', text: '(ignored unsafe extraArgs)' }); } catch { /* ignore */ } return; }
|
|
41
|
+
args.push(...extra);
|
|
42
|
+
}
|
package/src/engines/claude.js
CHANGED
|
@@ -19,6 +19,8 @@ import os from 'node:os';
|
|
|
19
19
|
import path from 'node:path';
|
|
20
20
|
import { resolveClaude, buildSpawnSpec, isCompiledBinary, selfMcpStdio } from '../env.js';
|
|
21
21
|
import { buildCliPrompt } from './prompt.js';
|
|
22
|
+
import { killOnAbort } from '../proc.js';
|
|
23
|
+
import { pushExtraArgs, FORBIDDEN } from './args.js';
|
|
22
24
|
|
|
23
25
|
// Write base64 data-URL images to temp files. Claude Code reads them with its
|
|
24
26
|
// Read tool (which feeds images to the model as vision), so we just reference the
|
|
@@ -95,7 +97,7 @@ export function claudeMcpConfig(mcp) {
|
|
|
95
97
|
// Spawn claude (however it resolves) and stream its stream-json output via
|
|
96
98
|
// `emit`. Resolves with { streamedAny, resultText } once it closes 0. Returns
|
|
97
99
|
// null (no spawn) when claude can't be resolved, so the caller can fall back.
|
|
98
|
-
function runClaude({ prompt, args, cwd, emit }) {
|
|
100
|
+
function runClaude({ prompt, args, cwd, emit, signal }) {
|
|
99
101
|
const spec = resolveClaude();
|
|
100
102
|
if (!spec) return null;
|
|
101
103
|
const [bin, argv, opts] = buildSpawnSpec(spec, args, cwd);
|
|
@@ -108,6 +110,8 @@ function runClaude({ prompt, args, cwd, emit }) {
|
|
|
108
110
|
return reject(new Error(`Failed to start claude: ${e.message}`));
|
|
109
111
|
}
|
|
110
112
|
|
|
113
|
+
const detach = killOnAbort(child, signal); // Stop → terminate the claude child
|
|
114
|
+
|
|
111
115
|
let stdout = '';
|
|
112
116
|
let stderr = '';
|
|
113
117
|
let streamedAny = false;
|
|
@@ -145,10 +149,13 @@ function runClaude({ prompt, args, cwd, emit }) {
|
|
|
145
149
|
child.stderr.on('data', (d) => { armIdle(); stderr += d.toString(); });
|
|
146
150
|
child.on('error', (e) => {
|
|
147
151
|
clearTimeout(idleTimer);
|
|
152
|
+
detach();
|
|
148
153
|
reject(new Error(`Failed to start claude (${bin}): ${e.message}`));
|
|
149
154
|
});
|
|
150
155
|
child.on('close', (code) => {
|
|
151
156
|
clearTimeout(idleTimer);
|
|
157
|
+
detach();
|
|
158
|
+
if (signal?.aborted) { resolve({ streamedAny, resultText }); return; } // Stop pressed — end quietly
|
|
152
159
|
if (code === 0) resolve({ streamedAny, resultText });
|
|
153
160
|
else reject(new Error(`Claude Code exited ${code}: ${stderr.trim().split('\n').pop() || 'failed'}`));
|
|
154
161
|
});
|
|
@@ -204,7 +211,7 @@ export function handleMessage(msg, emit, alreadyStreamed) {
|
|
|
204
211
|
return out;
|
|
205
212
|
}
|
|
206
213
|
|
|
207
|
-
export async function chat({ messages, system, options, images }, emit) {
|
|
214
|
+
export async function chat({ messages, system, options, images }, emit, { signal } = {}) {
|
|
208
215
|
const permissionMode = options.permissionMode || 'default';
|
|
209
216
|
// Explicit project dir, else null → CLI runs in home (or WSL home).
|
|
210
217
|
const cwd = options.workingDir ? path.resolve(options.workingDir) : null;
|
|
@@ -259,22 +266,13 @@ export async function chat({ messages, system, options, images }, emit) {
|
|
|
259
266
|
}: ${imageFiles.join(', ')}`;
|
|
260
267
|
}
|
|
261
268
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
// whole extraArgs (these tokens take values, so partial filtering is unsafe).
|
|
267
|
-
const FORBIDDEN = /^--?(permission-mode|allowed-?tools|disallowed-?tools|dangerously|add-dir|mcp-config|setting-sources|permission-prompt-tool)/i;
|
|
268
|
-
if (extra.some((t) => FORBIDDEN.test(t))) {
|
|
269
|
-
emit({ type: 'status', text: '(ignored unsafe extraArgs)' });
|
|
270
|
-
} else {
|
|
271
|
-
args.push(...extra);
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
const run = runClaude({ prompt, args, cwd, emit });
|
|
269
|
+
// Never let caller-supplied extras re-open the read-only boundary the mode flags
|
|
270
|
+
// above establish (shared sanitizer — see args.js).
|
|
271
|
+
pushExtraArgs(args, options.extraArgs, FORBIDDEN.claude, emit);
|
|
272
|
+
const run = runClaude({ prompt, args, cwd, emit, signal });
|
|
275
273
|
if (run === null) {
|
|
276
274
|
cleanup(); // SDK fallback doesn't take images yet
|
|
277
|
-
return sdkChat({ messages, system, options }, emit);
|
|
275
|
+
return sdkChat({ messages, system, options }, emit, { signal });
|
|
278
276
|
}
|
|
279
277
|
try {
|
|
280
278
|
const { streamedAny, resultText } = await run;
|
|
@@ -335,11 +333,18 @@ function loadSdk() {
|
|
|
335
333
|
return sdkPromise;
|
|
336
334
|
}
|
|
337
335
|
|
|
338
|
-
async function sdkChat({ messages, system, options }, emit) {
|
|
336
|
+
async function sdkChat({ messages, system, options }, emit, { signal } = {}) {
|
|
339
337
|
const sdk = await loadSdk();
|
|
340
338
|
if (!sdk) throw new Error(lastReason);
|
|
341
339
|
const { query } = sdk;
|
|
342
340
|
|
|
341
|
+
// The SDK cancels the run when this controller aborts — forward the request signal.
|
|
342
|
+
const abortController = new AbortController();
|
|
343
|
+
if (signal) {
|
|
344
|
+
if (signal.aborted) abortController.abort();
|
|
345
|
+
else signal.addEventListener('abort', () => abortController.abort(), { once: true });
|
|
346
|
+
}
|
|
347
|
+
|
|
343
348
|
const permissionMode = options.permissionMode || 'default';
|
|
344
349
|
const cwd = options.workingDir ? path.resolve(options.workingDir) : os.homedir();
|
|
345
350
|
const writesAllowed = permissionMode === 'acceptEdits' || permissionMode === 'bypassPermissions';
|
|
@@ -358,6 +363,7 @@ async function sdkChat({ messages, system, options }, emit) {
|
|
|
358
363
|
permissionMode,
|
|
359
364
|
includePartialMessages: true,
|
|
360
365
|
canUseTool,
|
|
366
|
+
abortController,
|
|
361
367
|
settingSources: options.useLocalConfig === false ? [] : ['user', 'project'],
|
|
362
368
|
systemPrompt: system
|
|
363
369
|
? { type: 'preset', preset: 'claude_code', append: system }
|
|
@@ -366,10 +372,15 @@ async function sdkChat({ messages, system, options }, emit) {
|
|
|
366
372
|
...(process.env.CHATPANEL_MAX_TURNS ? { maxTurns: Number(process.env.CHATPANEL_MAX_TURNS) } : {}),
|
|
367
373
|
},
|
|
368
374
|
});
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
375
|
+
try {
|
|
376
|
+
for await (const message of iterator) {
|
|
377
|
+
const r = handleMessage(message, emit, streamedAny);
|
|
378
|
+
if (r.streamed) streamedAny = true;
|
|
379
|
+
if (r.result != null) resultText = r.result;
|
|
380
|
+
}
|
|
381
|
+
} catch (e) {
|
|
382
|
+
if (signal?.aborted || abortController.signal.aborted) return; // Stop pressed — end quietly
|
|
383
|
+
throw e;
|
|
373
384
|
}
|
|
374
385
|
emit({ type: 'done', text: streamedAny ? '' : resultText });
|
|
375
386
|
}
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
// Built-in CLI agents — pi, opencode, kiro — that reuse the shared custom-engine
|
|
2
|
-
// runner (runSpec) with a
|
|
3
|
-
//
|
|
4
|
-
// free users to a single usable agent (FREE_LIMITS.bridgeAgents). Bring-your-own
|
|
5
|
-
// arbitrary CLIs stay Pro via custom.js.
|
|
2
|
+
// runner (runSpec) with a fixed spec each. These are fixed built-in engines and are
|
|
3
|
+
// not entitlement-gated; custom BYO CLIs are handled by custom.js.
|
|
6
4
|
//
|
|
7
5
|
// Specs come from each CLI's actual flags:
|
|
8
6
|
// pi — pi -p "<prompt>" · --model · @{path} images · --list-models
|
|
@@ -34,8 +32,8 @@ function makeCliAgent(command, spec, notFoundHint) {
|
|
|
34
32
|
listModels(options = {}) {
|
|
35
33
|
return listSpecModels(command, spec.listModelsArgs, options.workingDir);
|
|
36
34
|
},
|
|
37
|
-
chat(input, emit) {
|
|
38
|
-
return runSpec(resolvedSpec, input, emit);
|
|
35
|
+
chat(input, emit, opts) {
|
|
36
|
+
return runSpec(resolvedSpec, input, emit, opts);
|
|
39
37
|
},
|
|
40
38
|
};
|
|
41
39
|
}
|
package/src/engines/codex.js
CHANGED
|
@@ -15,12 +15,14 @@
|
|
|
15
15
|
// the agent to point it at a real project.
|
|
16
16
|
|
|
17
17
|
import { spawn, spawnSync } from 'node:child_process';
|
|
18
|
+
import { killOnAbort } from '../proc.js';
|
|
18
19
|
import { readFile, unlink, writeFile } from 'node:fs/promises';
|
|
19
20
|
import { existsSync, mkdirSync, symlinkSync, readFileSync } from 'node:fs';
|
|
20
21
|
import os from 'node:os';
|
|
21
22
|
import path from 'node:path';
|
|
22
23
|
import { findAgentBin, selfMcpStdio } from '../env.js';
|
|
23
24
|
import { buildCliPrompt } from './prompt.js';
|
|
25
|
+
import { pushExtraArgs, FORBIDDEN } from './args.js';
|
|
24
26
|
|
|
25
27
|
// Idle timeout: re-armed on every stdout/stderr chunk, so a long run that keeps
|
|
26
28
|
// streaming never trips it — only true silence does. Override with
|
|
@@ -136,7 +138,7 @@ async function writeImages(images, tag) {
|
|
|
136
138
|
return files;
|
|
137
139
|
}
|
|
138
140
|
|
|
139
|
-
export async function chat({ messages, system, options, images }, emit) {
|
|
141
|
+
export async function chat({ messages, system, options, images }, emit, { signal } = {}) {
|
|
140
142
|
ensureScratch();
|
|
141
143
|
const tag = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
142
144
|
const outFile = path.join(os.tmpdir(), `chatpanel-codex-${tag}.txt`);
|
|
@@ -162,7 +164,8 @@ export async function chat({ messages, system, options, images }, emit) {
|
|
|
162
164
|
// `-c key=value` parses value as TOML; JSON.stringify yields valid TOML here.
|
|
163
165
|
args.push(...codexMcpConfigArgs(options.mcp));
|
|
164
166
|
if (options.model) args.push('-m', options.model);
|
|
165
|
-
|
|
167
|
+
// Drop caller extras that would re-open the sandbox/approval boundary (shared sanitizer).
|
|
168
|
+
pushExtraArgs(args, options.extraArgs, FORBIDDEN.codex, emit);
|
|
166
169
|
for (const f of imageFiles) args.push('-i', f); // attach images to the initial prompt
|
|
167
170
|
args.push('-');
|
|
168
171
|
|
|
@@ -183,6 +186,8 @@ export async function chat({ messages, system, options, images }, emit) {
|
|
|
183
186
|
return reject(new Error(`Failed to start codex: ${e.message}`));
|
|
184
187
|
}
|
|
185
188
|
|
|
189
|
+
const detach = killOnAbort(child, signal); // Stop → SIGTERM/SIGKILL the codex child
|
|
190
|
+
|
|
186
191
|
let stdout = '';
|
|
187
192
|
let stderr = '';
|
|
188
193
|
let idleTimer;
|
|
@@ -213,11 +218,13 @@ export async function chat({ messages, system, options, images }, emit) {
|
|
|
213
218
|
child.stderr.on('data', (d) => { armIdle(); stderr += d.toString(); });
|
|
214
219
|
child.on('error', (e) => {
|
|
215
220
|
clearTimeout(idleTimer);
|
|
221
|
+
detach();
|
|
216
222
|
cleanupImages();
|
|
217
223
|
reject(e);
|
|
218
224
|
});
|
|
219
225
|
child.on('close', async (code) => {
|
|
220
226
|
clearTimeout(idleTimer);
|
|
227
|
+
detach();
|
|
221
228
|
let text = '';
|
|
222
229
|
try {
|
|
223
230
|
text = (await readFile(outFile, 'utf8')).trim();
|
|
@@ -226,6 +233,7 @@ export async function chat({ messages, system, options, images }, emit) {
|
|
|
226
233
|
}
|
|
227
234
|
unlink(outFile).catch(() => {});
|
|
228
235
|
cleanupImages();
|
|
236
|
+
if (signal?.aborted) { resolve(); return; } // Stop pressed — end quietly, no error
|
|
229
237
|
if (code === 0) {
|
|
230
238
|
emit({ type: 'delta', text: text || '(no output)' });
|
|
231
239
|
emit({ type: 'done', text: '' });
|
package/src/engines/custom.js
CHANGED
|
@@ -5,9 +5,7 @@
|
|
|
5
5
|
// Windows cli.js+.cmd / WSL — same launcher as Claude), pipes the prompt in, and
|
|
6
6
|
// streams output back.
|
|
7
7
|
//
|
|
8
|
-
//
|
|
9
|
-
// server-signed entitlement token (verified OFFLINE here — no network). A forked
|
|
10
|
-
// client or a raw POST can't forge it, so this is real gating, not UI.
|
|
8
|
+
// Custom agents require a valid entitlement token, verified offline here (no network).
|
|
11
9
|
//
|
|
12
10
|
// Output formats:
|
|
13
11
|
// 'text' (default) — stream stdout straight through as text deltas. Works for
|
|
@@ -21,8 +19,10 @@ import os from 'node:os';
|
|
|
21
19
|
import path from 'node:path';
|
|
22
20
|
import { resolveCommand, buildSpawnSpec, selfMcpStdio } from '../env.js';
|
|
23
21
|
import { isProEntitled } from '../entitlement.js';
|
|
22
|
+
import { killOnAbort } from '../proc.js';
|
|
24
23
|
import { handleMessage } from './claude.js';
|
|
25
24
|
import { buildCliPrompt } from './prompt.js';
|
|
25
|
+
import { pushExtraArgs, FORBIDDEN } from './args.js';
|
|
26
26
|
|
|
27
27
|
// Write base64 data-URL images to temp files so a custom CLI can take them via
|
|
28
28
|
// its configured `imageArg` template (e.g. "-i {path}", "@{path}"). Returns paths.
|
|
@@ -95,7 +95,7 @@ function parseModelList(stdout) {
|
|
|
95
95
|
|
|
96
96
|
// Unified model listing: run the agent's CONFIGURED list-models invocation
|
|
97
97
|
// (e.g. pi `--list-models`, opencode `models`) and parse the output. Returns []
|
|
98
|
-
// when not configured. Pro
|
|
98
|
+
// when not configured. Requires Pro (runs the user's CLI).
|
|
99
99
|
export async function listModels(options = {}) {
|
|
100
100
|
if (!(await isProEntitled(options.entitlement))) {
|
|
101
101
|
throw new Error('Custom agents require ChatPanel Pro.');
|
|
@@ -104,8 +104,8 @@ export async function listModels(options = {}) {
|
|
|
104
104
|
return listSpecModels(spec.command, spec.listModelsArgs, options.workingDir);
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
// Shared model listing
|
|
108
|
-
//
|
|
107
|
+
// Shared model listing: run a CLI's list-models invocation and parse it. Used by
|
|
108
|
+
// the custom engine and the built-in CLI agents.
|
|
109
109
|
export async function listSpecModels(command, listModelsArgs, workingDir) {
|
|
110
110
|
const listArgs = String(listModelsArgs || '').trim();
|
|
111
111
|
if (!command || !listArgs) return [];
|
|
@@ -360,18 +360,16 @@ export async function ensureStableMcpConfig(spec, cwd, label, emit, deps = {}) {
|
|
|
360
360
|
throw new Error(`${label} browser-tool setup completed, but the MCP server is still not visible. Run: ${setupCommand}`);
|
|
361
361
|
}
|
|
362
362
|
|
|
363
|
-
export async function chat({ messages, system, options, images }, emit) {
|
|
364
|
-
//
|
|
363
|
+
export async function chat({ messages, system, options, images }, emit, { signal } = {}) {
|
|
364
|
+
// Require a valid signed entitlement before running.
|
|
365
365
|
if (!(await isProEntitled(options.entitlement))) {
|
|
366
366
|
throw new Error('Custom agents require ChatPanel Pro. Upgrade in Settings to bring your own CLI agent.');
|
|
367
367
|
}
|
|
368
|
-
return runSpec(options.custom || {}, { messages, system, options, images }, emit);
|
|
368
|
+
return runSpec(options.custom || {}, { messages, system, options, images }, emit, { signal });
|
|
369
369
|
}
|
|
370
370
|
|
|
371
|
-
// Run a CLI agent from a spec —
|
|
372
|
-
|
|
373
|
-
// built-in agents are bounded instead by the extension's free 1-agent limit.
|
|
374
|
-
export async function runSpec(spec, { messages, system, options = {}, images }, emit) {
|
|
371
|
+
// Run a CLI agent from a spec — shared by the custom engine and the built-in CLI engines.
|
|
372
|
+
export async function runSpec(spec, { messages, system, options = {}, images }, emit, { signal } = {}) {
|
|
375
373
|
if (!spec.command) throw new Error('This agent has no command configured.');
|
|
376
374
|
|
|
377
375
|
const resolved = resolveCommand(spec.command);
|
|
@@ -394,9 +392,13 @@ export async function runSpec(spec, { messages, system, options = {}, images },
|
|
|
394
392
|
? String(spec.args).split(/\s+/).filter(Boolean)
|
|
395
393
|
: [];
|
|
396
394
|
// User-supplied extra CLI flags (Settings → agent → "Extra arguments"), placed
|
|
397
|
-
// right after the base args/subcommand
|
|
398
|
-
// --dangerously
|
|
399
|
-
|
|
395
|
+
// right after the base args/subcommand. Sanitized (shared helper): escalation flags
|
|
396
|
+
// (--dangerously*, --skip-permissions, --trust-all-tools, --no-sandbox …) are dropped
|
|
397
|
+
// from the EXTRA args so an injected value can't unlock tools. The built-in agents'
|
|
398
|
+
// intentional autonomy flags live in their BASE spec (cli-agents.js), not here, so
|
|
399
|
+
// they're unaffected; a custom CLI that genuinely needs such a flag should carry it
|
|
400
|
+
// in its configured command/args, not the extra-args field.
|
|
401
|
+
pushExtraArgs(args, options.extraArgs, FORBIDDEN.custom, emit);
|
|
400
402
|
// Inject the selected model via the agent's CONFIGURED model-arg template
|
|
401
403
|
// (e.g. "--model {model}" or, for opencode, "-m {model}" with provider/model).
|
|
402
404
|
// Without a template we can't know how this CLI takes a model, so options.model
|
|
@@ -486,6 +488,8 @@ export async function runSpec(spec, { messages, system, options = {}, images },
|
|
|
486
488
|
return reject(new Error(`Failed to start ${label}: ${e.message}`));
|
|
487
489
|
}
|
|
488
490
|
|
|
491
|
+
const detach = killOnAbort(child, signal); // Stop → terminate the CLI child
|
|
492
|
+
|
|
489
493
|
let stderr = '';
|
|
490
494
|
let streamedAny = false;
|
|
491
495
|
let resultText = '';
|
|
@@ -552,12 +556,15 @@ export async function runSpec(spec, { messages, system, options = {}, images },
|
|
|
552
556
|
child.stderr.on('data', (d) => { armIdle(); stderr += d.toString(); });
|
|
553
557
|
child.on('error', (e) => {
|
|
554
558
|
clearTimeout(idleTimer);
|
|
559
|
+
detach();
|
|
555
560
|
cleanup();
|
|
556
561
|
reject(new Error(`Failed to start ${label}: ${e.message}`));
|
|
557
562
|
});
|
|
558
563
|
child.on('close', (code) => {
|
|
559
564
|
clearTimeout(idleTimer);
|
|
565
|
+
detach();
|
|
560
566
|
cleanup();
|
|
567
|
+
if (signal?.aborted) { resolve(); return; } // Stop pressed — end quietly
|
|
561
568
|
if (code === 0) {
|
|
562
569
|
emit({ type: 'done', text: streamedAny ? '' : resultText });
|
|
563
570
|
resolve();
|
package/src/entitlement.js
CHANGED
|
@@ -1,11 +1,7 @@
|
|
|
1
|
-
// Offline
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// an ECDSA P-256 private key that lives ONLY there. The bridge ships the matching
|
|
6
|
-
// PUBLIC key and verifies the signature locally — no network, no secret. A forked
|
|
7
|
-
// client or a raw `curl` to the bridge can't forge entitlement without the
|
|
8
|
-
// private key, so this is a real cryptographic gate, not a UI check.
|
|
1
|
+
// Offline entitlement verification. The license server signs a compact entitlement
|
|
2
|
+
// token with an ECDSA P-256 private key; the bridge ships the matching public key
|
|
3
|
+
// and verifies the token's signature locally (no network). Gates Pro features such
|
|
4
|
+
// as "bring your own" custom CLI agents.
|
|
9
5
|
//
|
|
10
6
|
// Token format (identical to the extension's, extension/js/license.js):
|
|
11
7
|
// token = base64url(JSON payload) + "." + base64url(raw ECDSA signature)
|
|
@@ -44,9 +40,8 @@ function publicKey() {
|
|
|
44
40
|
}
|
|
45
41
|
|
|
46
42
|
// Verify a server entitlement token. Returns its payload, or null. Checks the
|
|
47
|
-
// ECDSA signature
|
|
48
|
-
//
|
|
49
|
-
// signature is what matters.
|
|
43
|
+
// ECDSA signature, the token type, and expiry. install_id binding is handled by
|
|
44
|
+
// the extension; the bridge checks the signature.
|
|
50
45
|
export async function verifyEntitlement(token) {
|
|
51
46
|
if (!token || typeof token !== 'string' || token.indexOf('.') < 0) return null;
|
|
52
47
|
const [head, sig] = token.split('.');
|
|
@@ -70,7 +65,8 @@ export async function verifyEntitlement(token) {
|
|
|
70
65
|
return null;
|
|
71
66
|
}
|
|
72
67
|
if (payload.typ !== 'ent') return null;
|
|
73
|
-
|
|
68
|
+
// exp is required; the worker always mints a finite exp, so requiring one is non-breaking.
|
|
69
|
+
if (typeof payload.exp !== 'number' || !Number.isFinite(payload.exp) || Date.now() > payload.exp) return null;
|
|
74
70
|
return payload;
|
|
75
71
|
}
|
|
76
72
|
|
package/src/net.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// VENDORED COPY of chatpanel-pii/net.js — keep in sync with the canonical engine
|
|
2
|
+
// (the bridge stays dependency-free, so this is a copy, not an import). Regenerate
|
|
3
|
+
// by copying ../chatpanel-pii/net.js over this file (then re-add this header).
|
|
4
|
+
//
|
|
5
|
+
// Shared host classifier + outbound-URL guard — the SSRF primitive.
|
|
6
|
+
//
|
|
7
|
+
// One implementation of "what is a loopback / cloud-metadata / private host",
|
|
8
|
+
// delivered the way the rest of @chatpanel/pii is: npm dependency for the
|
|
9
|
+
// gateway/bridge, vendorable into the browser extension (pure — only URL + string
|
|
10
|
+
// ops, no node APIs, so it runs in a Worker/service-worker too). Replaces the
|
|
11
|
+
// hand-maintained copies in the bridge (src/ssrf.js) and the extension
|
|
12
|
+
// (js/context.js isBlockedHost) so a security guard can't silently drift between
|
|
13
|
+
// the direct client path and the proxied path. See docs/secure-data-plane.md.
|
|
14
|
+
//
|
|
15
|
+
// The policy knobs cover the two legitimate trust contexts:
|
|
16
|
+
// • A MODEL / API / MCP endpoint (gateway upstream, bridge MCP proxy) may live on
|
|
17
|
+
// loopback (Ollama, LM Studio) or the LAN (a homelab GPU box) — so those are
|
|
18
|
+
// allowed by default — but must NEVER reach cloud instance metadata.
|
|
19
|
+
// • A WEB PAGE fetch (link title, page context) has no business touching loopback
|
|
20
|
+
// or any private host at all — call with { allowLoopback:false, allowPrivate:false }.
|
|
21
|
+
// Cloud metadata (169.254.169.254 & friends) and non-http(s) schemes are blocked in
|
|
22
|
+
// BOTH contexts, unconditionally. Re-run the assert on every redirect hop.
|
|
23
|
+
|
|
24
|
+
function ipv4(h) {
|
|
25
|
+
const m = String(h).match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
26
|
+
if (!m) return null;
|
|
27
|
+
const o = m.slice(1).map(Number);
|
|
28
|
+
if (o.some((n) => n > 255)) return null;
|
|
29
|
+
return o;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const norm = (hostname) => String(hostname || '').toLowerCase().replace(/^\[|\]$/g, '');
|
|
33
|
+
|
|
34
|
+
// Loopback = this host's own services (127.0.0.0/8, ::1, localhost, *.localhost).
|
|
35
|
+
export function isLoopbackHost(hostname) {
|
|
36
|
+
const h = norm(hostname);
|
|
37
|
+
if (!h) return false;
|
|
38
|
+
if (h === 'localhost' || h.endsWith('.localhost')) return true;
|
|
39
|
+
if (h === '::1') return true;
|
|
40
|
+
const o = ipv4(h);
|
|
41
|
+
return !!(o && o[0] === 127);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Cloud instance metadata — the sharpest SSRF target (credential theft). Covers the
|
|
45
|
+
// link-local IMDS address used by AWS/GCP/Azure/DO (169.254.169.254), Alibaba's
|
|
46
|
+
// 100.100.100.200, and the GCP/name-based metadata hosts. ALWAYS blocked.
|
|
47
|
+
export function isMetadataHost(hostname) {
|
|
48
|
+
const h = norm(hostname);
|
|
49
|
+
if (h === 'metadata.google.internal' || h === 'metadata') return true;
|
|
50
|
+
const o = ipv4(h);
|
|
51
|
+
if (!o) return false;
|
|
52
|
+
if (o[0] === 169 && o[1] === 254) return true; // 169.254.169.254 (+ link-local)
|
|
53
|
+
if (o[0] === 100 && o[1] === 100 && o[2] === 100 && o[3] === 200) return true; // Alibaba IMDS
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Private / internal address space, EXCLUDING loopback + metadata (checked
|
|
58
|
+
// separately): RFC1918, CGNAT, IPv6 ULA/link-local, mDNS .local, this-host 0.x/::.
|
|
59
|
+
export function isPrivateHost(hostname) {
|
|
60
|
+
const h = norm(hostname);
|
|
61
|
+
if (!h) return true;
|
|
62
|
+
if (h.endsWith('.local')) return true;
|
|
63
|
+
if (
|
|
64
|
+
h === '::' || h.startsWith('fc') || h.startsWith('fd') // IPv6 ULA
|
|
65
|
+
|| h.startsWith('fe8') || h.startsWith('fe9') || h.startsWith('fea') || h.startsWith('feb') // link-local
|
|
66
|
+
) return true;
|
|
67
|
+
const o = ipv4(h);
|
|
68
|
+
if (o) {
|
|
69
|
+
const [a, b] = o;
|
|
70
|
+
if (a === 0 || a === 10) return true; // this-host / RFC1918
|
|
71
|
+
if (a === 169 && b === 254) return true; // link-local
|
|
72
|
+
if (a === 172 && b >= 16 && b <= 31) return true; // RFC1918
|
|
73
|
+
if (a === 192 && b === 168) return true; // RFC1918
|
|
74
|
+
if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT
|
|
75
|
+
}
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Policy-driven classifier. Returns true if `hostname` must be blocked under `policy`.
|
|
80
|
+
// Defaults model the ENDPOINT context (loopback + LAN allowed, metadata never).
|
|
81
|
+
export function isBlockedHost(hostname, { allowLoopback = true, allowPrivate = true } = {}) {
|
|
82
|
+
const h = norm(hostname);
|
|
83
|
+
if (!h) return true;
|
|
84
|
+
if (isMetadataHost(h)) return true; // never, in any context
|
|
85
|
+
if (isLoopbackHost(h)) return !allowLoopback;
|
|
86
|
+
if (isPrivateHost(h)) return !allowPrivate;
|
|
87
|
+
return false; // public host
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Assert a URL is fetchable under `policy`; returns the parsed URL or throws.
|
|
91
|
+
// Call on the initial URL AND after every redirect hop.
|
|
92
|
+
export function assertFetchableUrl(u, policy = {}) {
|
|
93
|
+
let parsed;
|
|
94
|
+
try { parsed = new URL(u); } catch { throw new Error(`invalid URL: ${u}`); }
|
|
95
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
96
|
+
throw new Error(`only http(s) URLs allowed (got "${parsed.protocol}")`);
|
|
97
|
+
}
|
|
98
|
+
if (isBlockedHost(parsed.hostname, policy)) {
|
|
99
|
+
throw new Error(`refusing to reach a blocked address (${parsed.hostname})`);
|
|
100
|
+
}
|
|
101
|
+
return parsed;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Endpoint context: model/API/MCP upstream — loopback + LAN OK, metadata never.
|
|
105
|
+
export const assertEndpointUrl = (u, opts = {}) => assertFetchableUrl(u, { allowLoopback: true, allowPrivate: true, ...opts });
|
|
106
|
+
// Web-page context: no loopback, no private, no metadata — genuinely public only.
|
|
107
|
+
export const assertPublicWebUrl = (u) => assertFetchableUrl(u, { allowLoopback: false, allowPrivate: false });
|
package/src/proc.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Terminate a spawned CLI child when an AbortSignal fires. The extension's Stop
|
|
2
|
+
// button aborts the /chat request; server.js turns that disconnect into an abort on
|
|
3
|
+
// this signal. Without this, the agent CLI (codex / claude / agy / custom) keeps
|
|
4
|
+
// running to completion in the background after Stop — burning tokens and holding the
|
|
5
|
+
// session — and only the 3-minute idle timer would eventually reap it.
|
|
6
|
+
//
|
|
7
|
+
// SIGTERM first so the CLI can flush + exit cleanly (its own child procs get the
|
|
8
|
+
// signal via the process group where the platform delivers it), then SIGKILL after a
|
|
9
|
+
// short grace if it's still alive. Returns a detach() to drop the listener once the
|
|
10
|
+
// child exits normally.
|
|
11
|
+
export function killOnAbort(child, signal, { graceMs = 1500 } = {}) {
|
|
12
|
+
if (!signal || !child) return () => {};
|
|
13
|
+
const onAbort = () => {
|
|
14
|
+
try { child.kill('SIGTERM'); } catch { /* already exited */ }
|
|
15
|
+
const t = setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* gone */ } }, graceMs);
|
|
16
|
+
if (t.unref) t.unref(); // don't keep the event loop alive just for the grace timer
|
|
17
|
+
};
|
|
18
|
+
if (signal.aborted) { onAbort(); return () => {}; }
|
|
19
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
20
|
+
return () => { try { signal.removeEventListener('abort', onAbort); } catch { /* noop */ } };
|
|
21
|
+
}
|
package/src/server.js
CHANGED
|
@@ -30,6 +30,7 @@ import { pi, opencode, kiro } 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.
|
|
41
|
+
const VERSION = '0.10.22';
|
|
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
|
|
|
@@ -108,10 +109,13 @@ function relayToolCall(session, name, input) {
|
|
|
108
109
|
|
|
109
110
|
// The extension returns a string OR { text, image(dataURL) }; map to MCP content.
|
|
110
111
|
function toMcpContent(result) {
|
|
112
|
+
// L5: de-steganographize tool-result TEXT before it flows back to the CLI/model —
|
|
113
|
+
// the bridge is a public localhost endpoint, so (like the prompt path) it must strip
|
|
114
|
+
// ASCII-smuggled / bidi Unicode from relayed results, not assume the caller did.
|
|
111
115
|
if (result == null) return { content: [{ type: 'text', text: 'ok' }] };
|
|
112
|
-
if (typeof result === 'string') return { content: [{ type: 'text', text: result }] };
|
|
116
|
+
if (typeof result === 'string') return { content: [{ type: 'text', text: stripHidden(result) }] };
|
|
113
117
|
const content = [];
|
|
114
|
-
if (result.text) content.push({ type: 'text', text: String(result.text) });
|
|
118
|
+
if (result.text) content.push({ type: 'text', text: stripHidden(String(result.text)) });
|
|
115
119
|
if (typeof result.image === 'string') {
|
|
116
120
|
const m = /^data:([^;]+);base64,(.+)$/s.exec(result.image);
|
|
117
121
|
if (m) content.push({ type: 'image', data: m[2], mimeType: m[1] });
|
|
@@ -330,9 +334,13 @@ async function handleChat(req, res) {
|
|
|
330
334
|
if (!res.writableEnded) res.write(`data: ${JSON.stringify(obj)}\n\n`);
|
|
331
335
|
};
|
|
332
336
|
|
|
333
|
-
// If the client disconnects, stop caring about late
|
|
337
|
+
// If the client disconnects (Stop, or the panel closes), stop caring about late
|
|
338
|
+
// writes AND abort the run so the engine kills its CLI child instead of letting it
|
|
339
|
+
// finish in the background. Older engines ignore the signal (harmless); the spawn
|
|
340
|
+
// engines honor it via killOnAbort.
|
|
334
341
|
let closed = false;
|
|
335
|
-
|
|
342
|
+
const ac = new AbortController();
|
|
343
|
+
req.on('close', () => { closed = true; ac.abort(); });
|
|
336
344
|
|
|
337
345
|
const safeEmit = (obj) => { if (!closed) emit(obj); };
|
|
338
346
|
|
|
@@ -358,6 +366,7 @@ async function handleChat(req, res) {
|
|
|
358
366
|
images: Array.isArray(body.images) ? body.images : [],
|
|
359
367
|
},
|
|
360
368
|
safeEmit,
|
|
369
|
+
{ signal: ac.signal },
|
|
361
370
|
);
|
|
362
371
|
} catch (e) {
|
|
363
372
|
log('error', `${body.agent} chat failed: ${e?.message || e}`);
|
|
@@ -718,11 +727,16 @@ const server = createServer(async (req, res) => {
|
|
|
718
727
|
try {
|
|
719
728
|
if (req.method === 'GET' && url.pathname === '/health') return handleHealth(res);
|
|
720
729
|
if (req.method === 'GET' && url.pathname === '/debug') {
|
|
730
|
+
// L6: by default expose only version + agent AVAILABILITY (a boolean) — enough
|
|
731
|
+
// to diagnose "is codex installed?". The full home dir, $PATH, and resolved
|
|
732
|
+
// binary paths (which embed the username / home) leak environment detail, so
|
|
733
|
+
// they're opt-in behind CHATPANEL_BRIDGE_DEBUG=1. The extension doesn't read
|
|
734
|
+
// this route, so trimming it by default breaks nothing.
|
|
735
|
+
const verbose = /^(1|true|yes|on)$/i.test(process.env.CHATPANEL_BRIDGE_DEBUG || '');
|
|
721
736
|
return json(res, 200, {
|
|
722
737
|
version: VERSION,
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
path: process.env.PATH,
|
|
738
|
+
agents: Object.fromEntries(AGENT_CLIS.map((name) => [name, verbose ? (findAgentBin(name) || null) : !!findAgentBin(name)])),
|
|
739
|
+
...(verbose ? { home: os.homedir(), path: process.env.PATH } : {}),
|
|
726
740
|
});
|
|
727
741
|
}
|
|
728
742
|
if (req.method === 'POST' && url.pathname === '/chat') return handleChat(req, res);
|
|
@@ -826,6 +840,12 @@ function startServer() {
|
|
|
826
840
|
});
|
|
827
841
|
server.listen(PORT, HOST, async () => {
|
|
828
842
|
log('info', `listening on http://${HOST}:${PORT}`);
|
|
843
|
+
// M7: a non-loopback bind disables the anti-DNS-rebinding Host check (hostAllowed
|
|
844
|
+
// returns true for any Host), so the only inbound guard left is the per-install
|
|
845
|
+
// token / extension Origin. Make that trade-off LOUD — it's rarely what you want.
|
|
846
|
+
if (!LOOPBACK_HOSTNAMES.has(HOST)) {
|
|
847
|
+
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.`);
|
|
848
|
+
}
|
|
829
849
|
for (const [, { engine, label, hidden }] of Object.entries(ENGINES)) {
|
|
830
850
|
if (hidden) continue;
|
|
831
851
|
const a = await engine.available().catch(() => ({ ok: false }));
|
package/src/ssrf.js
CHANGED
|
@@ -1,88 +1,35 @@
|
|
|
1
|
-
// SSRF guard for the /mcp-remote
|
|
1
|
+
// SSRF guard for the bridge's outbound proxies (/mcp-remote, /fetch-title).
|
|
2
2
|
//
|
|
3
|
-
// The
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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
|
-
//
|
|
12
|
-
// •
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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) {
|