mirror of
https://github.com/0xMassi/webclaw.git
synced 2026-05-29 20:45:12 +02:00
fix(cli): close --on-change command injection via sh -c (P0) (#20)
* fix(cli): close --on-change command injection via sh -c (P0)
The --on-change flag on `webclaw watch` (single-URL, line 1588) and
`webclaw watch` multi-URL mode (line 1738) previously handed the entire
user-supplied string to `tokio::process::Command::new("sh").arg("-c").arg(cmd)`.
Any path that can influence that string — a malicious config file, an MCP
client driven by an LLM with prompt-injection exposure, an untrusted
environment variable substitution — gets arbitrary shell execution.
The command is now tokenized with `shlex::split` (POSIX-ish quoting rules)
and executed directly via `Command::new(prog).args(args)`. Metacharacters
like `;`, `&&`, `|`, `$()`, `<(...)`, env expansion, and globbing no longer
fire.
An explicit opt-in escape hatch is available for users who genuinely need
a shell pipeline: `WEBCLAW_ALLOW_SHELL=1` preserves the old `sh -c` path
and logs a warning on every invocation so it can't slip in silently.
Both call sites now route through a shared `spawn_on_change()` helper.
Adds `shlex = "1"` to webclaw-cli dependencies.
Version: 0.3.13 -> 0.3.14
CHANGELOG updated.
Surfaced by the 2026-04-16 workspace audit.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* chore(brand): fix clippy 1.95 unnecessary_sort_by errors
Pre-existing sort_by calls in brand.rs became hard errors under clippy
1.95. Switch to sort_by_key with std::cmp::Reverse. Pure refactor — same
ordering, no behavior change. Bundled here so CI goes green on the P0
command-injection fix.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
6316b1a6e7
commit
1352f48e05
6 changed files with 73 additions and 39 deletions
|
|
@ -24,3 +24,4 @@ tracing-subscriber = { workspace = true }
|
|||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
regex = "1"
|
||||
url = "2"
|
||||
shlex = "1"
|
||||
|
|
|
|||
|
|
@ -1456,6 +1456,59 @@ fn timestamp() -> String {
|
|||
format!("{hours:02}:{minutes:02}:{seconds:02}")
|
||||
}
|
||||
|
||||
/// Spawn the `--on-change` command with `payload` on stdin.
|
||||
///
|
||||
/// Previously this passed the entire user-provided string to `sh -c`, which
|
||||
/// made `--on-change 'notify "$URL"; rm -rf /'` a plausible disaster the
|
||||
/// moment an untrusted config file or MCP-driven agent fed us a command.
|
||||
/// The MCP surface specifically is prompt-injection-exposed: an LLM that
|
||||
/// controls CLI args can escalate into arbitrary shell on the host.
|
||||
///
|
||||
/// We now parse the command with `shlex` (POSIX-ish tokenization with proper
|
||||
/// quoting) and exec the program directly without an intermediate shell, so
|
||||
/// metacharacters like `;`, `&&`, `|`, `$()`, and env expansion can't fire.
|
||||
/// Users who genuinely need a pipeline can set the whole chain behind a
|
||||
/// script they've written, or opt in per-call via `WEBCLAW_ALLOW_SHELL=1`
|
||||
/// (documented escape hatch, noisy by design).
|
||||
async fn spawn_on_change(cmd: &str, stdin_payload: &[u8]) {
|
||||
eprintln!("[watch] Running: {cmd}");
|
||||
|
||||
let allow_shell = std::env::var("WEBCLAW_ALLOW_SHELL")
|
||||
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
|
||||
.unwrap_or(false);
|
||||
|
||||
let mut command = if allow_shell {
|
||||
eprintln!("[watch] WEBCLAW_ALLOW_SHELL=1 — executing via sh -c (unsafe)");
|
||||
let mut c = tokio::process::Command::new("sh");
|
||||
c.arg("-c").arg(cmd);
|
||||
c
|
||||
} else {
|
||||
let Some(argv) = shlex::split(cmd) else {
|
||||
eprintln!("[watch] Failed to parse --on-change command (unbalanced quotes?)");
|
||||
return;
|
||||
};
|
||||
let Some((program, args)) = argv.split_first() else {
|
||||
eprintln!("[watch] --on-change command is empty");
|
||||
return;
|
||||
};
|
||||
let mut c = tokio::process::Command::new(program);
|
||||
c.args(args);
|
||||
c
|
||||
};
|
||||
|
||||
command.stdin(std::process::Stdio::piped());
|
||||
|
||||
match command.spawn() {
|
||||
Ok(mut child) => {
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
let _ = stdin.write_all(stdin_payload).await;
|
||||
}
|
||||
}
|
||||
Err(e) => eprintln!("[watch] Failed to run command: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Fire a webhook POST with a JSON payload. Non-blocking — errors logged to stderr.
|
||||
/// Auto-detects Discord and Slack webhook URLs and wraps the payload accordingly.
|
||||
fn fire_webhook(url: &str, payload: &serde_json::Value) {
|
||||
|
|
@ -1587,21 +1640,7 @@ async fn run_watch_single(
|
|||
|
||||
if let Some(ref cmd) = cli.on_change {
|
||||
let diff_json = serde_json::to_string(&diff).unwrap_or_default();
|
||||
eprintln!("[watch] Running: {cmd}");
|
||||
match tokio::process::Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg(cmd)
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
{
|
||||
Ok(mut child) => {
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
let _ = stdin.write_all(diff_json.as_bytes()).await;
|
||||
}
|
||||
}
|
||||
Err(e) => eprintln!("[watch] Failed to run command: {e}"),
|
||||
}
|
||||
spawn_on_change(cmd, diff_json.as_bytes()).await;
|
||||
}
|
||||
|
||||
if let Some(ref webhook_url) = cli.webhook {
|
||||
|
|
@ -1745,21 +1784,7 @@ async fn run_watch_multi(
|
|||
"changes": changed,
|
||||
});
|
||||
let payload_json = serde_json::to_string(&payload).unwrap_or_default();
|
||||
eprintln!("[watch] Running: {cmd}");
|
||||
match tokio::process::Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg(cmd)
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
{
|
||||
Ok(mut child) => {
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
let _ = stdin.write_all(payload_json.as_bytes()).await;
|
||||
}
|
||||
}
|
||||
Err(e) => eprintln!("[watch] Failed to run command: {e}"),
|
||||
}
|
||||
spawn_on_change(cmd, payload_json.as_bytes()).await;
|
||||
}
|
||||
|
||||
// Fire webhook once with aggregate payload
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue