diff --git a/CHANGELOG.md b/CHANGELOG.md index 71d0a38..7914fc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### 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. ### Security diff --git a/ocp-connect b/ocp-connect index a6ced15..de268fa 100755 --- a/ocp-connect +++ b/ocp-connect @@ -634,11 +634,12 @@ PYEOF { echo "" 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 - echo "export OPENAI_API_KEY=$key" + echo "export OPENAI_API_KEY='$key'" fi } >> "$rc_file" + chmod 600 "$rc_file" 2>/dev/null || true done echo " Shell config:" @@ -671,6 +672,7 @@ PYEOF echo "OPENAI_API_KEY=$key" fi } > "$env_dir/ocp.conf" + chmod 600 "$env_dir/ocp.conf" 2>/dev/null || true echo "" echo " System-level (systemd):" echo " ✓ $env_dir/ocp.conf" diff --git a/ocp-plugin/index.js b/ocp-plugin/index.js index 966a971..817ea10 100644 --- a/ocp-plugin/index.js +++ b/ocp-plugin/index.js @@ -208,31 +208,34 @@ async function cmdTest() { async function cmdRestart(args) { const target = (args || "").trim().toLowerCase(); 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 { if (target === "gateway") { - execSync("launchctl kickstart -k gui/501/ai.openclaw.gateway", { timeout: 15000 }); + execSync(macGateway, { timeout: 15000 }); return "✓ Gateway restarted"; } 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 - execSync("launchctl kickstart -k gui/501/ai.openclaw.gateway", { timeout: 15000 }); + execSync(macGateway, { timeout: 15000 }); return "✓ Proxy + Gateway restarted"; } else { - execSync("launchctl kickstart -k gui/501/ai.openclaw.proxy", { timeout: 15000 }); + execSync(macProxy, { timeout: 15000 }); return "✓ Proxy restarted"; } } catch (e) { - // Try systemd for Linux + // Linux: systemd user services try { if (target === "gateway") { execSync("systemctl --user restart openclaw-gateway", { timeout: 15000 }); return "✓ Gateway restarted"; } 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"; } } 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.`; } } } diff --git a/scripts/lib/plist-merge.mjs b/scripts/lib/plist-merge.mjs index 52683f5..7ef8ebc 100644 --- a/scripts/lib/plist-merge.mjs +++ b/scripts/lib/plist-merge.mjs @@ -9,6 +9,8 @@ // No new dependencies — regex-based, plist XY shape // 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 bodies — the [^<]* regex below is safe. const PLIST_KV_RE = /([^<]+)<\/key>\s*([^<]*)<\/string>/g; export function parsePlistEnv(plistContent) { diff --git a/setup.mjs b/setup.mjs index f356555..54c2fb1 100755 --- a/setup.mjs +++ b/setup.mjs @@ -65,6 +65,28 @@ const OCP_ADMIN_KEY_INJECT = process.env.OCP_ADMIN_KEY || null; // PROXY_ANONYMOUS_KEY — same pattern const PROXY_ANON_KEY_INJECT = process.env.PROXY_ANONYMOUS_KEY || null; +// ── Inject-value helpers ───────────────────────────────────────────────── +// Escape a value for safe inclusion in a plist body. +function xmlEscape(v) { + return String(v).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) ────────── const modelsConfig = JSON.parse(readFileSync(join(__dirname, "models.json"), "utf-8")); @@ -403,17 +425,17 @@ if (!DRY_RUN) { EnvironmentVariables CLAUDE_PROXY_PORT - ${PORT} + ${xmlEscape(PORT)} CLAUDE_BIND - ${BIND_ADDRESS} + ${xmlEscape(BIND_ADDRESS)} CLAUDE_AUTH_MODE - ${AUTH_MODE_CONFIG}${CLAUDE_BIN_INJECT ? ` + ${xmlEscape(AUTH_MODE_CONFIG)}${CLAUDE_BIN_INJECT ? ` CLAUDE_BIN - ${CLAUDE_BIN_INJECT}` : ""}${OCP_ADMIN_KEY_INJECT ? ` + ${xmlEscape(CLAUDE_BIN_INJECT)}` : ""}${OCP_ADMIN_KEY_INJECT ? ` OCP_ADMIN_KEY - ${OCP_ADMIN_KEY_INJECT}` : ""}${PROXY_ANON_KEY_INJECT ? ` + ${xmlEscape(OCP_ADMIN_KEY_INJECT)}` : ""}${PROXY_ANON_KEY_INJECT ? ` PROXY_ANONYMOUS_KEY - ${PROXY_ANON_KEY_INJECT}` : ""} + ${xmlEscape(PROXY_ANON_KEY_INJECT)}` : ""} RunAtLoad diff --git a/test-features.mjs b/test-features.mjs index ffc36e8..c057897 100644 --- a/test-features.mjs +++ b/test-features.mjs @@ -858,6 +858,74 @@ test("gcSnapshots keeps last N regardless of age", () => { 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, "'"); +} +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&"\''), "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&\"'"); // "a<b>&"'" + const template = ` + + + EnvironmentVariables + + CLAUDE_AUTH_MODE + ${escaped} + + +`; + // 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", () => { const root = mkdtempSync(testJoin(tmpdir(), "ocp-gc-days-")); const dotOcp = testJoin(root, ".ocp");