mirror of
https://github.com/farcasclaudiu/openclaw.git
synced 2026-06-29 07:01:40 +03: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.
|
- 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.
|
- 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.
|
- 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
|
## 2026.2.12
|
||||||
|
|
||||||
|
|||||||
@@ -164,6 +164,68 @@ describe("exec approvals shell parsing", () => {
|
|||||||
expect(res.segments[0]?.argv[0]).toBe("echo");
|
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", () => {
|
it("rejects windows shell metacharacters", () => {
|
||||||
const res = analyzeShellCommand({
|
const res = analyzeShellCommand({
|
||||||
command: "ping 127.0.0.1 -n 1 & whoami",
|
command: "ping 127.0.0.1 -n 1 & whoami",
|
||||||
|
|||||||
+145
-54
@@ -636,31 +636,77 @@ function isDoubleQuoteEscape(next: string | undefined): next is string {
|
|||||||
return Boolean(next && DOUBLE_QUOTE_ESCAPES.has(next));
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
const parseHeredocDelimiter = (
|
||||||
* Iterates through a command string while respecting shell quoting rules.
|
source: string,
|
||||||
* The callback receives each character and the next character, and returns an action:
|
start: number,
|
||||||
* - "split": push current buffer as a segment and start a new one
|
): { delimiter: string; end: number } | null => {
|
||||||
* - "skip": skip this character (and optionally the next via skip count)
|
let i = start;
|
||||||
* - "include": add this character to the buffer
|
while (i < source.length && (source[i] === " " || source[i] === "\t")) {
|
||||||
* - { reject: reason }: abort with an error
|
i += 1;
|
||||||
*/
|
}
|
||||||
function iterateQuoteAware(
|
if (i >= source.length) {
|
||||||
command: string,
|
return null;
|
||||||
onChar: (ch: string, next: string | undefined, index: number) => IteratorAction,
|
}
|
||||||
): { ok: true; parts: string[]; hasSplit: boolean } | { ok: false; reason: string } {
|
|
||||||
const parts: string[] = [];
|
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 buf = "";
|
||||||
let inSingle = false;
|
let inSingle = false;
|
||||||
let inDouble = false;
|
let inDouble = false;
|
||||||
let escaped = false;
|
let escaped = false;
|
||||||
let hasSplit = false;
|
let emptySegment = false;
|
||||||
|
const pendingHeredocs: HeredocSpec[] = [];
|
||||||
|
let inHeredocBody = false;
|
||||||
|
let heredocLine = "";
|
||||||
|
|
||||||
const pushPart = () => {
|
const pushPart = () => {
|
||||||
const trimmed = buf.trim();
|
const trimmed = buf.trim();
|
||||||
if (trimmed) {
|
if (trimmed) {
|
||||||
parts.push(trimmed);
|
segments.push(trimmed);
|
||||||
}
|
}
|
||||||
buf = "";
|
buf = "";
|
||||||
};
|
};
|
||||||
@@ -669,14 +715,38 @@ function iterateQuoteAware(
|
|||||||
const ch = command[i];
|
const ch = command[i];
|
||||||
const next = command[i + 1];
|
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) {
|
if (escaped) {
|
||||||
buf += ch;
|
buf += ch;
|
||||||
escaped = false;
|
escaped = false;
|
||||||
|
emptySegment = false;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!inSingle && !inDouble && ch === "\\") {
|
if (!inSingle && !inDouble && ch === "\\") {
|
||||||
escaped = true;
|
escaped = true;
|
||||||
buf += ch;
|
buf += ch;
|
||||||
|
emptySegment = false;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (inSingle) {
|
if (inSingle) {
|
||||||
@@ -684,6 +754,7 @@ function iterateQuoteAware(
|
|||||||
inSingle = false;
|
inSingle = false;
|
||||||
}
|
}
|
||||||
buf += ch;
|
buf += ch;
|
||||||
|
emptySegment = false;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (inDouble) {
|
if (inDouble) {
|
||||||
@@ -691,93 +762,113 @@ function iterateQuoteAware(
|
|||||||
buf += ch;
|
buf += ch;
|
||||||
buf += next;
|
buf += next;
|
||||||
i += 1;
|
i += 1;
|
||||||
|
emptySegment = false;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (ch === "$" && next === "(") {
|
if (ch === "$" && next === "(") {
|
||||||
return { ok: false, reason: "unsupported shell token: $()" };
|
return { ok: false, reason: "unsupported shell token: $()", segments: [] };
|
||||||
}
|
}
|
||||||
if (ch === "`") {
|
if (ch === "`") {
|
||||||
return { ok: false, reason: "unsupported shell token: `" };
|
return { ok: false, reason: "unsupported shell token: `", segments: [] };
|
||||||
}
|
}
|
||||||
if (ch === "\n" || ch === "\r") {
|
if (ch === "\n" || ch === "\r") {
|
||||||
return { ok: false, reason: "unsupported shell token: newline" };
|
return { ok: false, reason: "unsupported shell token: newline", segments: [] };
|
||||||
}
|
}
|
||||||
if (ch === '"') {
|
if (ch === '"') {
|
||||||
inDouble = false;
|
inDouble = false;
|
||||||
}
|
}
|
||||||
buf += ch;
|
buf += ch;
|
||||||
|
emptySegment = false;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (ch === "'") {
|
if (ch === "'") {
|
||||||
inSingle = true;
|
inSingle = true;
|
||||||
buf += ch;
|
buf += ch;
|
||||||
|
emptySegment = false;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (ch === '"') {
|
if (ch === '"') {
|
||||||
inDouble = true;
|
inDouble = true;
|
||||||
buf += ch;
|
buf += ch;
|
||||||
|
emptySegment = false;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const action = onChar(ch, next, i);
|
if ((ch === "\n" || ch === "\r") && pendingHeredocs.length > 0) {
|
||||||
if (typeof action === "object" && "reject" in action) {
|
inHeredocBody = true;
|
||||||
return { ok: false, reason: action.reject };
|
heredocLine = "";
|
||||||
|
if (ch === "\r" && next === "\n") {
|
||||||
|
i += 1;
|
||||||
}
|
}
|
||||||
if (action === "split") {
|
|
||||||
pushPart();
|
|
||||||
hasSplit = true;
|
|
||||||
continue;
|
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 === "|") {
|
if (ch === "|" && next === "|") {
|
||||||
return { reject: "unsupported shell token: ||" };
|
return { ok: false, reason: "unsupported shell token: ||", segments: [] };
|
||||||
}
|
}
|
||||||
if (ch === "|" && next === "&") {
|
if (ch === "|" && next === "&") {
|
||||||
return { reject: "unsupported shell token: |&" };
|
return { ok: false, reason: "unsupported shell token: |&", segments: [] };
|
||||||
}
|
}
|
||||||
if (ch === "|") {
|
if (ch === "|") {
|
||||||
emptySegment = true;
|
emptySegment = true;
|
||||||
return "split";
|
pushPart();
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
if (ch === "&" || ch === ";") {
|
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)) {
|
if (DISALLOWED_PIPELINE_TOKENS.has(ch)) {
|
||||||
return { reject: `unsupported shell token: ${ch}` };
|
return { ok: false, reason: `unsupported shell token: ${ch}`, segments: [] };
|
||||||
}
|
}
|
||||||
if (ch === "$" && next === "(") {
|
if (ch === "$" && next === "(") {
|
||||||
return { reject: "unsupported shell token: $()" };
|
return { ok: false, reason: "unsupported shell token: $()", segments: [] };
|
||||||
}
|
}
|
||||||
|
buf += ch;
|
||||||
emptySegment = false;
|
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 {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
reason: result.parts.length === 0 ? "empty command" : "empty pipeline segment",
|
reason: segments.length === 0 ? "empty command" : "empty pipeline segment",
|
||||||
segments: [],
|
segments: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { ok: true, segments: result.parts };
|
return { ok: true, segments };
|
||||||
}
|
}
|
||||||
|
|
||||||
function findWindowsUnsupportedToken(command: string): string | null {
|
function findWindowsUnsupportedToken(command: string): string | null {
|
||||||
|
|||||||
Reference in New Issue
Block a user