mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-19 18:39:20 -05:00
fix(exec): allow heredoc operator (<<) in allowlist security mode (#13811)
* fix(exec): allow heredoc operator (<<) in allowlist security mode * fix: allow multiline heredoc parsing in exec approvals (#13811) (thanks @mcaxtr) --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
@@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Onboarding/CLI: restore terminal state without resuming paused `stdin`, so onboarding exits cleanly after choosing Web UI and the installer returns instead of appearing stuck.
|
||||
- macOS Voice Wake: fix a crash in trigger trimming for CJK/Unicode transcripts by matching and slicing on original-string ranges instead of transformed-string indices. (#11052) Thanks @Flash-LHR.
|
||||
- Heartbeat: prevent scheduler silent-death races during runner reloads, preserve retry cooldown backoff under wake bursts, and prioritize user/action wake causes over interval/retry reasons when coalescing. (#15108) Thanks @joeykrug.
|
||||
- Exec/Allowlist: allow multiline heredoc bodies (`<<`, `<<-`) while keeping multiline non-heredoc shell commands blocked, so exec approval parsing permits heredoc input safely without allowing general newline command chaining. (#13811) Thanks @mcaxtr.
|
||||
|
||||
## 2026.2.12
|
||||
|
||||
|
||||
@@ -164,6 +164,68 @@ describe("exec approvals shell parsing", () => {
|
||||
expect(res.segments[0]?.argv[0]).toBe("echo");
|
||||
});
|
||||
|
||||
it("rejects input redirection (<)", () => {
|
||||
const res = analyzeShellCommand({ command: "cat < input.txt" });
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.reason).toBe("unsupported shell token: <");
|
||||
});
|
||||
|
||||
it("rejects output redirection (>)", () => {
|
||||
const res = analyzeShellCommand({ command: "echo ok > output.txt" });
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.reason).toBe("unsupported shell token: >");
|
||||
});
|
||||
|
||||
it("allows heredoc operator (<<)", () => {
|
||||
const res = analyzeShellCommand({ command: "/usr/bin/tee /tmp/file << 'EOF'" });
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.segments[0]?.argv[0]).toBe("/usr/bin/tee");
|
||||
});
|
||||
|
||||
it("allows heredoc without space before delimiter", () => {
|
||||
const res = analyzeShellCommand({ command: "/usr/bin/tee /tmp/file <<EOF" });
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.segments[0]?.argv[0]).toBe("/usr/bin/tee");
|
||||
});
|
||||
|
||||
it("allows heredoc with strip-tabs operator (<<-)", () => {
|
||||
const res = analyzeShellCommand({ command: "/usr/bin/cat <<-DELIM" });
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.segments[0]?.argv[0]).toBe("/usr/bin/cat");
|
||||
});
|
||||
|
||||
it("allows heredoc in pipeline", () => {
|
||||
const res = analyzeShellCommand({ command: "/usr/bin/cat << 'EOF' | /usr/bin/grep pattern" });
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.segments).toHaveLength(2);
|
||||
expect(res.segments[0]?.argv[0]).toBe("/usr/bin/cat");
|
||||
expect(res.segments[1]?.argv[0]).toBe("/usr/bin/grep");
|
||||
});
|
||||
|
||||
it("allows multiline heredoc body", () => {
|
||||
const res = analyzeShellCommand({
|
||||
command: "/usr/bin/tee /tmp/file << 'EOF'\nline one\nline two\nEOF",
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.segments[0]?.argv[0]).toBe("/usr/bin/tee");
|
||||
});
|
||||
|
||||
it("allows multiline heredoc body with strip-tabs operator (<<-)", () => {
|
||||
const res = analyzeShellCommand({
|
||||
command: "/usr/bin/cat <<-EOF\n\tline one\n\tline two\n\tEOF",
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.segments[0]?.argv[0]).toBe("/usr/bin/cat");
|
||||
});
|
||||
|
||||
it("rejects multiline commands without heredoc", () => {
|
||||
const res = analyzeShellCommand({
|
||||
command: "/usr/bin/echo first line\n/usr/bin/echo second line",
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.reason).toBe("unsupported shell token: \n");
|
||||
});
|
||||
|
||||
it("rejects windows shell metacharacters", () => {
|
||||
const res = analyzeShellCommand({
|
||||
command: "ping 127.0.0.1 -n 1 & whoami",
|
||||
|
||||
@@ -636,31 +636,77 @@ function isDoubleQuoteEscape(next: string | undefined): next is string {
|
||||
return Boolean(next && DOUBLE_QUOTE_ESCAPES.has(next));
|
||||
}
|
||||
|
||||
type IteratorAction = "split" | "skip" | "include" | { reject: string };
|
||||
function splitShellPipeline(command: string): { ok: boolean; reason?: string; segments: string[] } {
|
||||
type HeredocSpec = {
|
||||
delimiter: string;
|
||||
stripTabs: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Iterates through a command string while respecting shell quoting rules.
|
||||
* The callback receives each character and the next character, and returns an action:
|
||||
* - "split": push current buffer as a segment and start a new one
|
||||
* - "skip": skip this character (and optionally the next via skip count)
|
||||
* - "include": add this character to the buffer
|
||||
* - { reject: reason }: abort with an error
|
||||
*/
|
||||
function iterateQuoteAware(
|
||||
command: string,
|
||||
onChar: (ch: string, next: string | undefined, index: number) => IteratorAction,
|
||||
): { ok: true; parts: string[]; hasSplit: boolean } | { ok: false; reason: string } {
|
||||
const parts: string[] = [];
|
||||
const parseHeredocDelimiter = (
|
||||
source: string,
|
||||
start: number,
|
||||
): { delimiter: string; end: number } | null => {
|
||||
let i = start;
|
||||
while (i < source.length && (source[i] === " " || source[i] === "\t")) {
|
||||
i += 1;
|
||||
}
|
||||
if (i >= source.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const first = source[i];
|
||||
if (first === "'" || first === '"') {
|
||||
const quote = first;
|
||||
i += 1;
|
||||
let delimiter = "";
|
||||
while (i < source.length) {
|
||||
const ch = source[i];
|
||||
if (ch === "\n" || ch === "\r") {
|
||||
return null;
|
||||
}
|
||||
if (quote === '"' && ch === "\\" && i + 1 < source.length) {
|
||||
delimiter += source[i + 1];
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (ch === quote) {
|
||||
return { delimiter, end: i + 1 };
|
||||
}
|
||||
delimiter += ch;
|
||||
i += 1;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
let delimiter = "";
|
||||
while (i < source.length) {
|
||||
const ch = source[i];
|
||||
if (/\s/.test(ch) || ch === "|" || ch === "&" || ch === ";" || ch === "<" || ch === ">") {
|
||||
break;
|
||||
}
|
||||
delimiter += ch;
|
||||
i += 1;
|
||||
}
|
||||
if (!delimiter) {
|
||||
return null;
|
||||
}
|
||||
return { delimiter, end: i };
|
||||
};
|
||||
|
||||
const segments: string[] = [];
|
||||
let buf = "";
|
||||
let inSingle = false;
|
||||
let inDouble = false;
|
||||
let escaped = false;
|
||||
let hasSplit = false;
|
||||
let emptySegment = false;
|
||||
const pendingHeredocs: HeredocSpec[] = [];
|
||||
let inHeredocBody = false;
|
||||
let heredocLine = "";
|
||||
|
||||
const pushPart = () => {
|
||||
const trimmed = buf.trim();
|
||||
if (trimmed) {
|
||||
parts.push(trimmed);
|
||||
segments.push(trimmed);
|
||||
}
|
||||
buf = "";
|
||||
};
|
||||
@@ -669,14 +715,38 @@ function iterateQuoteAware(
|
||||
const ch = command[i];
|
||||
const next = command[i + 1];
|
||||
|
||||
if (inHeredocBody) {
|
||||
if (ch === "\n" || ch === "\r") {
|
||||
const current = pendingHeredocs[0];
|
||||
if (current) {
|
||||
const line = current.stripTabs ? heredocLine.replace(/^\t+/, "") : heredocLine;
|
||||
if (line === current.delimiter) {
|
||||
pendingHeredocs.shift();
|
||||
}
|
||||
}
|
||||
heredocLine = "";
|
||||
if (pendingHeredocs.length === 0) {
|
||||
inHeredocBody = false;
|
||||
}
|
||||
if (ch === "\r" && next === "\n") {
|
||||
i += 1;
|
||||
}
|
||||
} else {
|
||||
heredocLine += ch;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (escaped) {
|
||||
buf += ch;
|
||||
escaped = false;
|
||||
emptySegment = false;
|
||||
continue;
|
||||
}
|
||||
if (!inSingle && !inDouble && ch === "\\") {
|
||||
escaped = true;
|
||||
buf += ch;
|
||||
emptySegment = false;
|
||||
continue;
|
||||
}
|
||||
if (inSingle) {
|
||||
@@ -684,6 +754,7 @@ function iterateQuoteAware(
|
||||
inSingle = false;
|
||||
}
|
||||
buf += ch;
|
||||
emptySegment = false;
|
||||
continue;
|
||||
}
|
||||
if (inDouble) {
|
||||
@@ -691,93 +762,113 @@ function iterateQuoteAware(
|
||||
buf += ch;
|
||||
buf += next;
|
||||
i += 1;
|
||||
emptySegment = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === "$" && next === "(") {
|
||||
return { ok: false, reason: "unsupported shell token: $()" };
|
||||
return { ok: false, reason: "unsupported shell token: $()", segments: [] };
|
||||
}
|
||||
if (ch === "`") {
|
||||
return { ok: false, reason: "unsupported shell token: `" };
|
||||
return { ok: false, reason: "unsupported shell token: `", segments: [] };
|
||||
}
|
||||
if (ch === "\n" || ch === "\r") {
|
||||
return { ok: false, reason: "unsupported shell token: newline" };
|
||||
return { ok: false, reason: "unsupported shell token: newline", segments: [] };
|
||||
}
|
||||
if (ch === '"') {
|
||||
inDouble = false;
|
||||
}
|
||||
buf += ch;
|
||||
emptySegment = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === "'") {
|
||||
inSingle = true;
|
||||
buf += ch;
|
||||
emptySegment = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') {
|
||||
inDouble = true;
|
||||
buf += ch;
|
||||
emptySegment = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
const action = onChar(ch, next, i);
|
||||
if (typeof action === "object" && "reject" in action) {
|
||||
return { ok: false, reason: action.reject };
|
||||
}
|
||||
if (action === "split") {
|
||||
pushPart();
|
||||
hasSplit = true;
|
||||
if ((ch === "\n" || ch === "\r") && pendingHeredocs.length > 0) {
|
||||
inHeredocBody = true;
|
||||
heredocLine = "";
|
||||
if (ch === "\r" && next === "\n") {
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (action === "skip") {
|
||||
continue;
|
||||
}
|
||||
buf += ch;
|
||||
}
|
||||
|
||||
if (escaped || inSingle || inDouble) {
|
||||
return { ok: false, reason: "unterminated shell quote/escape" };
|
||||
}
|
||||
pushPart();
|
||||
return { ok: true, parts, hasSplit };
|
||||
}
|
||||
|
||||
function splitShellPipeline(command: string): { ok: boolean; reason?: string; segments: string[] } {
|
||||
let emptySegment = false;
|
||||
const result = iterateQuoteAware(command, (ch, next) => {
|
||||
if (ch === "|" && next === "|") {
|
||||
return { reject: "unsupported shell token: ||" };
|
||||
return { ok: false, reason: "unsupported shell token: ||", segments: [] };
|
||||
}
|
||||
if (ch === "|" && next === "&") {
|
||||
return { reject: "unsupported shell token: |&" };
|
||||
return { ok: false, reason: "unsupported shell token: |&", segments: [] };
|
||||
}
|
||||
if (ch === "|") {
|
||||
emptySegment = true;
|
||||
return "split";
|
||||
pushPart();
|
||||
continue;
|
||||
}
|
||||
if (ch === "&" || ch === ";") {
|
||||
return { reject: `unsupported shell token: ${ch}` };
|
||||
return { ok: false, reason: `unsupported shell token: ${ch}`, segments: [] };
|
||||
}
|
||||
if (ch === "<" && next === "<") {
|
||||
buf += "<<";
|
||||
emptySegment = false;
|
||||
i += 1;
|
||||
|
||||
let scanIndex = i + 1;
|
||||
let stripTabs = false;
|
||||
if (command[scanIndex] === "-") {
|
||||
stripTabs = true;
|
||||
buf += "-";
|
||||
scanIndex += 1;
|
||||
}
|
||||
|
||||
const parsed = parseHeredocDelimiter(command, scanIndex);
|
||||
if (parsed) {
|
||||
pendingHeredocs.push({ delimiter: parsed.delimiter, stripTabs });
|
||||
buf += command.slice(scanIndex, parsed.end);
|
||||
i = parsed.end - 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (DISALLOWED_PIPELINE_TOKENS.has(ch)) {
|
||||
return { reject: `unsupported shell token: ${ch}` };
|
||||
return { ok: false, reason: `unsupported shell token: ${ch}`, segments: [] };
|
||||
}
|
||||
if (ch === "$" && next === "(") {
|
||||
return { reject: "unsupported shell token: $()" };
|
||||
return { ok: false, reason: "unsupported shell token: $()", segments: [] };
|
||||
}
|
||||
buf += ch;
|
||||
emptySegment = false;
|
||||
return "include";
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
return { ok: false, reason: result.reason, segments: [] };
|
||||
}
|
||||
if (emptySegment || result.parts.length === 0) {
|
||||
|
||||
if (inHeredocBody && pendingHeredocs.length > 0) {
|
||||
const current = pendingHeredocs[0];
|
||||
const line = current.stripTabs ? heredocLine.replace(/^\t+/, "") : heredocLine;
|
||||
if (line === current.delimiter) {
|
||||
pendingHeredocs.shift();
|
||||
}
|
||||
}
|
||||
|
||||
if (escaped || inSingle || inDouble) {
|
||||
return { ok: false, reason: "unterminated shell quote/escape", segments: [] };
|
||||
}
|
||||
|
||||
pushPart();
|
||||
if (emptySegment || segments.length === 0) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: result.parts.length === 0 ? "empty command" : "empty pipeline segment",
|
||||
reason: segments.length === 0 ? "empty command" : "empty pipeline segment",
|
||||
segments: [],
|
||||
};
|
||||
}
|
||||
return { ok: true, segments: result.parts };
|
||||
return { ok: true, segments };
|
||||
}
|
||||
|
||||
function findWindowsUnsupportedToken(command: string): string | null {
|
||||
|
||||
Reference in New Issue
Block a user