Skip to content

Commit f241394

Browse files
committed
shell hardening
1 parent 9e230f4 commit f241394

File tree

4 files changed

+83
-45
lines changed

4 files changed

+83
-45
lines changed

Cargo.lock

Lines changed: 15 additions & 14 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/openfang-api/src/ws.rs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,17 +148,26 @@ pub async fn agent_ws(
148148
// SECURITY: Authenticate WebSocket upgrades (bypasses middleware).
149149
let api_key = &state.kernel.config.api_key;
150150
if !api_key.is_empty() {
151+
// SECURITY: Use constant-time comparison to prevent timing attacks on API key
152+
let ct_eq = |token: &str, key: &str| -> bool {
153+
use subtle::ConstantTimeEq;
154+
if token.len() != key.len() {
155+
return false;
156+
}
157+
token.as_bytes().ct_eq(key.as_bytes()).into()
158+
};
159+
151160
let header_auth = headers
152161
.get("authorization")
153162
.and_then(|v| v.to_str().ok())
154163
.and_then(|v| v.strip_prefix("Bearer "))
155-
.map(|token| token == api_key)
164+
.map(|token| ct_eq(token, api_key))
156165
.unwrap_or(false);
157166

158167
let query_auth = uri
159168
.query()
160169
.and_then(|q| q.split('&').find_map(|pair| pair.strip_prefix("token=")))
161-
.map(|token| token == api_key)
170+
.map(|token| ct_eq(token, api_key))
162171
.unwrap_or(false);
163172

164173
if !header_auth && !query_auth {

crates/openfang-runtime/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ dashmap = { workspace = true }
3131
regex-lite = { workspace = true }
3232
rusqlite = { workspace = true }
3333
tokio-tungstenite = "0.24"
34+
shlex = "1"
3435

3536
[dev-dependencies]
3637
tokio-test = { workspace = true }

crates/openfang-runtime/src/tool_runner.rs

Lines changed: 56 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1438,40 +1438,67 @@ async fn tool_shell_exec(
14381438
let policy_timeout = exec_policy.map(|p| p.timeout_secs).unwrap_or(30);
14391439
let timeout_secs = input["timeout_seconds"].as_u64().unwrap_or(policy_timeout);
14401440

1441-
// Shell resolution: prefer sh (Git Bash/MSYS2) on Windows to avoid cmd.exe
1442-
// quoting issues (% expansion mangles yt-dlp templates, " in filenames
1443-
// converted to # by --restrict-filenames). Fall back to cmd if sh not found.
1444-
#[cfg(windows)]
1445-
let git_sh: Option<&str> = {
1446-
const SH_PATHS: &[&str] = &[
1447-
"C:\\Program Files\\Git\\usr\\bin\\sh.exe",
1448-
"C:\\Program Files (x86)\\Git\\usr\\bin\\sh.exe",
1449-
];
1450-
SH_PATHS
1451-
.iter()
1452-
.copied()
1453-
.find(|p| std::path::Path::new(p).exists())
1454-
};
1455-
let (shell, shell_arg) = if cfg!(windows) {
1456-
#[cfg(windows)]
1457-
{
1458-
if let Some(sh) = git_sh {
1459-
(sh, "-c")
1460-
} else {
1461-
("cmd", "/C")
1462-
}
1441+
// SECURITY: Determine execution strategy based on exec policy.
1442+
//
1443+
// In Allowlist mode (default): Use direct execution via shlex argv splitting.
1444+
// This avoids invoking a shell interpreter, which eliminates an entire class
1445+
// of injection attacks (encoding tricks, $IFS, glob expansion, etc.).
1446+
//
1447+
// In Full mode: User explicitly opted into unrestricted shell access,
1448+
// so we use sh -c / cmd /C as before.
1449+
let use_direct_exec = exec_policy
1450+
.map(|p| p.mode == openfang_types::config::ExecSecurityMode::Allowlist)
1451+
.unwrap_or(true); // Default to safe mode
1452+
1453+
let mut cmd = if use_direct_exec {
1454+
// SAFE PATH: Split command into argv using POSIX shell lexer rules,
1455+
// then execute the binary directly — no shell interpreter involved.
1456+
let argv = shlex::split(command).ok_or_else(|| {
1457+
"Command contains unmatched quotes or invalid shell syntax".to_string()
1458+
})?;
1459+
if argv.is_empty() {
1460+
return Err("Empty command after parsing".to_string());
14631461
}
1464-
#[cfg(not(windows))]
1465-
{
1466-
("sh", "-c")
1462+
let mut c = tokio::process::Command::new(&argv[0]);
1463+
if argv.len() > 1 {
1464+
c.args(&argv[1..]);
14671465
}
1466+
c
14681467
} else {
1469-
("sh", "-c")
1468+
// UNSAFE PATH: Full mode — user explicitly opted in to shell interpretation.
1469+
// Shell resolution: prefer sh (Git Bash/MSYS2) on Windows.
1470+
#[cfg(windows)]
1471+
let git_sh: Option<&str> = {
1472+
const SH_PATHS: &[&str] = &[
1473+
"C:\\Program Files\\Git\\usr\\bin\\sh.exe",
1474+
"C:\\Program Files (x86)\\Git\\usr\\bin\\sh.exe",
1475+
];
1476+
SH_PATHS
1477+
.iter()
1478+
.copied()
1479+
.find(|p| std::path::Path::new(p).exists())
1480+
};
1481+
let (shell, shell_arg) = if cfg!(windows) {
1482+
#[cfg(windows)]
1483+
{
1484+
if let Some(sh) = git_sh {
1485+
(sh, "-c")
1486+
} else {
1487+
("cmd", "/C")
1488+
}
1489+
}
1490+
#[cfg(not(windows))]
1491+
{
1492+
("sh", "-c")
1493+
}
1494+
} else {
1495+
("sh", "-c")
1496+
};
1497+
let mut c = tokio::process::Command::new(shell);
1498+
c.arg(shell_arg).arg(command);
1499+
c
14701500
};
14711501

1472-
let mut cmd = tokio::process::Command::new(shell);
1473-
cmd.arg(shell_arg).arg(command);
1474-
14751502
// Set working directory to agent workspace so files are created there
14761503
if let Some(ws) = workspace_root {
14771504
cmd.current_dir(ws);

0 commit comments

Comments
 (0)