diff --git a/CHANGELOG.md b/CHANGELOG.md index a293a5a96d6..e2714302913 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ Docs: https://docs.openclaw.ai - CLI/channels: keep `status`, `health`, `channels list`, and `channels status` on read-only channel metadata when Telegram, Slack, Discord, or third-party channel plugins are configured, avoiding full bundled plugin runtime imports on those cold paths. Fixes #69042. (#69479) Thanks @gumadeiras. - Synology Chat: validate outbound webhook `file_url` values against the shared SSRF policy before forwarding to the NAS, rejecting malformed URLs, non-`http(s)` schemes, and private/blocked network targets so the NAS cannot be used as a confused deputy to fetch internal addresses. (#69784) Thanks @eleqtrizit. - Gateway/Control UI: require gateway auth on the Control UI avatar route (`GET /avatar/` and `?meta=1` metadata) when auth is configured, matching the sibling assistant-media route, and propagate the existing gateway token through the UI avatar fetch (bearer header + authenticated blob URL) so authenticated dashboards still load local avatars. (#69775) +- Exec/allowlist: reject POSIX parameter expansion forms such as `$VAR`, `$?`, `$$`, `$1`, and `$@` inside unquoted heredocs during shell approval analysis, so these heredocs no longer pass allowlist review as plain text. (#69795) Thanks @drobison00. ## 2026.4.20 diff --git a/src/infra/exec-approvals-analysis.test.ts b/src/infra/exec-approvals-analysis.test.ts index a4eb51b3c42..e823b9400c8 100644 --- a/src/infra/exec-approvals-analysis.test.ts +++ b/src/infra/exec-approvals-analysis.test.ts @@ -362,6 +362,10 @@ describe("exec approvals shell analysis", () => { command: "/usr/bin/cat < { const res = expectAnalyzedShellCommand(command); expect(res.segments.map((segment) => segment.argv[0])).toEqual(expectedArgv); @@ -370,20 +374,66 @@ describe("exec approvals shell analysis", () => { it.each([ { command: "/usr/bin/cat < { @@ -392,6 +442,35 @@ describe("exec approvals shell analysis", () => { expect(res.reason).toBe(reason); }); + it("splices a delimiter-matching line into a pending continuation instead of terminating the heredoc", () => { + // Bash treats the `EOF` after `safe\` as continued body content + // (producing `safeEOF`) rather than as the delimiter, then keeps reading + // until the real delimiter on line 4. No expansion is present, so the + // analyzer must accept the command and mirror the runtime semantics. + const res = analyzeShellCommand({ + command: "/usr/bin/cat < segment.argv[0])).toEqual(["/usr/bin/cat"]); + }); + + it("rejects oversized unquoted heredoc logical lines", () => { + const res = analyzeShellCommand({ + command: `/usr/bin/cat < { + const continuedLines = "\\\n".repeat(1025); + const res = analyzeShellCommand({ + command: `/usr/bin/cat < { const res = analyzeShellCommand({ command: '"C:\\Program Files\\Tool\\tool.exe" --version', diff --git a/src/infra/exec-approvals-analysis.ts b/src/infra/exec-approvals-analysis.ts index c6fdec71b09..8a76f06b24e 100644 --- a/src/infra/exec-approvals-analysis.ts +++ b/src/infra/exec-approvals-analysis.ts @@ -44,6 +44,8 @@ export type ShellChainPart = { const DISALLOWED_PIPELINE_TOKENS = new Set([">", "<", "`", "\n", "\r", "(", ")"]); const DOUBLE_QUOTE_ESCAPES = new Set(["\\", '"', "$", "`"]); +const MAX_UNQUOTED_HEREDOC_CONTINUATION_LINES = 1024; +const MAX_UNQUOTED_HEREDOC_LOGICAL_LINE_LENGTH = 64 * 1024; const WINDOWS_UNSUPPORTED_TOKENS = new Set([ "&", "|", @@ -145,6 +147,8 @@ function splitShellPipeline(command: string): { ok: boolean; reason?: string; se const pendingHeredocs: HeredocSpec[] = []; let inHeredocBody = false; let heredocLine = ""; + let unquotedHeredocLogicalChunks: string[] = []; + let unquotedHeredocLogicalLength = 0; const pushPart = () => { const trimmed = buf.trim(); @@ -170,7 +174,13 @@ function splitShellPipeline(command: string): { ok: boolean; reason?: string; se } if (ch === "$" && !isEscapedInHeredocLine(line, i)) { const next = line[i + 1]; - if (next === "(" || next === "{") { + if ( + next === "(" || + next === "{" || + next === "[" || + (next !== undefined && + (/^[A-Za-z_]$/.test(next) || /^[0-9]$/.test(next) || "@*?!$#-".includes(next))) + ) { return true; } } @@ -178,6 +188,19 @@ function splitShellPipeline(command: string): { ok: boolean; reason?: string; se return false; }; + const stripUnquotedHeredocLineContinuation = ( + line: string, + ): { line: string; continues: boolean } => { + let trailingSlashes = 0; + for (let i = line.length - 1; i >= 0 && line[i] === "\\"; i -= 1) { + trailingSlashes += 1; + } + if (trailingSlashes % 2 === 1) { + return { line: line.slice(0, -1), continues: true }; + } + return { line, continues: false }; + }; + for (let i = 0; i < command.length; i += 1) { const ch = command[i]; const next = command[i + 1]; @@ -187,10 +210,48 @@ function splitShellPipeline(command: string): { ok: boolean; reason?: string; se const current = pendingHeredocs[0]; if (current) { const line = current.stripTabs ? heredocLine.replace(/^\t+/, "") : heredocLine; - if (line === current.delimiter) { - pendingHeredocs.shift(); - } else if (!current.quoted && hasUnquotedHeredocExpansionToken(heredocLine)) { - return { ok: false, reason: "command substitution in unquoted heredoc", segments: [] }; + if (current.quoted) { + if (line === current.delimiter) { + pendingHeredocs.shift(); + } + } else { + // An unquoted heredoc body whose previous physical line ended with + // `\` is spliced into the next line at runtime. In that + // case bash does not treat the next physical line as the delimiter, + // even if it matches literally — the splice wins and the body + // continues. Only recognize the delimiter when no continuation is + // pending. + if (line === current.delimiter && unquotedHeredocLogicalChunks.length === 0) { + pendingHeredocs.shift(); + } else { + const continued = stripUnquotedHeredocLineContinuation(line); + unquotedHeredocLogicalChunks.push(continued.line); + if ( + unquotedHeredocLogicalChunks.length > + MAX_UNQUOTED_HEREDOC_CONTINUATION_LINES + ) { + return { + ok: false, + reason: "heredoc continuation too long", + segments: [], + }; + } + unquotedHeredocLogicalLength += continued.line.length; + if (unquotedHeredocLogicalLength > MAX_UNQUOTED_HEREDOC_LOGICAL_LINE_LENGTH) { + return { + ok: false, + reason: "heredoc logical line too large", + segments: [], + }; + } + if (!continued.continues) { + if (hasUnquotedHeredocExpansionToken(unquotedHeredocLogicalChunks.join(""))) { + return { ok: false, reason: "shell expansion in unquoted heredoc", segments: [] }; + } + unquotedHeredocLogicalChunks = []; + unquotedHeredocLogicalLength = 0; + } + } } } heredocLine = ""; @@ -326,8 +387,24 @@ function splitShellPipeline(command: string): { ok: boolean; reason?: string; se if (inHeredocBody && pendingHeredocs.length > 0) { const current = pendingHeredocs[0]; const line = current.stripTabs ? heredocLine.replace(/^\t+/, "") : heredocLine; - if (line === current.delimiter) { + // Mirror the in-loop guard: a pending unquoted continuation splices into + // the trailing line and prevents the delimiter from terminating the + // heredoc, so only accept the tail as a delimiter when no continuation + // chunks are pending. If a continuation is pending, splice the tail into + // the buffered logical line and run the expansion check against what bash + // would actually expand at runtime, so payloads like + // `cat < 0; + if (pendingContinuation) { + const continued = stripUnquotedHeredocLineContinuation(line); + const logical = [...unquotedHeredocLogicalChunks, continued.line].join(""); + if (hasUnquotedHeredocExpansionToken(logical)) { + return { ok: false, reason: "shell expansion in unquoted heredoc", segments: [] }; + } + } else if (line === current.delimiter) { pendingHeredocs.shift(); + unquotedHeredocLogicalChunks = []; + unquotedHeredocLogicalLength = 0; if (pendingHeredocs.length === 0) { inHeredocBody = false; }