fix: harden macos shell continuation parsing
This commit is contained in:
parent
cd72fa6e77
commit
6b49a604b4
@ -34,6 +34,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Security/exec approvals: fail closed for Perl `-M` and `-I` approval flows so preload and load-path module resolution stays outside approval-backed runtime execution unless the operator uses a broader explicit trust path.
|
||||
- Security/exec approvals: recognize PowerShell `-File` and `-f` wrapper forms during inline-command extraction so approval and command-analysis paths treat file-based PowerShell launches like the existing `-Command` variants.
|
||||
- Security/exec approvals: unwrap `env` dispatch wrappers inside shell-segment allowlist resolution on macOS so `env FOO=bar /path/to/bin` resolves against the effective executable instead of the wrapper token.
|
||||
- Security/exec approvals: treat backslash-newline as shell line continuation during macOS shell-chain parsing so line-continued `$(` substitutions fail closed instead of slipping past command-substitution checks.
|
||||
- Security/external content: strip zero-width and soft-hyphen marker-splitting characters during boundary sanitization so spoofed `EXTERNAL_UNTRUSTED_CONTENT` markers fall back to the existing hardening path instead of bypassing marker normalization.
|
||||
- Control UI/insecure auth: preserve explicit shared token and password auth on plain-HTTP Control UI connects so LAN and reverse-proxy sessions no longer drop shared auth before the first WebSocket handshake. (#45088) Thanks @velvet-shark.
|
||||
- macOS/onboarding: avoid self-restarting freshly bootstrapped launchd gateways and give new daemon installs longer to become healthy, so `openclaw onboard --install-daemon` no longer false-fails on slower Macs and fresh VM snapshots.
|
||||
|
||||
@ -214,8 +214,14 @@ struct ExecCommandResolution {
|
||||
while idx < chars.count {
|
||||
let ch = chars[idx]
|
||||
let next: Character? = idx + 1 < chars.count ? chars[idx + 1] : nil
|
||||
let lookahead = self.nextShellSignificantCharacter(chars: chars, after: idx, inSingle: inSingle)
|
||||
|
||||
if escaped {
|
||||
if ch == "\n" {
|
||||
escaped = false
|
||||
idx += 1
|
||||
continue
|
||||
}
|
||||
current.append(ch)
|
||||
escaped = false
|
||||
idx += 1
|
||||
@ -223,6 +229,10 @@ struct ExecCommandResolution {
|
||||
}
|
||||
|
||||
if ch == "\\", !inSingle {
|
||||
if next == "\n" {
|
||||
idx += 2
|
||||
continue
|
||||
}
|
||||
current.append(ch)
|
||||
escaped = true
|
||||
idx += 1
|
||||
@ -243,7 +253,7 @@ struct ExecCommandResolution {
|
||||
continue
|
||||
}
|
||||
|
||||
if !inSingle, self.shouldFailClosedForShell(ch: ch, next: next, inDouble: inDouble) {
|
||||
if !inSingle, self.shouldFailClosedForShell(ch: ch, next: lookahead, inDouble: inDouble) {
|
||||
// Fail closed on command/process substitution in allowlist mode,
|
||||
// including command substitution inside double-quoted shell strings.
|
||||
return nil
|
||||
@ -267,6 +277,25 @@ struct ExecCommandResolution {
|
||||
return segments
|
||||
}
|
||||
|
||||
private static func nextShellSignificantCharacter(
|
||||
chars: [Character],
|
||||
after idx: Int,
|
||||
inSingle: Bool) -> Character?
|
||||
{
|
||||
guard !inSingle else {
|
||||
return idx + 1 < chars.count ? chars[idx + 1] : nil
|
||||
}
|
||||
var cursor = idx + 1
|
||||
while cursor < chars.count {
|
||||
if chars[cursor] == "\\", cursor + 1 < chars.count, chars[cursor + 1] == "\n" {
|
||||
cursor += 2
|
||||
continue
|
||||
}
|
||||
return chars[cursor]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func shouldFailClosedForShell(ch: Character, next: Character?, inDouble: Bool) -> Bool {
|
||||
let context: ShellTokenContext = inDouble ? .doubleQuoted : .unquoted
|
||||
guard let rules = self.shellFailClosedRules[context] else {
|
||||
|
||||
@ -141,6 +141,26 @@ struct ExecAllowlistTests {
|
||||
#expect(resolutions.isEmpty)
|
||||
}
|
||||
|
||||
@Test func `resolve for allowlist fails closed on line-continued command substitution`() {
|
||||
let command = ["/bin/sh", "-lc", "echo $\\\n(/usr/bin/touch /tmp/openclaw-allowlist-test-line-cont-subst)"]
|
||||
let resolutions = ExecCommandResolution.resolveForAllowlist(
|
||||
command: command,
|
||||
rawCommand: "echo $\\\n(/usr/bin/touch /tmp/openclaw-allowlist-test-line-cont-subst)",
|
||||
cwd: nil,
|
||||
env: ["PATH": "/usr/bin:/bin"])
|
||||
#expect(resolutions.isEmpty)
|
||||
}
|
||||
|
||||
@Test func `resolve for allowlist fails closed on chained line-continued command substitution`() {
|
||||
let command = ["/bin/sh", "-lc", "echo ok && $\\\n(/usr/bin/touch /tmp/openclaw-allowlist-test-chained-line-cont-subst)"]
|
||||
let resolutions = ExecCommandResolution.resolveForAllowlist(
|
||||
command: command,
|
||||
rawCommand: "echo ok && $\\\n(/usr/bin/touch /tmp/openclaw-allowlist-test-chained-line-cont-subst)",
|
||||
cwd: nil,
|
||||
env: ["PATH": "/usr/bin:/bin"])
|
||||
#expect(resolutions.isEmpty)
|
||||
}
|
||||
|
||||
@Test func `resolve for allowlist fails closed on quoted backticks`() {
|
||||
let command = ["/bin/sh", "-lc", "echo \"ok `/usr/bin/id`\""]
|
||||
let resolutions = ExecCommandResolution.resolveForAllowlist(
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user