mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-19 09:44:07 +00:00
Three CLI/installer findings from the 2026-05-31 audit (no server.mjs): 1. ocp-plugin `cmdRestart` hardcoded uid 501 + the legacy `ai.openclaw.proxy` label, and fell through to a dangerous `pkill -f 'node.*server.mjs' && cd ~/.openclaw/projects/*/; node server.mjs &` that could kill an unrelated node process and relaunch an env-less ghost proxy from a glob-ambiguous dir. Now uses process.getuid() + the live labels (dev.ocp.proxy on macOS, ocp-proxy on Linux systemd; the OpenClaw gateway label is unchanged) and drops the pkill fallback entirely (returns a manual `ocp restart` message on failure). 2. ocp-connect wrote the quota key unquoted into rc files and a world-readable environment.d/ocp.conf. Now single-quotes the value and chmod 600s the rc files and ocp.conf (matching the existing auth-profiles.json 0o600 convention). 3. setup.mjs interpolated the injected service-unit secrets (CLAUDE_BIN, OCP_ADMIN_KEY, PROXY_ANONYMOUS_KEY) raw into plist <string> and systemd Environment= lines. Added xmlEscape() for all plist <string> values and assertSafeInjectValue() which rejects control characters (\x00-\x1f — newline, CR, tab) before any unit is written, blocking a newline-injected rogue Environment= directive. Spaces are intentionally allowed (CLAUDE_BIN paths may contain them). XML-escaping on write also resolves plist-merge.mjs's [^<]* regex concern transitively (no raw < reaches it) — comment added, logic unchanged. ALIGNMENT.md: CLI/installer scripts only, no Anthropic operation forwarded → cli.js citation N/A. No blacklisted tokens or port literals introduced; alignment.yml passes. Independent fresh-context reviewer (opus): APPROVE (Iron Rule 10) — verified the control-char regex byte-exact via od (no space-rejection regression), the pkill fallback fully removed, restart labels match setup.mjs ground truth, OCP keys (base64url) cannot break the single-quoting, and the validator runs before any write. Closes #113. Co-authored-by: dtzp555 <dtzp555@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
### Fix
|
### Fix
|
||||||
|
|
||||||
|
- **#113** — CLI/installer hardening: ocp-plugin restart uses the live uid + `dev.ocp.proxy`/`ocp-proxy` labels and drops the unsafe pkill fallback; ocp-connect quotes + chmod 600 the persisted key; setup.mjs XML-escapes and newline-validates injected service-unit secrets.
|
||||||
- **#112** — Recorded OAuth-host verification against compiled cli.js v2.1.154 (ALIGNMENT Class A); usage-probe and default request model now derive from `models.json` (ADR 0003 SPOT) instead of hardcoded IDs.
|
- **#112** — Recorded OAuth-host verification against compiled cli.js v2.1.154 (ALIGNMENT Class A); usage-probe and default request model now derive from `models.json` (ADR 0003 SPOT) instead of hardcoded IDs.
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
|||||||
+4
-2
@@ -634,11 +634,12 @@ PYEOF
|
|||||||
{
|
{
|
||||||
echo ""
|
echo ""
|
||||||
echo "# OCP LAN (added by ocp connect)"
|
echo "# OCP LAN (added by ocp connect)"
|
||||||
echo "export OPENAI_BASE_URL=$base_url/v1"
|
echo "export OPENAI_BASE_URL='$base_url/v1'"
|
||||||
if [[ -n "$key" ]]; then
|
if [[ -n "$key" ]]; then
|
||||||
echo "export OPENAI_API_KEY=$key"
|
echo "export OPENAI_API_KEY='$key'"
|
||||||
fi
|
fi
|
||||||
} >> "$rc_file"
|
} >> "$rc_file"
|
||||||
|
chmod 600 "$rc_file" 2>/dev/null || true
|
||||||
done
|
done
|
||||||
|
|
||||||
echo " Shell config:"
|
echo " Shell config:"
|
||||||
@@ -671,6 +672,7 @@ PYEOF
|
|||||||
echo "OPENAI_API_KEY=$key"
|
echo "OPENAI_API_KEY=$key"
|
||||||
fi
|
fi
|
||||||
} > "$env_dir/ocp.conf"
|
} > "$env_dir/ocp.conf"
|
||||||
|
chmod 600 "$env_dir/ocp.conf" 2>/dev/null || true
|
||||||
echo ""
|
echo ""
|
||||||
echo " System-level (systemd):"
|
echo " System-level (systemd):"
|
||||||
echo " ✓ $env_dir/ocp.conf"
|
echo " ✓ $env_dir/ocp.conf"
|
||||||
|
|||||||
+10
-7
@@ -208,31 +208,34 @@ async function cmdTest() {
|
|||||||
async function cmdRestart(args) {
|
async function cmdRestart(args) {
|
||||||
const target = (args || "").trim().toLowerCase();
|
const target = (args || "").trim().toLowerCase();
|
||||||
const { execSync } = await import("node:child_process");
|
const { execSync } = await import("node:child_process");
|
||||||
|
const uid = typeof process.getuid === "function" ? process.getuid() : 501;
|
||||||
|
const macProxy = `launchctl kickstart -k gui/${uid}/dev.ocp.proxy`;
|
||||||
|
const macGateway = `launchctl kickstart -k gui/${uid}/ai.openclaw.gateway`;
|
||||||
try {
|
try {
|
||||||
if (target === "gateway") {
|
if (target === "gateway") {
|
||||||
execSync("launchctl kickstart -k gui/501/ai.openclaw.gateway", { timeout: 15000 });
|
execSync(macGateway, { timeout: 15000 });
|
||||||
return "✓ Gateway restarted";
|
return "✓ Gateway restarted";
|
||||||
} else if (target === "all") {
|
} else if (target === "all") {
|
||||||
execSync("launchctl kickstart -k gui/501/ai.openclaw.proxy", { timeout: 15000 });
|
execSync(macProxy, { timeout: 15000 });
|
||||||
// Gateway restart will kill this plugin too, so do it last
|
// Gateway restart will kill this plugin too, so do it last
|
||||||
execSync("launchctl kickstart -k gui/501/ai.openclaw.gateway", { timeout: 15000 });
|
execSync(macGateway, { timeout: 15000 });
|
||||||
return "✓ Proxy + Gateway restarted";
|
return "✓ Proxy + Gateway restarted";
|
||||||
} else {
|
} else {
|
||||||
execSync("launchctl kickstart -k gui/501/ai.openclaw.proxy", { timeout: 15000 });
|
execSync(macProxy, { timeout: 15000 });
|
||||||
return "✓ Proxy restarted";
|
return "✓ Proxy restarted";
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Try systemd for Linux
|
// Linux: systemd user services
|
||||||
try {
|
try {
|
||||||
if (target === "gateway") {
|
if (target === "gateway") {
|
||||||
execSync("systemctl --user restart openclaw-gateway", { timeout: 15000 });
|
execSync("systemctl --user restart openclaw-gateway", { timeout: 15000 });
|
||||||
return "✓ Gateway restarted";
|
return "✓ Gateway restarted";
|
||||||
} else {
|
} else {
|
||||||
execSync("systemctl --user restart openclaw-proxy 2>/dev/null || pkill -f 'node.*server.mjs' && sleep 2 && cd ~/.openclaw/projects/*/; node server.mjs &", { timeout: 15000, shell: true });
|
execSync("systemctl --user restart ocp-proxy", { timeout: 15000 });
|
||||||
return "✓ Proxy restarted";
|
return "✓ Proxy restarted";
|
||||||
}
|
}
|
||||||
} catch (e2) {
|
} catch (e2) {
|
||||||
return `✗ Restart failed: ${e2.message?.slice(0, 100)}`;
|
return `✗ Restart failed: ${e2.message?.slice(0, 100)}. Run \`ocp restart\` on the server host manually.`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
// No new dependencies — regex-based, plist <key>X</key><string>Y</string> shape
|
// No new dependencies — regex-based, plist <key>X</key><string>Y</string> shape
|
||||||
// is stable enough for our hand-written templates in setup.mjs.
|
// is stable enough for our hand-written templates in setup.mjs.
|
||||||
|
|
||||||
|
// Note: setup.mjs XML-escapes all injected values before writing (via xmlEscape()),
|
||||||
|
// so raw `<` / `>` / `&` never appear in plist <string> bodies — the [^<]* regex below is safe.
|
||||||
const PLIST_KV_RE = /<key>([^<]+)<\/key>\s*<string>([^<]*)<\/string>/g;
|
const PLIST_KV_RE = /<key>([^<]+)<\/key>\s*<string>([^<]*)<\/string>/g;
|
||||||
|
|
||||||
export function parsePlistEnv(plistContent) {
|
export function parsePlistEnv(plistContent) {
|
||||||
|
|||||||
@@ -65,6 +65,28 @@ const OCP_ADMIN_KEY_INJECT = process.env.OCP_ADMIN_KEY || null;
|
|||||||
// PROXY_ANONYMOUS_KEY — same pattern
|
// PROXY_ANONYMOUS_KEY — same pattern
|
||||||
const PROXY_ANON_KEY_INJECT = process.env.PROXY_ANONYMOUS_KEY || null;
|
const PROXY_ANON_KEY_INJECT = process.env.PROXY_ANONYMOUS_KEY || null;
|
||||||
|
|
||||||
|
// ── Inject-value helpers ─────────────────────────────────────────────────
|
||||||
|
// Escape a value for safe inclusion in a plist <string>…</string> body.
|
||||||
|
function xmlEscape(v) {
|
||||||
|
return String(v).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
// Validate an injected service value: no control chars (a newline would inject a
|
||||||
|
// rogue systemd Environment= directive; other control chars corrupt the unit/plist).
|
||||||
|
// Spaces are allowed — filesystem paths (CLAUDE_BIN) may legitimately contain them.
|
||||||
|
function assertSafeInjectValue(name, v) {
|
||||||
|
if (v == null) return v;
|
||||||
|
if (/[\x00-\x1f]/.test(String(v))) {
|
||||||
|
console.error(`FATAL: ${name} contains a newline or control character — refusing to write it into the service unit.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate all three INJECT values before they are written into any service unit.
|
||||||
|
assertSafeInjectValue("CLAUDE_BIN", CLAUDE_BIN_INJECT);
|
||||||
|
assertSafeInjectValue("OCP_ADMIN_KEY", OCP_ADMIN_KEY_INJECT);
|
||||||
|
assertSafeInjectValue("PROXY_ANONYMOUS_KEY", PROXY_ANON_KEY_INJECT);
|
||||||
|
|
||||||
// ── Models: derived from models.json (single source of truth) ──────────
|
// ── Models: derived from models.json (single source of truth) ──────────
|
||||||
const modelsConfig = JSON.parse(readFileSync(join(__dirname, "models.json"), "utf-8"));
|
const modelsConfig = JSON.parse(readFileSync(join(__dirname, "models.json"), "utf-8"));
|
||||||
|
|
||||||
@@ -403,17 +425,17 @@ if (!DRY_RUN) {
|
|||||||
<key>EnvironmentVariables</key>
|
<key>EnvironmentVariables</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>CLAUDE_PROXY_PORT</key>
|
<key>CLAUDE_PROXY_PORT</key>
|
||||||
<string>${PORT}</string>
|
<string>${xmlEscape(PORT)}</string>
|
||||||
<key>CLAUDE_BIND</key>
|
<key>CLAUDE_BIND</key>
|
||||||
<string>${BIND_ADDRESS}</string>
|
<string>${xmlEscape(BIND_ADDRESS)}</string>
|
||||||
<key>CLAUDE_AUTH_MODE</key>
|
<key>CLAUDE_AUTH_MODE</key>
|
||||||
<string>${AUTH_MODE_CONFIG}</string>${CLAUDE_BIN_INJECT ? `
|
<string>${xmlEscape(AUTH_MODE_CONFIG)}</string>${CLAUDE_BIN_INJECT ? `
|
||||||
<key>CLAUDE_BIN</key>
|
<key>CLAUDE_BIN</key>
|
||||||
<string>${CLAUDE_BIN_INJECT}</string>` : ""}${OCP_ADMIN_KEY_INJECT ? `
|
<string>${xmlEscape(CLAUDE_BIN_INJECT)}</string>` : ""}${OCP_ADMIN_KEY_INJECT ? `
|
||||||
<key>OCP_ADMIN_KEY</key>
|
<key>OCP_ADMIN_KEY</key>
|
||||||
<string>${OCP_ADMIN_KEY_INJECT}</string>` : ""}${PROXY_ANON_KEY_INJECT ? `
|
<string>${xmlEscape(OCP_ADMIN_KEY_INJECT)}</string>` : ""}${PROXY_ANON_KEY_INJECT ? `
|
||||||
<key>PROXY_ANONYMOUS_KEY</key>
|
<key>PROXY_ANONYMOUS_KEY</key>
|
||||||
<string>${PROXY_ANON_KEY_INJECT}</string>` : ""}
|
<string>${xmlEscape(PROXY_ANON_KEY_INJECT)}</string>` : ""}
|
||||||
</dict>
|
</dict>
|
||||||
<key>RunAtLoad</key>
|
<key>RunAtLoad</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
|||||||
@@ -858,6 +858,74 @@ test("gcSnapshots keeps last N regardless of age", () => {
|
|||||||
rmSync(root, { recursive: true, force: true });
|
rmSync(root, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── setup.mjs helpers: xmlEscape + assertSafeInjectValue ──
|
||||||
|
// setup.mjs cannot be imported (top-level side effects run the installer).
|
||||||
|
// Replicated verbatim from setup.mjs for unit-testing — keep in sync with source.
|
||||||
|
console.log("\nsetup.mjs inject helpers:");
|
||||||
|
|
||||||
|
function xmlEscape(v) {
|
||||||
|
return String(v).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
function assertSafeInjectValueTest(name, v) {
|
||||||
|
if (v == null) return v;
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
if (/[\x00-\x1f]/.test(String(v))) {
|
||||||
|
throw new Error(`FATAL: ${name} contains a newline or control character`);
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("xmlEscape encodes all five special XML chars", () => {
|
||||||
|
assert.equal(xmlEscape('a<b>&"\''), "a<b>&"'");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("xmlEscape leaves normal ocp_ token untouched", () => {
|
||||||
|
assert.equal(xmlEscape("ocp_abc123"), "ocp_abc123");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("assertSafeInjectValue rejects value with newline", () => {
|
||||||
|
assert.throws(() => assertSafeInjectValueTest("OCP_ADMIN_KEY", "a\nb"), /FATAL/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("assertSafeInjectValue rejects value with carriage return", () => {
|
||||||
|
assert.throws(() => assertSafeInjectValueTest("OCP_ADMIN_KEY", "a\rb"), /FATAL/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("assertSafeInjectValue rejects value with a tab (control char)", () => {
|
||||||
|
assert.throws(() => assertSafeInjectValueTest("OCP_ADMIN_KEY", "a\tb"), /FATAL/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("assertSafeInjectValue ACCEPTS a path with a space (CLAUDE_BIN may legitimately contain one)", () => {
|
||||||
|
assert.equal(assertSafeInjectValueTest("CLAUDE_BIN", "/Users/x/My Apps/node"), "/Users/x/My Apps/node");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("assertSafeInjectValue accepts normal ocp_ token", () => {
|
||||||
|
assert.doesNotThrow(() => assertSafeInjectValueTest("OCP_ADMIN_KEY", "ocp_abc123"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("assertSafeInjectValue accepts null (omit path)", () => {
|
||||||
|
assert.doesNotThrow(() => assertSafeInjectValueTest("OCP_ADMIN_KEY", null));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("plist-merge round-trips XML-escaped value correctly via mergePlistEnv", () => {
|
||||||
|
// A value written with xmlEscape must survive a merge cycle — the [^<]* regex in
|
||||||
|
// parsePlistEnv only sees the escaped form (no raw < reaches it), so round-trip is safe.
|
||||||
|
const escaped = xmlEscape("a<b>&\"'"); // "a<b>&"'"
|
||||||
|
const template = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>EnvironmentVariables</key>
|
||||||
|
<dict>
|
||||||
|
<key>CLAUDE_AUTH_MODE</key>
|
||||||
|
<string>${escaped}</string>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>`;
|
||||||
|
// mergePlistEnv with no existing plist returns template unchanged.
|
||||||
|
const merged = mergePlistEnv(null, template);
|
||||||
|
assert.ok(merged.includes(escaped), "escaped value should survive unchanged through plist merge");
|
||||||
|
});
|
||||||
|
|
||||||
test("gcSnapshots keeps snapshots newer than keepDays regardless of count", () => {
|
test("gcSnapshots keeps snapshots newer than keepDays regardless of count", () => {
|
||||||
const root = mkdtempSync(testJoin(tmpdir(), "ocp-gc-days-"));
|
const root = mkdtempSync(testJoin(tmpdir(), "ocp-gc-days-"));
|
||||||
const dotOcp = testJoin(root, ".ocp");
|
const dotOcp = testJoin(root, ".ocp");
|
||||||
|
|||||||
Reference in New Issue
Block a user