mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-05 19:20:22 +00:00
Exec: harden host env override handling across gateway and node (#51207)
* Exec: harden host env override enforcement and fail closed * Node host: enforce env override diagnostics before shell filtering * Env overrides: align Windows key handling and mac node rejection
This commit is contained in:
@@ -1,5 +1,10 @@
|
||||
import Foundation
|
||||
|
||||
struct HostEnvOverrideDiagnostics: Equatable {
|
||||
var blockedKeys: [String]
|
||||
var invalidKeys: [String]
|
||||
}
|
||||
|
||||
enum HostEnvSanitizer {
|
||||
/// Generated from src/infra/host-env-security-policy.json via scripts/generate-host-env-security-policy-swift.mjs.
|
||||
/// Parity is validated by src/infra/host-env-security.policy-parity.test.ts.
|
||||
@@ -41,6 +46,67 @@ enum HostEnvSanitizer {
|
||||
return filtered.isEmpty ? nil : filtered
|
||||
}
|
||||
|
||||
private static func isPortableHead(_ scalar: UnicodeScalar) -> Bool {
|
||||
let value = scalar.value
|
||||
return value == 95 || (65...90).contains(value) || (97...122).contains(value)
|
||||
}
|
||||
|
||||
private static func isPortableTail(_ scalar: UnicodeScalar) -> Bool {
|
||||
let value = scalar.value
|
||||
return self.isPortableHead(scalar) || (48...57).contains(value)
|
||||
}
|
||||
|
||||
private static func normalizeOverrideKey(_ rawKey: String) -> String? {
|
||||
let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !key.isEmpty else { return nil }
|
||||
guard let first = key.unicodeScalars.first, self.isPortableHead(first) else {
|
||||
return nil
|
||||
}
|
||||
for scalar in key.unicodeScalars.dropFirst() {
|
||||
if self.isPortableTail(scalar) || scalar == "(" || scalar == ")" {
|
||||
continue
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
private static func sortedUnique(_ values: [String]) -> [String] {
|
||||
Array(Set(values)).sorted()
|
||||
}
|
||||
|
||||
static func inspectOverrides(
|
||||
overrides: [String: String]?,
|
||||
blockPathOverrides: Bool = true) -> HostEnvOverrideDiagnostics
|
||||
{
|
||||
guard let overrides else {
|
||||
return HostEnvOverrideDiagnostics(blockedKeys: [], invalidKeys: [])
|
||||
}
|
||||
|
||||
var blocked: [String] = []
|
||||
var invalid: [String] = []
|
||||
for (rawKey, _) in overrides {
|
||||
let candidate = rawKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let normalized = self.normalizeOverrideKey(rawKey) else {
|
||||
invalid.append(candidate.isEmpty ? rawKey : candidate)
|
||||
continue
|
||||
}
|
||||
let upper = normalized.uppercased()
|
||||
if blockPathOverrides, upper == "PATH" {
|
||||
blocked.append(upper)
|
||||
continue
|
||||
}
|
||||
if self.isBlockedOverride(upper) || self.isBlocked(upper) {
|
||||
blocked.append(upper)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return HostEnvOverrideDiagnostics(
|
||||
blockedKeys: self.sortedUnique(blocked),
|
||||
invalidKeys: self.sortedUnique(invalid))
|
||||
}
|
||||
|
||||
static func sanitize(overrides: [String: String]?, shellWrapper: Bool = false) -> [String: String] {
|
||||
var merged: [String: String] = [:]
|
||||
for (rawKey, value) in ProcessInfo.processInfo.environment {
|
||||
@@ -57,8 +123,7 @@ enum HostEnvSanitizer {
|
||||
|
||||
guard let effectiveOverrides else { return merged }
|
||||
for (rawKey, value) in effectiveOverrides {
|
||||
let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !key.isEmpty else { continue }
|
||||
guard let key = self.normalizeOverrideKey(rawKey) else { continue }
|
||||
let upper = key.uppercased()
|
||||
// PATH is part of the security boundary (command resolution + safe-bin checks). Never
|
||||
// allow request-scoped PATH overrides from agents/gateways.
|
||||
|
||||
@@ -63,7 +63,23 @@ enum HostEnvSecurityPolicy {
|
||||
"OPENSSL_ENGINES",
|
||||
"PYTHONSTARTUP",
|
||||
"WGETRC",
|
||||
"CURL_HOME"
|
||||
"CURL_HOME",
|
||||
"CLASSPATH",
|
||||
"CGO_CFLAGS",
|
||||
"CGO_LDFLAGS",
|
||||
"GOFLAGS",
|
||||
"CORECLR_PROFILER_PATH",
|
||||
"PHPRC",
|
||||
"PHP_INI_SCAN_DIR",
|
||||
"DENO_DIR",
|
||||
"BUN_CONFIG_REGISTRY",
|
||||
"LUA_PATH",
|
||||
"LUA_CPATH",
|
||||
"GEM_HOME",
|
||||
"GEM_PATH",
|
||||
"BUNDLE_GEMFILE",
|
||||
"COMPOSER_HOME",
|
||||
"XDG_CONFIG_HOME"
|
||||
]
|
||||
|
||||
static let blockedOverridePrefixes: [String] = [
|
||||
|
||||
@@ -465,6 +465,23 @@ actor MacNodeRuntime {
|
||||
? params.sessionKey!.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
: self.mainSessionKey
|
||||
let runId = UUID().uuidString
|
||||
let envOverrideDiagnostics = HostEnvSanitizer.inspectOverrides(
|
||||
overrides: params.env,
|
||||
blockPathOverrides: true)
|
||||
if !envOverrideDiagnostics.blockedKeys.isEmpty || !envOverrideDiagnostics.invalidKeys.isEmpty {
|
||||
var details: [String] = []
|
||||
if !envOverrideDiagnostics.blockedKeys.isEmpty {
|
||||
details.append("blocked override keys: \(envOverrideDiagnostics.blockedKeys.joined(separator: ", "))")
|
||||
}
|
||||
if !envOverrideDiagnostics.invalidKeys.isEmpty {
|
||||
details.append(
|
||||
"invalid non-portable override keys: \(envOverrideDiagnostics.invalidKeys.joined(separator: ", "))")
|
||||
}
|
||||
return Self.errorResponse(
|
||||
req,
|
||||
code: .invalidRequest,
|
||||
message: "SYSTEM_RUN_DENIED: environment override rejected (\(details.joined(separator: "; ")))")
|
||||
}
|
||||
let evaluation = await ExecApprovalEvaluator.evaluate(
|
||||
command: command,
|
||||
rawCommand: params.rawCommand,
|
||||
|
||||
@@ -33,4 +33,24 @@ struct HostEnvSanitizerTests {
|
||||
let env = HostEnvSanitizer.sanitize(overrides: ["OPENCLAW_TOKEN": "secret"])
|
||||
#expect(env["OPENCLAW_TOKEN"] == "secret")
|
||||
}
|
||||
|
||||
@Test func `inspect overrides rejects blocked and invalid keys`() {
|
||||
let diagnostics = HostEnvSanitizer.inspectOverrides(overrides: [
|
||||
"CLASSPATH": "/tmp/evil-classpath",
|
||||
"BAD-KEY": "x",
|
||||
"ProgramFiles(x86)": "C:\\Program Files (x86)",
|
||||
])
|
||||
|
||||
#expect(diagnostics.blockedKeys == ["CLASSPATH"])
|
||||
#expect(diagnostics.invalidKeys == ["BAD-KEY"])
|
||||
}
|
||||
|
||||
@Test func `sanitize accepts Windows-style override key names`() {
|
||||
let env = HostEnvSanitizer.sanitize(overrides: [
|
||||
"ProgramFiles(x86)": "D:\\SDKs",
|
||||
"CommonProgramFiles(x86)": "D:\\Common",
|
||||
])
|
||||
#expect(env["ProgramFiles(x86)"] == "D:\\SDKs")
|
||||
#expect(env["CommonProgramFiles(x86)"] == "D:\\Common")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,32 @@ struct MacNodeRuntimeTests {
|
||||
#expect(response.ok == false)
|
||||
}
|
||||
|
||||
@Test func `handle invoke rejects blocked system run env override before execution`() async throws {
|
||||
let runtime = MacNodeRuntime()
|
||||
let params = OpenClawSystemRunParams(
|
||||
command: ["/bin/sh", "-lc", "echo ok"],
|
||||
env: ["CLASSPATH": "/tmp/evil-classpath"])
|
||||
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
|
||||
let response = await runtime.handleInvoke(
|
||||
BridgeInvokeRequest(id: "req-2c", command: OpenClawSystemCommand.run.rawValue, paramsJSON: json))
|
||||
#expect(response.ok == false)
|
||||
#expect(response.error?.message.contains("SYSTEM_RUN_DENIED: environment override rejected") == true)
|
||||
#expect(response.error?.message.contains("CLASSPATH") == true)
|
||||
}
|
||||
|
||||
@Test func `handle invoke rejects invalid system run env override key before execution`() async throws {
|
||||
let runtime = MacNodeRuntime()
|
||||
let params = OpenClawSystemRunParams(
|
||||
command: ["/bin/sh", "-lc", "echo ok"],
|
||||
env: ["BAD-KEY": "x"])
|
||||
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
|
||||
let response = await runtime.handleInvoke(
|
||||
BridgeInvokeRequest(id: "req-2d", command: OpenClawSystemCommand.run.rawValue, paramsJSON: json))
|
||||
#expect(response.ok == false)
|
||||
#expect(response.error?.message.contains("SYSTEM_RUN_DENIED: environment override rejected") == true)
|
||||
#expect(response.error?.message.contains("BAD-KEY") == true)
|
||||
}
|
||||
|
||||
@Test func `handle invoke rejects empty system which`() async throws {
|
||||
let runtime = MacNodeRuntime()
|
||||
let params = OpenClawSystemWhichParams(bins: [])
|
||||
|
||||
Reference in New Issue
Block a user