@sleep2agi/commhub-server 0.9.0-preview.4 → 0.9.0-preview.6
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/api-nodes-shape.test.ts +89 -11
- package/src/db.ts +70 -0
- package/src/index.ts +42 -2
- package/src/probe-validate.test.ts +221 -0
- package/src/probe-validate.ts +104 -0
- package/src/probe.test.ts +80 -0
- package/src/probe.ts +200 -0
- package/src/shared/probe-host-allowlist-drift.test.ts +54 -0
- package/src/shared/probe-host-allowlist.ts +128 -0
- package/src/tools.ts +483 -0
- package/src/update-provider.test.ts +169 -0
- package/src/vault.test.ts +200 -0
- package/src/vault.ts +156 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, expect, test, beforeEach } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
putPendingProbeSecret,
|
|
4
|
+
takePendingProbeSecret,
|
|
5
|
+
peekPendingProbeSecret,
|
|
6
|
+
evictExpiredProbeSecrets,
|
|
7
|
+
stopBackgroundProbeTimersForTest,
|
|
8
|
+
type PendingProbeSecret,
|
|
9
|
+
} from "./probe.js";
|
|
10
|
+
|
|
11
|
+
// RFC-028 P1 fold-in #4 — takePendingProbeSecret 4-case unit. Mirrors
|
|
12
|
+
// RFC-026 §4.4 takePendingEnvBlob unit pattern. This is the C2 cross-
|
|
13
|
+
// daemon guard for probes: a daemon-X that intercepts a probe_id minted
|
|
14
|
+
// for daemon-Y must NOT be able to consume it. Wrong-caller branch must
|
|
15
|
+
// NOT evict (so the right daemon can still consume in TTL).
|
|
16
|
+
|
|
17
|
+
function mkSecret(over: Partial<PendingProbeSecret> = {}): Omit<PendingProbeSecret, "expires_at"> {
|
|
18
|
+
return {
|
|
19
|
+
probe_id: over.probe_id ?? "pr_test_1",
|
|
20
|
+
daemon_node_id: over.daemon_node_id ?? "node_daemon_alpha",
|
|
21
|
+
provider_id: over.provider_id ?? "prov_1",
|
|
22
|
+
vendor: over.vendor ?? "anthropic",
|
|
23
|
+
base_url: over.base_url ?? "https://api.anthropic.com",
|
|
24
|
+
model_name: over.model_name ?? "claude-opus-test",
|
|
25
|
+
api_key: over.api_key ?? "sk-secret-not-leaked",
|
|
26
|
+
network_id: over.network_id ?? "net_1",
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe("RFC-028 P1 — takePendingProbeSecret 4-case", () => {
|
|
31
|
+
beforeEach(() => stopBackgroundProbeTimersForTest());
|
|
32
|
+
|
|
33
|
+
test("case 1: happy path — right daemon, within TTL → returns + evicts", () => {
|
|
34
|
+
putPendingProbeSecret(mkSecret({ probe_id: "pr_happy" }));
|
|
35
|
+
expect(peekPendingProbeSecret("pr_happy")).not.toBeNull();
|
|
36
|
+
const out = takePendingProbeSecret("pr_happy", "node_daemon_alpha");
|
|
37
|
+
expect(out).not.toBeNull();
|
|
38
|
+
expect(out!.api_key).toBe("sk-secret-not-leaked");
|
|
39
|
+
// Evicted on success → second take returns null
|
|
40
|
+
expect(takePendingProbeSecret("pr_happy", "node_daemon_alpha")).toBeNull();
|
|
41
|
+
expect(peekPendingProbeSecret("pr_happy")).toBeNull();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("case 2: wrong daemon → returns null AND does NOT evict (right daemon can still consume)", () => {
|
|
45
|
+
putPendingProbeSecret(mkSecret({ probe_id: "pr_wrongcaller", daemon_node_id: "node_daemon_alpha" }));
|
|
46
|
+
// attacker daemon tries to consume
|
|
47
|
+
const wrong = takePendingProbeSecret("pr_wrongcaller", "node_daemon_attacker");
|
|
48
|
+
expect(wrong).toBeNull();
|
|
49
|
+
// entry MUST still be there — peek confirms (this is the C2 invariant:
|
|
50
|
+
// a wrong-caller probe cannot DoS the legitimate one by triggering eviction)
|
|
51
|
+
const peeked = peekPendingProbeSecret("pr_wrongcaller");
|
|
52
|
+
expect(peeked).not.toBeNull();
|
|
53
|
+
expect(peeked!.daemon_node_id).toBe("node_daemon_alpha");
|
|
54
|
+
// right daemon STILL gets it
|
|
55
|
+
const right = takePendingProbeSecret("pr_wrongcaller", "node_daemon_alpha");
|
|
56
|
+
expect(right).not.toBeNull();
|
|
57
|
+
expect(right!.api_key).toBe("sk-secret-not-leaked");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("case 3: not found → returns null (no side-effect)", () => {
|
|
61
|
+
const out = takePendingProbeSecret("pr_nonexistent", "node_daemon_alpha");
|
|
62
|
+
expect(out).toBeNull();
|
|
63
|
+
// also confirm peek is null
|
|
64
|
+
expect(peekPendingProbeSecret("pr_nonexistent")).toBeNull();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("case 4: expired → returns null + evicts (so memory doesn't accumulate)", async () => {
|
|
68
|
+
// Use evictExpiredProbeSecrets's now-arg surrogate: put a fresh entry then
|
|
69
|
+
// ask take with a time-shifted view by mutating the map. Since the public
|
|
70
|
+
// API doesn't expose expires_at directly, we use the sweeper to verify
|
|
71
|
+
// expired entries get cleaned, then assert take returns null.
|
|
72
|
+
putPendingProbeSecret(mkSecret({ probe_id: "pr_expired" }));
|
|
73
|
+
// Sweep with a forced "now" 120s in the future → past TTL_MS (60s)
|
|
74
|
+
const n = evictExpiredProbeSecrets(Date.now() + 120_000);
|
|
75
|
+
expect(n).toBeGreaterThanOrEqual(1);
|
|
76
|
+
// Now any take must return null + the entry is gone
|
|
77
|
+
expect(takePendingProbeSecret("pr_expired", "node_daemon_alpha")).toBeNull();
|
|
78
|
+
expect(peekPendingProbeSecret("pr_expired")).toBeNull();
|
|
79
|
+
});
|
|
80
|
+
});
|
package/src/probe.ts
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// RFC-028 P1 §2.5 — hub-side probe state (pendingProbeSecrets Map +
|
|
2
|
+
// orphan revoke sweeper + finalize helper). MCP tools register in
|
|
3
|
+
// tools.ts (calls into here).
|
|
4
|
+
//
|
|
5
|
+
// Mirrors RFC-026 §4.4 mint-stream-evict pattern:
|
|
6
|
+
// - hub creates probe row (metadata only) + stashes decrypted secret
|
|
7
|
+
// + child fetcher params in in-memory Map (TTL 60s)
|
|
8
|
+
// - daemon get_probe_request takes (one-shot consume + evict)
|
|
9
|
+
// - daemon ack_probe_request → hub deriveErrorLabel + final row write
|
|
10
|
+
// - sweeper revokes orphan rows + tokens (P2 not needed since probe
|
|
11
|
+
// doesn't mint long-lived tokens — short-window only)
|
|
12
|
+
|
|
13
|
+
import { db, uuidv4 } from "./db.js";
|
|
14
|
+
import { vaultGet, VaultError } from "./vault.js";
|
|
15
|
+
import {
|
|
16
|
+
validateBaseUrl, deriveErrorLabel, rejectIfSecretLeaked,
|
|
17
|
+
ProbeAckPayloadSchema, type ProbeAckPayload,
|
|
18
|
+
ProbeValidationError,
|
|
19
|
+
} from "./probe-validate.js";
|
|
20
|
+
|
|
21
|
+
// ── Probe ephemeral state Map (F1 mint-stream-evict, RFC-028 §2.5) ──
|
|
22
|
+
|
|
23
|
+
export interface PendingProbeSecret {
|
|
24
|
+
probe_id: string;
|
|
25
|
+
daemon_node_id: string; // C2 cross-daemon guard
|
|
26
|
+
provider_id: string;
|
|
27
|
+
vendor: string;
|
|
28
|
+
base_url: string;
|
|
29
|
+
model_name: string;
|
|
30
|
+
api_key: string; // plaintext, ephemeral, never persisted
|
|
31
|
+
network_id: string;
|
|
32
|
+
expires_at: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const TTL_MS = 60_000;
|
|
36
|
+
const SWEEPER_INTERVAL_MS = 30_000;
|
|
37
|
+
const REAPER_TTL_MS = 60_000;
|
|
38
|
+
|
|
39
|
+
const pendingProbeSecrets = new Map<string, PendingProbeSecret>();
|
|
40
|
+
let _gcTimer: ReturnType<typeof setInterval> | null = null;
|
|
41
|
+
let _sweeperTimer: ReturnType<typeof setInterval> | null = null;
|
|
42
|
+
|
|
43
|
+
export function putPendingProbeSecret(b: Omit<PendingProbeSecret, "expires_at">): void {
|
|
44
|
+
pendingProbeSecrets.set(b.probe_id, { ...b, expires_at: Date.now() + TTL_MS });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Caller-bound one-shot consume. Returns null if not found, wrong
|
|
48
|
+
* daemon, or expired; entry deleted on success. */
|
|
49
|
+
export function takePendingProbeSecret(probe_id: string, callerDaemonNodeId: string): PendingProbeSecret | null {
|
|
50
|
+
const b = pendingProbeSecrets.get(probe_id);
|
|
51
|
+
if (!b) return null;
|
|
52
|
+
if (b.daemon_node_id !== callerDaemonNodeId) return null; // wrong-caller probe: DON'T evict
|
|
53
|
+
if (Date.now() > b.expires_at) { pendingProbeSecrets.delete(probe_id); return null; }
|
|
54
|
+
pendingProbeSecrets.delete(probe_id);
|
|
55
|
+
return b;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function peekPendingProbeSecret(probe_id: string): PendingProbeSecret | null {
|
|
59
|
+
return pendingProbeSecrets.get(probe_id) || null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function evictExpiredProbeSecrets(now = Date.now()): number {
|
|
63
|
+
let n = 0;
|
|
64
|
+
for (const [k, v] of pendingProbeSecrets) {
|
|
65
|
+
if (now > v.expires_at) { pendingProbeSecrets.delete(k); n++; }
|
|
66
|
+
}
|
|
67
|
+
return n;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function startPendingProbeGcTimer(): void {
|
|
71
|
+
if (_gcTimer) return;
|
|
72
|
+
_gcTimer = setInterval(() => {
|
|
73
|
+
const n = evictExpiredProbeSecrets();
|
|
74
|
+
if (n > 0) console.log(`[probe] GC: evicted ${n} expired probe secret(s)`);
|
|
75
|
+
}, 5_000);
|
|
76
|
+
(_gcTimer as any).unref?.();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function stopBackgroundProbeTimersForTest(): void {
|
|
80
|
+
if (_gcTimer) { clearInterval(_gcTimer); _gcTimer = null; }
|
|
81
|
+
if (_sweeperTimer) { clearInterval(_sweeperTimer); _sweeperTimer = null; }
|
|
82
|
+
pendingProbeSecrets.clear();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── Sweeper: terminal-transition probe_results rows that never got ack ──
|
|
86
|
+
// Mirrors RFC-026 §4.4.8 — probe rows older than REAPER_TTL_MS that
|
|
87
|
+
// are still status='pending' get marked timeout + error_label set.
|
|
88
|
+
|
|
89
|
+
export function runOrphanProbeSweepOnce(now = Date.now()): { swept: number } {
|
|
90
|
+
const cutoffMs = now - REAPER_TTL_MS;
|
|
91
|
+
const stale = db.all<{ probe_id: string }>(
|
|
92
|
+
`SELECT probe_id FROM probe_results WHERE status = 'pending' AND probed_at < ?1`,
|
|
93
|
+
cutoffMs,
|
|
94
|
+
);
|
|
95
|
+
if (stale.length === 0) return { swept: 0 };
|
|
96
|
+
|
|
97
|
+
let swept = 0;
|
|
98
|
+
try {
|
|
99
|
+
db.exec("BEGIN");
|
|
100
|
+
for (const row of stale) {
|
|
101
|
+
db.run(
|
|
102
|
+
`UPDATE probe_results SET status = ?1, error_label = ?2 WHERE probe_id = ?3 AND status = 'pending'`,
|
|
103
|
+
["timeout", "连通性测试超时 (sweeper: 60s 无 daemon ack)", row.probe_id],
|
|
104
|
+
);
|
|
105
|
+
pendingProbeSecrets.delete(row.probe_id);
|
|
106
|
+
swept++;
|
|
107
|
+
}
|
|
108
|
+
db.exec("COMMIT");
|
|
109
|
+
} catch (e: any) {
|
|
110
|
+
try { db.exec("ROLLBACK"); } catch { /* ok */ }
|
|
111
|
+
console.warn(`[probe] sweeper failed: ${e?.message || e}`);
|
|
112
|
+
return { swept: 0 };
|
|
113
|
+
}
|
|
114
|
+
if (swept > 0) console.log(`[commhub] ✓ probe sweeper: swept ${swept} stale probe(s)`);
|
|
115
|
+
return { swept };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function startProbeSweeperTimer(): void {
|
|
119
|
+
if (_sweeperTimer) return;
|
|
120
|
+
try { runOrphanProbeSweepOnce(); }
|
|
121
|
+
catch (e: any) { console.warn(`[probe] boot sweeper failed: ${e?.message || e}`); }
|
|
122
|
+
_sweeperTimer = setInterval(() => {
|
|
123
|
+
try { runOrphanProbeSweepOnce(); }
|
|
124
|
+
catch (e: any) { console.warn(`[probe] periodic sweeper failed: ${e?.message || e}`); }
|
|
125
|
+
}, SWEEPER_INTERVAL_MS);
|
|
126
|
+
(_sweeperTimer as any).unref?.();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── Helpers ─────────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
export function newProbeId(): string {
|
|
132
|
+
return `pr_${uuidv4()}`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Hub-side: receive daemon ack, validate, redact, derive label, write
|
|
136
|
+
* final row. Returns the persisted row OR throws ProbeValidationError. */
|
|
137
|
+
export function finalizeProbeAck(
|
|
138
|
+
rawAck: unknown,
|
|
139
|
+
caller: { network_id: string; daemon_node_id: string },
|
|
140
|
+
): { ok: boolean; error_label: string | null; status: string } {
|
|
141
|
+
// 1. Schema parse — rejects extra fields via .strict()
|
|
142
|
+
const ack = ProbeAckPayloadSchema.parse(rawAck);
|
|
143
|
+
// 2. Pull the row (existence + caller binding)
|
|
144
|
+
const row = db.get<{ probe_id: string; daemon_node_id: string; provider_id: string; status: string }>(
|
|
145
|
+
`SELECT probe_id, daemon_node_id, provider_id, status FROM probe_results WHERE probe_id = ?1`,
|
|
146
|
+
ack.probe_id,
|
|
147
|
+
);
|
|
148
|
+
if (!row) throw new ProbeValidationError("probe_not_found", { probe_id: ack.probe_id });
|
|
149
|
+
if (row.daemon_node_id !== caller.daemon_node_id) {
|
|
150
|
+
throw new ProbeValidationError("not_your_probe", { probe_id: ack.probe_id });
|
|
151
|
+
}
|
|
152
|
+
if (row.status !== "pending") {
|
|
153
|
+
throw new ProbeValidationError("probe_not_pending", { current_status: row.status });
|
|
154
|
+
}
|
|
155
|
+
// 3. Belt-and-suspenders secret-leak guard on full ack JSON
|
|
156
|
+
// (catches daemon impl bug stuffing secret into probe_id etc.)
|
|
157
|
+
try {
|
|
158
|
+
const providerRow = db.get<{ secret_key_ref: string }>(
|
|
159
|
+
`SELECT secret_key_ref FROM providers WHERE provider_id = ?1 AND network_id = ?2`,
|
|
160
|
+
row.provider_id, caller.network_id,
|
|
161
|
+
);
|
|
162
|
+
if (providerRow) {
|
|
163
|
+
const secretVal = vaultGet(caller.network_id, providerRow.secret_key_ref);
|
|
164
|
+
if (secretVal) {
|
|
165
|
+
rejectIfSecretLeaked(JSON.stringify(ack), [secretVal]);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
} catch (e) {
|
|
169
|
+
if (e instanceof ProbeValidationError && e.code === "ack_secret_leak") {
|
|
170
|
+
// audit-log this serious event (impl pattern below in tools.ts)
|
|
171
|
+
console.warn(`[probe] ack_secret_leak: daemon=${caller.daemon_node_id} probe=${ack.probe_id} reason=${JSON.stringify(e.detail)}`);
|
|
172
|
+
// mark row as failed for safety
|
|
173
|
+
db.run(
|
|
174
|
+
`UPDATE probe_results SET status = 'tls_error', error_label = ?1 WHERE probe_id = ?2 AND status = 'pending'`,
|
|
175
|
+
["内部安全异常: daemon ack 含 secret 值, 已拒并 audit", ack.probe_id],
|
|
176
|
+
);
|
|
177
|
+
throw e;
|
|
178
|
+
}
|
|
179
|
+
// VaultError (missing key etc.) — non-fatal, skip leak guard but log
|
|
180
|
+
if (e instanceof VaultError) {
|
|
181
|
+
console.warn(`[probe] leak-guard skipped (vault read failed): ${e.message}`);
|
|
182
|
+
} else {
|
|
183
|
+
throw e;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// 4. Derive error_label from enum + raw_status_code
|
|
188
|
+
const label = deriveErrorLabel(ack);
|
|
189
|
+
// 5. Final write
|
|
190
|
+
db.run(
|
|
191
|
+
`UPDATE probe_results
|
|
192
|
+
SET status = ?1, latency_ms = ?2, raw_status_code = ?3, error_label = ?4
|
|
193
|
+
WHERE probe_id = ?5 AND status = 'pending'`,
|
|
194
|
+
[ack.status, ack.latency_ms, ack.raw_status_code ?? null, label, ack.probe_id],
|
|
195
|
+
);
|
|
196
|
+
return { ok: true, status: ack.status, error_label: label };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Re-export validate funcs for tools.ts convenience
|
|
200
|
+
export { validateBaseUrl, ProbeValidationError };
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
VENDOR_HOST_ALLOWLIST as HUB_ALLOW,
|
|
6
|
+
FORBIDDEN_IPV4_RE as HUB_V4,
|
|
7
|
+
FORBIDDEN_IPV6_RE as HUB_V6,
|
|
8
|
+
} from "./probe-host-allowlist.js";
|
|
9
|
+
|
|
10
|
+
// RFC-028 P1 fold-in #1 — hub-and-daemon probe-host-allowlist drift
|
|
11
|
+
// guard. Mirrors the G9 reserved-env drift test (RFC-026 §4.4.7).
|
|
12
|
+
// Two enforcement modes:
|
|
13
|
+
// 1. byte-identical source files
|
|
14
|
+
// 2. runtime set-equality of the imported constants (vendor regex
|
|
15
|
+
// source strings + private-IP regex source strings)
|
|
16
|
+
// If a future contributor edits one side without the other, this test
|
|
17
|
+
// breaks the merge — the daemon-side compromised-hub defense depends
|
|
18
|
+
// on the two lists being identical.
|
|
19
|
+
|
|
20
|
+
const HUB_PATH = join(import.meta.dir, "probe-host-allowlist.ts");
|
|
21
|
+
const DAEMON_PATH = join(
|
|
22
|
+
import.meta.dir,
|
|
23
|
+
"..", "..", "..", "agent-node", "src", "shared", "probe-host-allowlist.ts",
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
describe("RFC-028 P1 — hub/daemon probe-host-allowlist drift guard", () => {
|
|
27
|
+
test("byte-identical source files", () => {
|
|
28
|
+
const hub = readFileSync(HUB_PATH, "utf-8");
|
|
29
|
+
const daemon = readFileSync(DAEMON_PATH, "utf-8");
|
|
30
|
+
expect(daemon).toBe(hub);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("VENDOR_HOST_ALLOWLIST regex sources equal at runtime", async () => {
|
|
34
|
+
const daemonMod = await import(DAEMON_PATH);
|
|
35
|
+
const daemonAllow = daemonMod.VENDOR_HOST_ALLOWLIST as Record<string, ReadonlyArray<RegExp>>;
|
|
36
|
+
// vendor set equality
|
|
37
|
+
expect(new Set(Object.keys(daemonAllow))).toEqual(new Set(Object.keys(HUB_ALLOW)));
|
|
38
|
+
// per-vendor regex source array equality (order matters — both should
|
|
39
|
+
// be authored in identical order to keep diffs minimal)
|
|
40
|
+
for (const vendor of Object.keys(HUB_ALLOW)) {
|
|
41
|
+
const hubSources = HUB_ALLOW[vendor].map(r => r.source);
|
|
42
|
+
const daemonSources = daemonAllow[vendor].map(r => r.source);
|
|
43
|
+
expect(daemonSources).toEqual(hubSources);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("FORBIDDEN_IPV4_RE + FORBIDDEN_IPV6_RE sources equal at runtime", async () => {
|
|
48
|
+
const daemonMod = await import(DAEMON_PATH);
|
|
49
|
+
const daemonV4 = (daemonMod.FORBIDDEN_IPV4_RE as ReadonlyArray<RegExp>).map(r => r.source);
|
|
50
|
+
const daemonV6 = (daemonMod.FORBIDDEN_IPV6_RE as ReadonlyArray<RegExp>).map(r => r.source);
|
|
51
|
+
expect(daemonV4).toEqual(HUB_V4.map(r => r.source));
|
|
52
|
+
expect(daemonV6).toEqual(HUB_V6.map(r => r.source));
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// RFC-028 P1 §4.4 — shared probe SSRF guards.
|
|
2
|
+
// Host allowlist + private IP block + validateBaseUrl.
|
|
3
|
+
//
|
|
4
|
+
// MUST be byte-identical between hub (server/src/shared/) and daemon
|
|
5
|
+
// (agent-node/src/shared/). Enforced by probe-host-allowlist-drift.test.ts
|
|
6
|
+
// (same pattern as reserved-env.ts G9 drift guard, RFC-026 §4.4.7).
|
|
7
|
+
//
|
|
8
|
+
// Why mirror not symlink: hub and daemon are separate npm packages
|
|
9
|
+
// installed independently on different machines. A drift-checked source
|
|
10
|
+
// copy survives `npm pack` + global install; a symlink does not.
|
|
11
|
+
//
|
|
12
|
+
// Threat model the daemon re-check defends against:
|
|
13
|
+
// - Compromised hub records a malicious base_url in providers/spec
|
|
14
|
+
// - Hub bypassed validateBaseUrl (regression / different code path)
|
|
15
|
+
// - On-wire injection between hub→daemon SSE
|
|
16
|
+
// Daemon trusts process.execPath + this file's content, NOT what
|
|
17
|
+
// `get_probe_request` claims. If hub-side check is identical, daemon
|
|
18
|
+
// re-check is a no-op; if hub-side ever loosens, daemon stays strict.
|
|
19
|
+
|
|
20
|
+
export class ProbeValidationError extends Error {
|
|
21
|
+
constructor(public code: string, public detail?: Record<string, unknown>) {
|
|
22
|
+
super(`${code}${detail ? ` ${JSON.stringify(detail)}` : ""}`);
|
|
23
|
+
this.name = "ProbeValidationError";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ── §4.4.1 per-vendor host allowlist ──────────────────────────────
|
|
28
|
+
// `anthropic` vendor accepts Anthropic + curated 3rd-party Anthropic-
|
|
29
|
+
// compatible endpoints that Vincent / N站马 ship live providers
|
|
30
|
+
// against (DeepSeek + MiniMax both expose `/v1/messages` with the
|
|
31
|
+
// Anthropic protocol surface). Whitelist semantics preserved — NOT
|
|
32
|
+
// allow-any. New entries land here via PR with security review
|
|
33
|
+
// (network-admin-configurable allowlist is backlog: needs strong
|
|
34
|
+
// IP-range / shape guard before admins can edit).
|
|
35
|
+
//
|
|
36
|
+
// Per 通信龙 (RFC-028 P1.5+): the host whitelist is defense layer 1;
|
|
37
|
+
// layer 2 is the DNS-resolved IP block (FORBIDDEN_IPV{4,6}_RE below),
|
|
38
|
+
// applied AFTER allowlist passes. Any new host gets BOTH layers
|
|
39
|
+
// automatically — host in allowlist + DNS resolves to private/metadata
|
|
40
|
+
// IP still surfaces `probe_resolve_unsafe_ip`.
|
|
41
|
+
//
|
|
42
|
+
// P2 will add vendors (openai/zai/openrouter/qwen) as separate keys.
|
|
43
|
+
export const VENDOR_HOST_ALLOWLIST: Record<string, ReadonlyArray<RegExp>> = {
|
|
44
|
+
anthropic: [
|
|
45
|
+
/^api\.anthropic\.com$/,
|
|
46
|
+
/^api\.deepseek\.com$/, // Anthropic-compatible /v1/messages
|
|
47
|
+
/^api\.minimax\.chat$/, // MiniMax legacy domain
|
|
48
|
+
/^api\.minimax\.io$/, // MiniMax new domain (Vincent live use)
|
|
49
|
+
],
|
|
50
|
+
// P2:
|
|
51
|
+
// openai: [/^api\.openai\.com$/],
|
|
52
|
+
// zai: [/^api\.z\.ai$/, /^open\.bigmodel\.cn$/],
|
|
53
|
+
// openrouter: [/^openrouter\.ai$/],
|
|
54
|
+
// qwen: [/^dashscope(-intl)?\.aliyuncs\.com$/],
|
|
55
|
+
};
|
|
56
|
+
export const SUPPORTED_VENDORS = Object.keys(VENDOR_HOST_ALLOWLIST);
|
|
57
|
+
|
|
58
|
+
// ── §4.4.2 private/reserved IP block (anti SSRF) ──────────────────
|
|
59
|
+
export const FORBIDDEN_IPV4_RE: ReadonlyArray<RegExp> = [
|
|
60
|
+
/^10\./,
|
|
61
|
+
/^127\./,
|
|
62
|
+
/^172\.(1[6-9]|2[0-9]|3[0-1])\./,
|
|
63
|
+
/^192\.168\./,
|
|
64
|
+
/^169\.254\./, // link-local + 169.254.169.254 cloud metadata
|
|
65
|
+
/^100\.(6[4-9]|[7-9][0-9]|1[01][0-9]|12[0-7])\./, // CGNAT
|
|
66
|
+
/^0\./,
|
|
67
|
+
/^22[4-9]\./, /^23[0-9]\./, // multicast
|
|
68
|
+
/^24[0-9]\./, /^25[0-5]\./, // experimental
|
|
69
|
+
];
|
|
70
|
+
export const FORBIDDEN_IPV6_RE: ReadonlyArray<RegExp> = [
|
|
71
|
+
/^::1$/,
|
|
72
|
+
/^::$/,
|
|
73
|
+
/^fe80:/i, /^fc00:/i, /^fd00:/i,
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
export function isLoopbackHost(host: string): boolean {
|
|
77
|
+
return host === "localhost" || host === "127.0.0.1" || host === "::1";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function isForbiddenIp(ip: string): boolean {
|
|
81
|
+
// IPv4-mapped IPv6: ::ffff:10.0.0.1 — recurse on inner v4
|
|
82
|
+
if (ip.toLowerCase().startsWith("::ffff:")) {
|
|
83
|
+
return isForbiddenIp(ip.slice(7));
|
|
84
|
+
}
|
|
85
|
+
// Simple v4/v6 distinction by presence of '.' / ':'
|
|
86
|
+
if (ip.includes(":") && !ip.includes(".")) {
|
|
87
|
+
return FORBIDDEN_IPV6_RE.some(re => re.test(ip));
|
|
88
|
+
}
|
|
89
|
+
return FORBIDDEN_IPV4_RE.some(re => re.test(ip));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── §4.4.3 validateBaseUrl — boot/pre-fetch URL+host+vendor check ─
|
|
93
|
+
/** Pure, no I/O. Called by hub (upsert_provider write path) AND
|
|
94
|
+
* daemon (handleProbeDoorbell re-check after get_probe_request).
|
|
95
|
+
* allowLoopback: dev-only opt-in honoring http://localhost / 127.0.0.1.
|
|
96
|
+
* Production daemon MUST NOT set allowLoopback=true. */
|
|
97
|
+
export function validateBaseUrl(
|
|
98
|
+
vendor: string,
|
|
99
|
+
baseUrl: string,
|
|
100
|
+
opts: { allowLoopback?: boolean } = {},
|
|
101
|
+
): void {
|
|
102
|
+
let u: URL;
|
|
103
|
+
try { u = new URL(baseUrl); }
|
|
104
|
+
catch {
|
|
105
|
+
throw new ProbeValidationError("probe_base_url_invalid", {
|
|
106
|
+
reason: "not a valid URL", baseUrl: baseUrl.slice(0, 100),
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (u.protocol !== "https:") {
|
|
111
|
+
if (!opts.allowLoopback || !isLoopbackHost(u.hostname) || u.protocol !== "http:") {
|
|
112
|
+
throw new ProbeValidationError("probe_base_url_invalid", {
|
|
113
|
+
reason: "must be https (or http+loopback with dev opt-in)",
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const allowed = VENDOR_HOST_ALLOWLIST[vendor];
|
|
118
|
+
if (!allowed) {
|
|
119
|
+
throw new ProbeValidationError("vendor_not_supported", {
|
|
120
|
+
vendor, supported: SUPPORTED_VENDORS,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
if (!allowed.some(re => re.test(u.hostname))) {
|
|
124
|
+
throw new ProbeValidationError("probe_target_forbidden", {
|
|
125
|
+
vendor, host: u.hostname, allowed: allowed.map(r => r.source),
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|