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

@ -1,9 +1,10 @@
use crate::config::Config;
use crate::memory::{self, Memory, MemoryCategory};
use crate::providers::{self, Provider};
use crate::security::pairing::{is_public_bind, PairingGuard};
use crate::security::pairing::{constant_time_eq, is_public_bind, PairingGuard};
use anyhow::Result;
use std::sync::Arc;
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
@ -106,9 +107,11 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
let pairing = pairing.clone();
tokio::spawn(async move {
let mut buf = vec![0u8; 8192];
let n = match stream.read(&mut buf).await {
Ok(n) if n > 0 => n,
// Read with 30s timeout to prevent slow-loris attacks
let mut buf = vec![0u8; 65_536]; // 64KB max request
let n = match tokio::time::timeout(Duration::from_secs(30), stream.read(&mut buf)).await
{
Ok(Ok(n)) if n > 0 => n,
_ => return,
};
@ -179,18 +182,31 @@ async fn handle_request(
// Pairing endpoint — exchange one-time code for bearer token
("POST", "/pair") => {
let code = extract_header(request, "X-Pairing-Code").unwrap_or("");
if let Some(token) = pairing.try_pair(code) {
tracing::info!("🔐 New client paired successfully");
let body = serde_json::json!({
"paired": true,
"token": token,
"message": "Save this token — use it as Authorization: Bearer <token>"
});
let _ = send_json(stream, 200, &body).await;
} else {
tracing::warn!("🔐 Pairing attempt with invalid code");
let err = serde_json::json!({"error": "Invalid pairing code"});
let _ = send_json(stream, 403, &err).await;
match pairing.try_pair(code) {
Ok(Some(token)) => {
tracing::info!("🔐 New client paired successfully");
let body = serde_json::json!({
"paired": true,
"token": token,
"message": "Save this token — use it as Authorization: Bearer <token>"
});
let _ = send_json(stream, 200, &body).await;
}
Ok(None) => {
tracing::warn!("🔐 Pairing attempt with invalid code");
let err = serde_json::json!({"error": "Invalid pairing code"});
let _ = send_json(stream, 403, &err).await;
}
Err(lockout_secs) => {
tracing::warn!(
"🔐 Pairing locked out — too many failed attempts ({lockout_secs}s remaining)"
);
let err = serde_json::json!({
"error": format!("Too many failed attempts. Try again in {lockout_secs}s."),
"retry_after": lockout_secs
});
let _ = send_json(stream, 429, &err).await;
}
}
}
@ -213,7 +229,7 @@ async fn handle_request(
if let Some(secret) = webhook_secret {
let header_val = extract_header(request, "X-Webhook-Secret");
match header_val {
Some(val) if val == secret.as_ref() => {}
Some(val) if constant_time_eq(val, secret.as_ref()) => {}
_ => {
tracing::warn!(
"Webhook: rejected request — invalid or missing X-Webhook-Secret"