fix: CLI/installer hardening — restart labels, key permissions, unit-secret escaping (#113) (#120)

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:
dtzp555-max
2026-05-31 22:56:47 +10:00
committed by GitHub
co-authored by taodeng Claude Opus 4.8
parent 4a7d79c330
commit 68d58e7df4
6 changed files with 113 additions and 15 deletions
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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.`;
} }
} }
} }
+2
View File
@@ -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) {
+28 -6
View File
@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
}
// 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/>
+68
View File
@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
}
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&lt;b&gt;&amp;&quot;&apos;");
});
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&lt;b&gt;&amp;&quot;&apos;"
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");