@sleep2agi/commhub-server 0.9.0-preview.2 → 0.9.0-preview.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.
@@ -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
+ }
@@ -0,0 +1,43 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import {
5
+ RESERVED_ENV_KEYS_EXACT as HUB_EXACT,
6
+ RESERVED_ENV_PREFIXES as HUB_PREFIXES,
7
+ } from "./reserved-env.js";
8
+
9
+ // RFC-026 v4 §4.4.7 G9 drift guard — denylist MUST be hub-and-daemon
10
+ // identical, or attacker walks the looser layer. Two enforcement modes:
11
+ //
12
+ // 1. byte-identical source files (this test)
13
+ // 2. runtime set-equality of the imported constants
14
+ //
15
+ // We assert both. If a future contributor edits one side without the
16
+ // other, this test breaks the merge.
17
+
18
+ const HUB_PATH = join(import.meta.dir, "reserved-env.ts");
19
+ const DAEMON_PATH = join(import.meta.dir, "..", "..", "..", "agent-node", "src", "shared", "reserved-env.ts");
20
+
21
+ describe("RFC-026 v4 §4.4.7 G9 — hub/daemon reserved-env drift guard", () => {
22
+ test("byte-identical source files", () => {
23
+ const hub = readFileSync(HUB_PATH, "utf-8");
24
+ const daemon = readFileSync(DAEMON_PATH, "utf-8");
25
+ expect(daemon).toBe(hub);
26
+ });
27
+
28
+ test("imported constants are set-equal at runtime", async () => {
29
+ // Use a dynamic file import path so this test breaks if the file
30
+ // moves but still references the daemon copy.
31
+ const daemonMod = await import(DAEMON_PATH);
32
+ const daemonExact = daemonMod.RESERVED_ENV_KEYS_EXACT as Set<string>;
33
+ const daemonPrefixes = daemonMod.RESERVED_ENV_PREFIXES as ReadonlyArray<string>;
34
+
35
+ // EXACT set equality
36
+ expect(daemonExact.size).toBe(HUB_EXACT.size);
37
+ for (const k of HUB_EXACT) expect(daemonExact.has(k)).toBe(true);
38
+ for (const k of daemonExact) expect(HUB_EXACT.has(k)).toBe(true);
39
+
40
+ // PREFIX array order-independent equality
41
+ expect(new Set(daemonPrefixes)).toEqual(new Set(HUB_PREFIXES));
42
+ });
43
+ });
@@ -0,0 +1,35 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { RESERVED_ENV_KEYS_EXACT, RESERVED_ENV_PREFIXES, isReservedEnvKey } from "./reserved-env.js";
3
+
4
+ describe("RFC-026 v4 §4.4.7 reserved env denylist (B1)", () => {
5
+ test("exact PATH/HOME/LANG/NODE_OPTIONS blocked", () => {
6
+ for (const k of ["PATH", "HOME", "LANG", "LC_ALL", "SHELL", "USER", "LOGNAME", "NODE_OPTIONS", "IFS", "TMPDIR"]) {
7
+ expect(isReservedEnvKey(k)).toBe(true);
8
+ }
9
+ });
10
+ test("LD_ prefix family blocked (Linux dynamic loader)", () => {
11
+ for (const k of ["LD_PRELOAD", "LD_LIBRARY_PATH", "LD_AUDIT", "LD_"]) {
12
+ expect(isReservedEnvKey(k)).toBe(true);
13
+ }
14
+ });
15
+ test("DYLD_ prefix blocked (macOS)", () => {
16
+ expect(isReservedEnvKey("DYLD_INSERT_LIBRARIES")).toBe(true);
17
+ expect(isReservedEnvKey("DYLD_LIBRARY_PATH")).toBe(true);
18
+ });
19
+ test("BUN_/NPM_/NPM_CONFIG_/NODE_ prefix blocked", () => {
20
+ expect(isReservedEnvKey("BUN_INSTALL")).toBe(true);
21
+ expect(isReservedEnvKey("NPM_TOKEN")).toBe(true);
22
+ expect(isReservedEnvKey("NPM_CONFIG_REGISTRY")).toBe(true);
23
+ expect(isReservedEnvKey("NODE_PATH")).toBe(true);
24
+ expect(isReservedEnvKey("NODE_TLS_REJECT_UNAUTHORIZED")).toBe(true);
25
+ });
26
+ test("legitimate API key style is NOT blocked", () => {
27
+ for (const k of ["ANTHROPIC_API_KEY", "OPENAI_API_KEY", "TELEGRAM_BOT_TOKEN", "DEEPSEEK_API_KEY", "MY_APP_CONFIG"]) {
28
+ expect(isReservedEnvKey(k)).toBe(false);
29
+ }
30
+ });
31
+ test("set shape sanity — both EXACT and PREFIX non-empty + frozen-ish", () => {
32
+ expect(RESERVED_ENV_KEYS_EXACT.size).toBeGreaterThan(10);
33
+ expect(RESERVED_ENV_PREFIXES.length).toBeGreaterThan(3);
34
+ });
35
+ });
@@ -0,0 +1,31 @@
1
+ // RFC-026 v4 §4.4.7 B1 — reserved env-key denylist (single source of
2
+ // truth for hub + daemon).
3
+ //
4
+ // Scope: env_refs naming a system/process-model sensitive key (PATH /
5
+ // LD_PRELOAD / NODE_OPTIONS / BUN_* / NPM_* / ...) MUST be rejected
6
+ // before the secret value ever reaches fork(), because such a key in
7
+ // the child env is effectively arbitrary code execution.
8
+ //
9
+ // G9 (drift guard): this module is the ONE source. Daemon-side runtime
10
+ // imports the same constants. A divergence between hub and daemon would
11
+ // let attacker target the looser layer; CI test asserts set equality.
12
+
13
+ export const RESERVED_ENV_KEYS_EXACT = new Set<string>([
14
+ "PATH", "HOME", "LANG", "LC_ALL", "SHELL", "USER", "LOGNAME",
15
+ "NODE_OPTIONS", "IFS", "PS1", "PS4", "ENV", "BASH_ENV",
16
+ "CDPATH", "PROMPT_COMMAND", "TMPDIR",
17
+ ]);
18
+
19
+ // Prefix-match denied. Order matters only for clarity (LD_ catches
20
+ // LD_PRELOAD / LD_LIBRARY_PATH / LD_AUDIT; DYLD_ catches DYLD_INSERT_
21
+ // LIBRARIES; BUN_ / NPM_ / NPM_CONFIG_ / NODE_ catch ecosystem-level
22
+ // runtime config).
23
+ export const RESERVED_ENV_PREFIXES: ReadonlyArray<string> = [
24
+ "LD_", "DYLD_", "BUN_", "NPM_", "NPM_CONFIG_", "NODE_",
25
+ ];
26
+
27
+ export function isReservedEnvKey(k: string): boolean {
28
+ if (RESERVED_ENV_KEYS_EXACT.has(k)) return true;
29
+ for (const p of RESERVED_ENV_PREFIXES) if (k.startsWith(p)) return true;
30
+ return false;
31
+ }