mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-09-02 17:49:20 +00:00
* fix: comprehensive security hardening across all layers Critical: - Replace --dangerously-skip-permissions with explicit tool allowlist via settings.json (Claude Code bridge) - Constant-time token comparison (subtle crate) in web auth and orchestrator auth to prevent timing attacks High: - Revoke tokens and clean up handles on container creation failure - Drop SETUID/SETGID capabilities from containers (keep only CHOWN) - Disable redirect following in HTTP tool and WASM wrapper (SSRF) - Reject URL userinfo (@) in WASM allowlist parser (host confusion) - Fix binary body bypassing leak detection (from_utf8 -> from_utf8_lossy) - Protect identity files from LLM overwrites (prompt injection defense) - Prevent tool shadowing: built-in tools cannot be replaced dynamically - User-scoped job APIs: list/detail/cancel/restart/prompt/events/files - CORS restricted to localhost origins, WebSocket origin validation - Sandbox shell fail-closed: no silent fallback to unsandboxed execution - Scrub secrets from log broadcaster before SSE broadcast - XSS sanitization on rendered markdown in web UI - WASM epoch ticker thread so timeout deadlines actually fire Medium: - Cap state transition history at 200 entries - SSE/WebSocket connection limit (100 max) - Request body size limit (1MB) - Response body size limit enforcement in WASM HTTP - UTF-8 safe string truncation (routine engine, shell tool) - Fix PolicyAction::Sanitize to actually run the sanitizer - TOCTOU fix in scheduler and context manager (hold write lock) - Project file serving moved behind auth - Path traversal guard on project_id - Session file permissions set to 0600 on unix - AtomicUsize for routine running_count (panic-safe) - Completion detection hardened against false positives and tool injection - Tool output no longer drives job completion (only LLM response) Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address security review findings across all layers - Fix path traversal sandbox bypass via lexical normalization (file.rs) - Fix SSRF via DNS rebinding with pre-request hostname resolution (http.rs) - Add token budget enforcement on LLM calls (reasoning.rs, state.rs) - Fix cross-user chat history leak with ownership verification (store.rs, server.rs) - Add sliding-window rate limiter on gateway chat endpoint (server.rs) - Harden extension install: HTTPS-only, 50MB cap, WASM magic validation (manager.rs) - Add destructive command blocklist that overrides shell auto-approval (shell.rs) - Add 5MB response body size cap to HTTP tool (http.rs) Co-Authored-By: Claude Opus 4.6 <[email protected]> * refactor: deduplicate shared helpers and remove dead code Extract floor_char_boundary and llm_signals_completion into src/util.rs, unifying diverging phrase lists from agent/worker.rs and worker/runtime.rs. Remove dead RespondResult::usage(), duplicate PROTECTED_IDENTITY_FILES constant, double LeakDetector scanning in WebLogLayer, and invalid 0.0.0.0 origin from WebSocket allow list. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address PR review findings and CI test failures - Fix record_failed_approve: .truncate(true) wiped the attempts file before reading, so failed pairing attempts never accumulated and rate limiting never triggered. - Guard wizard WASM test: skip gracefully when channel build artifacts are absent (CI doesn't compile wasm32-wasip2 targets). - Fix DNS rebinding check: use port 0 instead of hardcoded 443, since the port is irrelevant for hostname resolution. - Remove hardcoded CORS port 3001: the dynamic addr.port() entries already cover the actual server port. - Require WebSocket Origin header: reject connections that omit it entirely, since browsers always send Origin for WS upgrades and a missing header indicates a non-browser client bypassing the check. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address second round of PR review findings - store.rs: reintroduce file locking around read-modify-write in record_failed_approve (concurrent callers could clobber each other). - sse.rs: replace load+check+fetch_add with atomic fetch_update in both subscribe_raw() and subscribe() to prevent overshooting max_connections. - ws.rs: decrement WS tracker before early return when subscribe_raw() returns None (connection limit reached), fixing a counter leak. - server.rs: parse WS Origin host exactly instead of prefix matching, preventing bypass via crafted origins like http://localhost.evil.com. - workspace_integration.rs: skip tests gracefully when Postgres is unreachable instead of panicking (fixes 10 CI failures). Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: add Origin header to WS integration tests The Origin header requirement added in a3b0190 broke the WS gateway integration tests. Test clients now send Origin: http://127.0.0.1:{port} to match the server's localhost validation. Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Claude Opus 4.6 <[email protected]>
164 lines
4.7 KiB
Rust
164 lines
4.7 KiB
Rust
//! Per-job bearer token authentication for worker-to-orchestrator communication.
|
|
//!
|
|
//! Security properties:
|
|
//! - Tokens are cryptographically random (32 bytes, hex-encoded)
|
|
//! - Tokens are scoped to a specific job_id
|
|
//! - Tokens are ephemeral (in-memory only, never persisted)
|
|
//! - A token for Job A cannot access endpoints for Job B
|
|
|
|
use std::collections::HashMap;
|
|
use std::sync::Arc;
|
|
|
|
use axum::extract::{Request, State};
|
|
use axum::http::StatusCode;
|
|
use axum::middleware::Next;
|
|
use axum::response::Response;
|
|
use rand::Rng;
|
|
use subtle::ConstantTimeEq;
|
|
use tokio::sync::RwLock;
|
|
use uuid::Uuid;
|
|
|
|
/// In-memory store for per-job authentication tokens.
|
|
#[derive(Clone)]
|
|
pub struct TokenStore {
|
|
/// Maps job_id -> bearer token. Never logged or persisted.
|
|
tokens: Arc<RwLock<HashMap<Uuid, String>>>,
|
|
}
|
|
|
|
impl TokenStore {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
tokens: Arc::new(RwLock::new(HashMap::new())),
|
|
}
|
|
}
|
|
|
|
/// Generate and store a new token for a job.
|
|
pub async fn create_token(&self, job_id: Uuid) -> String {
|
|
let token = generate_token();
|
|
self.tokens.write().await.insert(job_id, token.clone());
|
|
token
|
|
}
|
|
|
|
/// Validate a token for a specific job (constant-time comparison).
|
|
pub async fn validate(&self, job_id: Uuid, token: &str) -> bool {
|
|
self.tokens
|
|
.read()
|
|
.await
|
|
.get(&job_id)
|
|
.map(|stored| stored.as_bytes().ct_eq(token.as_bytes()).into())
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
/// Remove a token (on container cleanup).
|
|
pub async fn revoke(&self, job_id: Uuid) {
|
|
self.tokens.write().await.remove(&job_id);
|
|
}
|
|
|
|
/// Get the number of active tokens (for diagnostics).
|
|
pub async fn active_count(&self) -> usize {
|
|
self.tokens.read().await.len()
|
|
}
|
|
}
|
|
|
|
impl Default for TokenStore {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
/// Generate a cryptographically random token (32 bytes, hex-encoded = 64 chars).
|
|
fn generate_token() -> String {
|
|
let mut bytes = [0u8; 32];
|
|
rand::thread_rng().fill(&mut bytes);
|
|
hex_encode(&bytes)
|
|
}
|
|
|
|
fn hex_encode(bytes: &[u8]) -> String {
|
|
bytes.iter().map(|b| format!("{:02x}", b)).collect()
|
|
}
|
|
|
|
/// Axum middleware that validates worker bearer tokens.
|
|
///
|
|
/// Extracts the job_id from the path (`/worker/{job_id}/...`) and validates
|
|
/// the `Authorization: Bearer <token>` header against the token store.
|
|
///
|
|
/// Wire up with `axum::middleware::from_fn_with_state(token_store, worker_auth_middleware)`.
|
|
pub async fn worker_auth_middleware(
|
|
State(token_store): State<TokenStore>,
|
|
request: Request,
|
|
next: Next,
|
|
) -> Result<Response, StatusCode> {
|
|
let path = request.uri().path().to_string();
|
|
let job_id = extract_job_id_from_path(&path).ok_or(StatusCode::BAD_REQUEST)?;
|
|
|
|
let token = request
|
|
.headers()
|
|
.get("authorization")
|
|
.and_then(|v| v.to_str().ok())
|
|
.and_then(|v| v.strip_prefix("Bearer "))
|
|
.ok_or(StatusCode::UNAUTHORIZED)?;
|
|
|
|
if !token_store.validate(job_id, token).await {
|
|
return Err(StatusCode::UNAUTHORIZED);
|
|
}
|
|
|
|
Ok(next.run(request).await)
|
|
}
|
|
|
|
/// Extract job UUID from a path like `/worker/{uuid}/...`
|
|
fn extract_job_id_from_path(path: &str) -> Option<Uuid> {
|
|
let parts: Vec<&str> = path.trim_start_matches('/').split('/').collect();
|
|
if parts.len() >= 2 && parts[0] == "worker" {
|
|
Uuid::parse_str(parts[1]).ok()
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[tokio::test]
|
|
async fn test_token_create_and_validate() {
|
|
let store = TokenStore::new();
|
|
let job_id = Uuid::new_v4();
|
|
|
|
let token = store.create_token(job_id).await;
|
|
assert_eq!(token.len(), 64); // 32 bytes hex = 64 chars
|
|
|
|
assert!(store.validate(job_id, &token).await);
|
|
assert!(!store.validate(job_id, "wrong-token").await);
|
|
assert!(!store.validate(Uuid::new_v4(), &token).await);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_token_revoke() {
|
|
let store = TokenStore::new();
|
|
let job_id = Uuid::new_v4();
|
|
|
|
let token = store.create_token(job_id).await;
|
|
assert!(store.validate(job_id, &token).await);
|
|
|
|
store.revoke(job_id).await;
|
|
assert!(!store.validate(job_id, &token).await);
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_job_id() {
|
|
let id = Uuid::new_v4();
|
|
let path = format!("/worker/{}/llm/complete", id);
|
|
assert_eq!(extract_job_id_from_path(&path), Some(id));
|
|
|
|
assert_eq!(extract_job_id_from_path("/other/path"), None);
|
|
assert_eq!(extract_job_id_from_path("/worker/not-a-uuid/foo"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn test_token_is_random() {
|
|
let t1 = generate_token();
|
|
let t2 = generate_token();
|
|
assert_ne!(t1, t2);
|
|
}
|
|
}
|