diff --git a/agents/hermes/start.sh b/agents/hermes/start.sh index 2a3db87c337..ef2a6c231d6 100755 --- a/agents/hermes/start.sh +++ b/agents/hermes/start.sh @@ -1664,6 +1664,17 @@ _nemoclaw_ca_merge_warn() { echo "[nemoclaw] WARNING: corporate proxy CA merge failed at ${1}; keeping OpenShell-only trust — external TLS through the corporate proxy may fail (#6210)" >&2 } merge_corporate_proxy_ca() { + # Trust-anchor tampering (#8650): replacing the baked corporate CA file with a + # symlink makes the merge below read the link target instead, adding + # attacker-selected bytes to the trust bundle that curl, python, git, and node + # verify against. The image bakes this path as a root-owned 0444 regular file, + # so a symlink here is never a legitimate state. This is not the recoverable + # "merge failed" case below, which safely keeps OpenShell-only trust, so it + # fails closed instead of warning. + if [ -L "$_NEMOCLAW_CORPORATE_CA_FILE" ]; then + echo "[nemoclaw] refusing symlinked corporate CA at ${_NEMOCLAW_CORPORATE_CA_FILE}; expected a regular file (#8650)" >&2 + exit 1 + fi [ -s "$_NEMOCLAW_CORPORATE_CA_FILE" ] || return 0 _base_bundle="" if [ -n "${SSL_CERT_FILE:-}" ] && [ -f "${SSL_CERT_FILE}" ]; then @@ -1703,11 +1714,46 @@ merge_corporate_proxy_ca() { return 0 } fi - cat "$_NEMOCLAW_CORPORATE_CA_FILE" >>"$_tmp" 2>/dev/null || { + # Append through a descriptor opened with O_NOFOLLOW and verified as a regular + # file (#8650). The check above rejects a planted symlink; this rejects one + # swapped in afterwards, because the type check and the read share one + # descriptor and no path is resolved twice. Status 2 means the source was + # rejected as a trust anchor; any other non-zero status is an ordinary read + # failure that keeps the existing warn-and-continue behavior. + _ca_append_status=0 + python3 -I - "$_NEMOCLAW_CORPORATE_CA_FILE" "$_tmp" <<'PY_APPEND_CORPORATE_CA' || _ca_append_status=$? +import errno +import os +import stat +import sys + +source, target = sys.argv[1], sys.argv[2] +try: + descriptor = os.open(source, os.O_RDONLY | os.O_NOFOLLOW) +except OSError as error: + raise SystemExit(2 if error.errno == errno.ELOOP else 3) +try: + if not stat.S_ISREG(os.fstat(descriptor).st_mode): + raise SystemExit(2) + with open(target, "ab") as merged: + while True: + chunk = os.read(descriptor, 65536) + if not chunk: + break + merged.write(chunk) +finally: + os.close(descriptor) +PY_APPEND_CORPORATE_CA + if [ "$_ca_append_status" -eq 2 ]; then + rm -f "$_tmp" + echo "[nemoclaw] refusing corporate CA at ${_NEMOCLAW_CORPORATE_CA_FILE}; expected a regular file, not a symlink (#8650)" >&2 + exit 1 + fi + if [ "$_ca_append_status" -ne 0 ]; then rm -f "$_tmp" _nemoclaw_ca_merge_warn "append corporate CA" return 0 - } + fi chmod 0444 "$_tmp" 2>/dev/null || { rm -f "$_tmp" _nemoclaw_ca_merge_warn "set merged bundle permissions (${_merged})" diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 0af62bac428..65deac8b31f 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -3188,6 +3188,17 @@ _nemoclaw_ca_merge_warn() { echo "[nemoclaw] WARNING: corporate proxy CA merge failed at ${1}; keeping OpenShell-only trust — external TLS through the corporate proxy may fail (#6210)" >&2 } merge_corporate_proxy_ca() { + # Trust-anchor tampering (#8650): replacing the baked corporate CA file with a + # symlink makes the merge below read the link target instead, adding + # attacker-selected bytes to the trust bundle that curl, python, git, and node + # verify against. The image bakes this path as a root-owned 0444 regular file, + # so a symlink here is never a legitimate state. This is not the recoverable + # "merge failed" case below, which safely keeps OpenShell-only trust, so it + # fails closed instead of warning. + if [ -L "$_NEMOCLAW_CORPORATE_CA_FILE" ]; then + echo "[nemoclaw] refusing symlinked corporate CA at ${_NEMOCLAW_CORPORATE_CA_FILE}; expected a regular file (#8650)" >&2 + exit 1 + fi [ -s "$_NEMOCLAW_CORPORATE_CA_FILE" ] || return 0 _base_bundle="" if [ -n "${SSL_CERT_FILE:-}" ] && [ -f "${SSL_CERT_FILE}" ]; then @@ -3227,11 +3238,46 @@ merge_corporate_proxy_ca() { return 0 } fi - cat "$_NEMOCLAW_CORPORATE_CA_FILE" >>"$_tmp" 2>/dev/null || { + # Append through a descriptor opened with O_NOFOLLOW and verified as a regular + # file (#8650). The check above rejects a planted symlink; this rejects one + # swapped in afterwards, because the type check and the read share one + # descriptor and no path is resolved twice. Status 2 means the source was + # rejected as a trust anchor; any other non-zero status is an ordinary read + # failure that keeps the existing warn-and-continue behavior. + _ca_append_status=0 + python3 -I - "$_NEMOCLAW_CORPORATE_CA_FILE" "$_tmp" <<'PY_APPEND_CORPORATE_CA' || _ca_append_status=$? +import errno +import os +import stat +import sys + +source, target = sys.argv[1], sys.argv[2] +try: + descriptor = os.open(source, os.O_RDONLY | os.O_NOFOLLOW) +except OSError as error: + raise SystemExit(2 if error.errno == errno.ELOOP else 3) +try: + if not stat.S_ISREG(os.fstat(descriptor).st_mode): + raise SystemExit(2) + with open(target, "ab") as merged: + while True: + chunk = os.read(descriptor, 65536) + if not chunk: + break + merged.write(chunk) +finally: + os.close(descriptor) +PY_APPEND_CORPORATE_CA + if [ "$_ca_append_status" -eq 2 ]; then + rm -f "$_tmp" + echo "[nemoclaw] refusing corporate CA at ${_NEMOCLAW_CORPORATE_CA_FILE}; expected a regular file, not a symlink (#8650)" >&2 + exit 1 + fi + if [ "$_ca_append_status" -ne 0 ]; then rm -f "$_tmp" _nemoclaw_ca_merge_warn "append corporate CA" return 0 - } + fi chmod 0444 "$_tmp" 2>/dev/null || { rm -f "$_tmp" _nemoclaw_ca_merge_warn "set merged bundle permissions (${_merged})" diff --git a/test/corporate-ca-runtime-merge.test.ts b/test/corporate-ca-runtime-merge.test.ts index f8b60d0445d..5fb89100458 100644 --- a/test/corporate-ca-runtime-merge.test.ts +++ b/test/corporate-ca-runtime-merge.test.ts @@ -5,7 +5,7 @@ // entrypoints. Exercises the actual shell blocks extracted from // scripts/nemoclaw-start.sh and agents/hermes/start.sh, not a re-implementation. -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; @@ -39,6 +39,85 @@ function mergeBlock(scriptPath: string, endMarker: string, corpCa: string, merge .replaceAll("/tmp/nemoclaw-ca-bundle.pem", merged); } +const OPENCLAW_END = "# Git TLS CA bundle fix (NemoClaw#2270)."; +const HERMES_END = "# OpenShell injects SSL_CERT_FILE/CURL_CA_BUNDLE for its L7 proxy CA."; + +/** + * Run a merge block whose stderr is captured to a file, and return that + * diagnostic. The block exits non-zero on a rejected trust anchor, so the + * caller asserts the throw and then reads the diagnostic this leaves behind. + */ +function mergeDiagnostic(dir: string, block: string, lines: string[] = []): string { + const errFile = join(dir, "merge-stderr.txt"); + expect(() => + runShellLines(dir, [`exec 2>${JSON.stringify(errFile)}`, ...lines, block]), + ).toThrow(); + return readFileSync(errFile, "utf-8"); +} + +describe("corporate proxy CA trust-anchor rejection (#8650)", () => { + it.each([ + { agent: "OpenClaw", script: OPENCLAW_START, end: OPENCLAW_END }, + { agent: "Hermes", script: HERMES_START, end: HERMES_END }, + ])("rejects a symlinked corporate CA before reading it for $agent (#8650)", ({ script, end }) => { + const dir = tmpDir("nemoclaw-corp-symlink-"); + const openshell = join(dir, "openshell-ca.pem"); + const corp = join(dir, "corporate-ca.pem"); + const merged = join(dir, "merged-ca.pem"); + const planted = join(dir, "not-a-certificate"); + writeFileSync(openshell, OPENSHELL_PEM); + // The baked path is a root-owned 0444 regular file in the image, so a + // symlink here is tampering. Point it at content that is not a certificate. + writeFileSync(planted, "PLANTED-NOT-A-CERTIFICATE\n"); + symlinkSync(planted, corp); + + const diagnostic = mergeDiagnostic(dir, mergeBlock(script, end, corp, merged), [ + `export SSL_CERT_FILE=${JSON.stringify(openshell)}`, + ]); + + expect(diagnostic).toContain("corporate CA"); + expect(diagnostic).not.toContain("PLANTED-NOT-A-CERTIFICATE"); + expect(existsSync(merged)).toBe(false); + }); + + it.each([ + { agent: "OpenClaw", script: OPENCLAW_START, end: OPENCLAW_END }, + { agent: "Hermes", script: HERMES_START, end: HERMES_END }, + ])("rejects a corporate CA swapped to a symlink after the path check for $agent (#8650)", ({ + script, + end, + }) => { + const dir = tmpDir("nemoclaw-corp-swap-"); + const openshell = join(dir, "openshell-ca.pem"); + const corp = join(dir, "corporate-ca.pem"); + const merged = join(dir, "merged-ca.pem"); + const planted = join(dir, "not-a-certificate"); + writeFileSync(openshell, OPENSHELL_PEM); + writeFileSync(planted, "PLANTED-NOT-A-CERTIFICATE\n"); + writeFileSync(corp, CORPORATE_PEM); + + // Stand in for a process that replaces the path between the `-L` check and + // the read. Swapping the file for a symlink right after the check means + // only the O_NOFOLLOW read can still reject it. + const swap = [ + `rm -f ${JSON.stringify(corp)}`, + `ln -s ${JSON.stringify(planted)} ${JSON.stringify(corp)}`, + ].join("\n"); + const raced = mergeBlock(script, end, corp, merged).replace( + `[ -s "$_NEMOCLAW_CORPORATE_CA_FILE" ] || return 0`, + `[ -s "$_NEMOCLAW_CORPORATE_CA_FILE" ] || return 0\n${swap}`, + ); + + const diagnostic = mergeDiagnostic(dir, raced, [ + `export SSL_CERT_FILE=${JSON.stringify(openshell)}`, + ]); + + expect(diagnostic).toContain("corporate CA"); + expect(diagnostic).not.toContain("PLANTED-NOT-A-CERTIFICATE"); + expect(existsSync(merged)).toBe(false); + }); +}); + describe("corporate proxy CA runtime merge (#6210)", () => { it("appends the corporate CA to the OpenShell bundle for OpenClaw and repoints all CA env (#6210)", () => { const dir = tmpDir("nemoclaw-corp-merge-openclaw-");