fix(security): block shell-wrapper line-continuation allowlist bypass

This commit is contained in:
Peter Steinberger
2026-02-22 22:36:29 +01:00
parent 7c109f5737
commit 3f0b9dbb36
6 changed files with 132 additions and 37 deletions

View File

@@ -16,6 +16,11 @@ import {
validateSafeBinArgv,
} from "./exec-safe-bin-policy.js";
import { isTrustedSafeBinPath } from "./exec-safe-bin-trust.js";
function hasShellLineContinuation(command: string): boolean {
return /\\(?:\r\n|\n|\r)/.test(command);
}
export function normalizeSafeBins(entries?: string[]): Set<string> {
if (!Array.isArray(entries)) {
return new Set();
@@ -375,6 +380,12 @@ export function evaluateShellAllowlist(params: {
segmentSatisfiedBy: [],
});
// Keep allowlist analysis conservative: line-continuation semantics are shell-dependent
// and can rewrite token boundaries at runtime.
if (hasShellLineContinuation(params.command)) {
return analysisFailure();
}
const chainParts = isWindowsPlatform(params.platform) ? null : splitCommandChain(params.command);
if (!chainParts) {
const analysis = analyzeShellCommand({

View File

@@ -317,7 +317,7 @@ export type ShellChainPart = {
};
const DISALLOWED_PIPELINE_TOKENS = new Set([">", "<", "`", "\n", "\r", "(", ")"]);
const DOUBLE_QUOTE_ESCAPES = new Set(["\\", '"', "$", "`", "\n", "\r"]);
const DOUBLE_QUOTE_ESCAPES = new Set(["\\", '"', "$", "`"]);
const WINDOWS_UNSUPPORTED_TOKENS = new Set([
"&",
"|",
@@ -336,6 +336,10 @@ function isDoubleQuoteEscape(next: string | undefined): next is string {
return Boolean(next && DOUBLE_QUOTE_ESCAPES.has(next));
}
function isEscapedLineContinuation(next: string | undefined): next is string {
return next === "\n" || next === "\r";
}
function splitShellPipeline(command: string): { ok: boolean; reason?: string; segments: string[] } {
type HeredocSpec = {
delimiter: string;
@@ -485,6 +489,9 @@ function splitShellPipeline(command: string): { ok: boolean; reason?: string; se
continue;
}
if (inDouble) {
if (ch === "\\" && isEscapedLineContinuation(next)) {
return { ok: false, reason: "unsupported shell token: newline", segments: [] };
}
if (ch === "\\" && isDoubleQuoteEscape(next)) {
buf += ch;
buf += next;
@@ -749,6 +756,10 @@ export function splitCommandChainWithOperators(command: string): ShellChainPart[
continue;
}
if (inDouble) {
if (ch === "\\" && isEscapedLineContinuation(next)) {
invalidChain = true;
break;
}
if (ch === "\\" && isDoubleQuoteEscape(next)) {
buf += ch;
buf += next;

View File

@@ -343,6 +343,14 @@ describe("exec approvals shell parsing", () => {
command: "/usr/bin/echo first line\n/usr/bin/echo second line",
reason: "unsupported shell token: \n",
},
{
command: 'echo "ok $\\\n(id -u)"',
reason: "unsupported shell token: newline",
},
{
command: 'echo "ok $\\\r\n(id -u)"',
reason: "unsupported shell token: newline",
},
{
command: "ping 127.0.0.1 -n 1 & whoami",
reason: "unsupported windows shell token: &",
@@ -548,6 +556,17 @@ describe("exec approvals shell allowlist (chained commands)", () => {
expect(result.allowlistSatisfied).toBe(true);
}
});
it("fails allowlist analysis for shell line continuations", () => {
const result = evaluateShellAllowlist({
command: 'echo "ok $\\\n(id -u)"',
allowlist: [{ pattern: "/usr/bin/echo" }],
safeBins: new Set(),
cwd: "/tmp",
});
expect(result.analysisOk).toBe(false);
expect(result.allowlistSatisfied).toBe(false);
});
});
describe("exec approvals safe bins", () => {