@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.
- package/package.json +2 -2
- package/src/ack-create-request.test.ts +331 -0
- package/src/api-host-supervisors-fallback.test.ts +262 -0
- package/src/api-nodes-shape.test.ts +172 -0
- package/src/auth.ts +3 -1
- package/src/config-apply-sec1.test.ts +107 -0
- package/src/config-apply-validate.test.ts +92 -0
- package/src/config-apply-validate.ts +81 -3
- package/src/create-node-validate.test.ts +187 -0
- package/src/create-node-validate.ts +206 -0
- package/src/create-node.test.ts +354 -0
- package/src/create-node.ts +346 -0
- package/src/db.ts +196 -0
- package/src/index.ts +216 -3
- package/src/lifecycle-guard.ts +50 -0
- package/src/list-host-supervisors.test.ts +314 -0
- 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/shared/reserved-env-drift.test.ts +43 -0
- package/src/shared/reserved-env.test.ts +35 -0
- package/src/shared/reserved-env.ts +31 -0
- package/src/stop-delete-node.test.ts +720 -0
- package/src/tools.ts +1776 -13
- package/src/update-provider.test.ts +234 -0
- package/src/uploads.test.ts +66 -0
- package/src/uploads.ts +36 -0
- package/src/vault.test.ts +200 -0
- package/src/vault.ts +156 -0
|
@@ -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
|
+
}
|