hardening: fix 7 production weaknesses found in codebase scan

Scan findings and fixes:

1. Gateway buffer overflow (8KB → 64KB)
   - Fixed: Increased request buffer from 8,192 to 65,536 bytes
   - Large POST bodies (long prompts) were silently truncated

2. Gateway slow-loris attack (no read timeout → 30s)
   - Fixed: tokio::time::timeout(30s) on stream.read()
   - Malicious clients could hold connections indefinitely

3. Webhook secret timing attack (== → constant_time_eq)
   - Fixed: Now uses constant_time_eq() for secret comparison
   - Prevents timing side-channel on webhook authentication

4. Pairing brute force (no limit → 5 attempts + 5min lockout)
   - Fixed: PairingGuard tracks failed attempts with lockout
   - Returns 429 Too Many Requests with retry_after seconds

5. Shell tool hang (no timeout → 60s kill)
   - Fixed: tokio::time::timeout(60s) on Command::output()
   - Commands that hang are killed and return error

6. Shell tool OOM (unbounded output → 1MB cap)
   - Fixed: stdout/stderr truncated at 1MB with warning
   - Prevents memory exhaustion from verbose commands

7. Provider HTTP timeout (none → 120s request + 10s connect)
   - Fixed: All 5 providers (OpenRouter, Anthropic, OpenAI,
     Ollama, Compatible) now have reqwest timeouts
   - Ollama gets 300s (local models are slower)

949 tests passing, 0 clippy warnings, cargo fmt clean
This commit is contained in:
argenis de la rosa 2026-02-14 01:47:08 -05:00
parent 0b5b49537a
commit 976c5bbf3c
8 changed files with 219 additions and 49 deletions

View file

@ -3,6 +3,12 @@ use crate::security::SecurityPolicy;
use async_trait::async_trait;
use serde_json::json;
use std::sync::Arc;
use std::time::Duration;
/// Maximum shell command execution time before kill.
const SHELL_TIMEOUT_SECS: u64 = 60;
/// Maximum output size in bytes (1MB).
const MAX_OUTPUT_BYTES: usize = 1_048_576;
/// Shell command execution tool with sandboxing
pub struct ShellTool {
@ -53,25 +59,55 @@ impl Tool for ShellTool {
});
}
let output = tokio::process::Command::new("sh")
.arg("-c")
.arg(command)
.current_dir(&self.security.workspace_dir)
.output()
.await?;
// Execute with timeout to prevent hanging commands
let result = tokio::time::timeout(
Duration::from_secs(SHELL_TIMEOUT_SECS),
tokio::process::Command::new("sh")
.arg("-c")
.arg(command)
.current_dir(&self.security.workspace_dir)
.output(),
)
.await;
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
match result {
Ok(Ok(output)) => {
let mut stdout = String::from_utf8_lossy(&output.stdout).to_string();
let mut stderr = String::from_utf8_lossy(&output.stderr).to_string();
Ok(ToolResult {
success: output.status.success(),
output: stdout,
error: if stderr.is_empty() {
None
} else {
Some(stderr)
},
})
// Truncate output to prevent OOM
if stdout.len() > MAX_OUTPUT_BYTES {
stdout.truncate(MAX_OUTPUT_BYTES);
stdout.push_str("\n... [output truncated at 1MB]");
}
if stderr.len() > MAX_OUTPUT_BYTES {
stderr.truncate(MAX_OUTPUT_BYTES);
stderr.push_str("\n... [stderr truncated at 1MB]");
}
Ok(ToolResult {
success: output.status.success(),
output: stdout,
error: if stderr.is_empty() {
None
} else {
Some(stderr)
},
})
}
Ok(Err(e)) => Ok(ToolResult {
success: false,
output: String::new(),
error: Some(format!("Failed to execute command: {e}")),
}),
Err(_) => Ok(ToolResult {
success: false,
output: String::new(),
error: Some(format!(
"Command timed out after {SHELL_TIMEOUT_SECS}s and was killed"
)),
}),
}
}
}