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]>
416 lines
13 KiB
Rust
416 lines
13 KiB
Rust
//! HTTP endpoint allowlist validation.
|
|
//!
|
|
//! Validates that HTTP requests from WASM tools only go to allowed endpoints.
|
|
//! This is the first line of defense against unauthorized API access.
|
|
//!
|
|
//! # Validation Flow
|
|
//!
|
|
//! ```text
|
|
//! WASM HTTP request ──► Parse URL ──► Check allowlist ──► Allow/Deny
|
|
//! │ │
|
|
//! │ ├─► Host match?
|
|
//! │ ├─► Path prefix match?
|
|
//! │ └─► Method allowed?
|
|
//! │
|
|
//! └─► Validate URL format
|
|
//! ```
|
|
|
|
use std::fmt;
|
|
|
|
use crate::tools::wasm::capabilities::EndpointPattern;
|
|
|
|
/// Result of allowlist validation.
|
|
#[derive(Debug, Clone)]
|
|
pub enum AllowlistResult {
|
|
/// Request is allowed.
|
|
Allowed,
|
|
/// Request is denied with reason.
|
|
Denied(DenyReason),
|
|
}
|
|
|
|
impl AllowlistResult {
|
|
pub fn is_allowed(&self) -> bool {
|
|
matches!(self, AllowlistResult::Allowed)
|
|
}
|
|
}
|
|
|
|
/// Reason why a request was denied.
|
|
#[derive(Debug, Clone)]
|
|
pub enum DenyReason {
|
|
/// URL could not be parsed.
|
|
InvalidUrl(String),
|
|
/// Host is not in the allowlist.
|
|
HostNotAllowed(String),
|
|
/// Path does not match any allowed prefix.
|
|
PathNotAllowed { host: String, path: String },
|
|
/// HTTP method is not allowed for this endpoint.
|
|
MethodNotAllowed { method: String, host: String },
|
|
/// Allowlist is empty (no endpoints configured).
|
|
EmptyAllowlist,
|
|
/// URL scheme is not HTTPS.
|
|
InsecureScheme(String),
|
|
}
|
|
|
|
impl fmt::Display for DenyReason {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
match self {
|
|
DenyReason::InvalidUrl(url) => write!(f, "Invalid URL: {}", url),
|
|
DenyReason::HostNotAllowed(host) => write!(f, "Host not in allowlist: {}", host),
|
|
DenyReason::PathNotAllowed { host, path } => {
|
|
write!(f, "Path not allowed for host {}: {}", host, path)
|
|
}
|
|
DenyReason::MethodNotAllowed { method, host } => {
|
|
write!(f, "Method {} not allowed for host {}", method, host)
|
|
}
|
|
DenyReason::EmptyAllowlist => write!(f, "No endpoints in allowlist"),
|
|
DenyReason::InsecureScheme(scheme) => {
|
|
write!(f, "Insecure scheme: {} (only HTTPS allowed)", scheme)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Validates HTTP requests against an allowlist.
|
|
pub struct AllowlistValidator {
|
|
patterns: Vec<EndpointPattern>,
|
|
/// Whether to require HTTPS (default: true).
|
|
require_https: bool,
|
|
}
|
|
|
|
impl AllowlistValidator {
|
|
/// Create a new validator with the given patterns.
|
|
pub fn new(patterns: Vec<EndpointPattern>) -> Self {
|
|
Self {
|
|
patterns,
|
|
require_https: true,
|
|
}
|
|
}
|
|
|
|
/// Allow HTTP (insecure) requests. Use with caution.
|
|
pub fn allow_http(mut self) -> Self {
|
|
self.require_https = false;
|
|
self
|
|
}
|
|
|
|
/// Check if a request is allowed.
|
|
pub fn validate(&self, url: &str, method: &str) -> AllowlistResult {
|
|
// Check for empty allowlist
|
|
if self.patterns.is_empty() {
|
|
return AllowlistResult::Denied(DenyReason::EmptyAllowlist);
|
|
}
|
|
|
|
// Parse the URL
|
|
let parsed = match parse_url(url) {
|
|
Ok(p) => p,
|
|
Err(e) => return AllowlistResult::Denied(DenyReason::InvalidUrl(e)),
|
|
};
|
|
|
|
// Check HTTPS requirement
|
|
if self.require_https && parsed.scheme != "https" {
|
|
return AllowlistResult::Denied(DenyReason::InsecureScheme(parsed.scheme.clone()));
|
|
}
|
|
|
|
// Find a matching pattern
|
|
for pattern in &self.patterns {
|
|
if pattern.matches(&parsed.host, &parsed.path, method) {
|
|
return AllowlistResult::Allowed;
|
|
}
|
|
}
|
|
|
|
// No pattern matched, figure out why for better error messages
|
|
let host_matches: Vec<_> = self
|
|
.patterns
|
|
.iter()
|
|
.filter(|p| p.host_matches(&parsed.host))
|
|
.collect();
|
|
|
|
if host_matches.is_empty() {
|
|
AllowlistResult::Denied(DenyReason::HostNotAllowed(parsed.host))
|
|
} else {
|
|
// Host matches but path/method doesn't
|
|
let path_matches: Vec<_> = host_matches
|
|
.iter()
|
|
.filter(|p| {
|
|
p.path_prefix.is_none()
|
|
|| parsed
|
|
.path
|
|
.starts_with(p.path_prefix.as_deref().unwrap_or(""))
|
|
})
|
|
.collect();
|
|
|
|
if path_matches.is_empty() {
|
|
AllowlistResult::Denied(DenyReason::PathNotAllowed {
|
|
host: parsed.host,
|
|
path: parsed.path,
|
|
})
|
|
} else {
|
|
AllowlistResult::Denied(DenyReason::MethodNotAllowed {
|
|
method: method.to_string(),
|
|
host: parsed.host,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Check if any pattern would allow this host.
|
|
pub fn host_allowed(&self, host: &str) -> bool {
|
|
self.patterns.iter().any(|p| p.host_matches(host))
|
|
}
|
|
|
|
/// Get all allowed hosts (for debugging/logging).
|
|
pub fn allowed_hosts(&self) -> Vec<&str> {
|
|
self.patterns.iter().map(|p| p.host.as_str()).collect()
|
|
}
|
|
}
|
|
|
|
/// Parsed URL components.
|
|
struct ParsedUrl {
|
|
scheme: String,
|
|
host: String,
|
|
path: String,
|
|
}
|
|
|
|
/// Simple URL parser (avoids pulling in a full URL crate).
|
|
fn parse_url(url: &str) -> Result<ParsedUrl, String> {
|
|
// Find scheme
|
|
let (scheme, rest) = url
|
|
.split_once("://")
|
|
.ok_or_else(|| "Missing scheme (expected http:// or https://)".to_string())?;
|
|
|
|
let scheme = scheme.to_lowercase();
|
|
if scheme != "http" && scheme != "https" {
|
|
return Err(format!("Unsupported scheme: {}", scheme));
|
|
}
|
|
|
|
// Reject URLs with userinfo (user:pass@host) to prevent allowlist bypass.
|
|
// A URL like https://[email protected]/ would match the allowlist
|
|
// for api.openai.com but actually send traffic to evil.com.
|
|
let authority = match rest.find('/') {
|
|
Some(idx) => &rest[..idx],
|
|
None => rest,
|
|
};
|
|
if authority.contains('@') {
|
|
return Err("URL contains userinfo (@) which is not allowed".to_string());
|
|
}
|
|
|
|
// Split host from path
|
|
let (host_and_port, path) = match rest.find('/') {
|
|
Some(idx) => (&rest[..idx], &rest[idx..]),
|
|
None => (rest, "/"),
|
|
};
|
|
|
|
// Remove port from host
|
|
let host = match host_and_port.rfind(':') {
|
|
Some(idx) => {
|
|
// Make sure this isn't an IPv6 address
|
|
if host_and_port.starts_with('[') {
|
|
// IPv6: [::1]:8080 or [::1]
|
|
if let Some(bracket_idx) = host_and_port.find(']') {
|
|
// Extract the IPv6 address without brackets
|
|
&host_and_port[1..bracket_idx]
|
|
} else {
|
|
return Err("Invalid IPv6 address".to_string());
|
|
}
|
|
} else {
|
|
&host_and_port[..idx]
|
|
}
|
|
}
|
|
None => host_and_port,
|
|
};
|
|
|
|
// Reject URLs with userinfo (user:pass@host).
|
|
// A URL like https://[email protected]/ confuses the parser into
|
|
// seeing "api.openai.com" as the host, but reqwest actually sends to
|
|
// "evil.com". Block any '@' in the authority section to prevent this.
|
|
if host.contains('@') || host_and_port.contains('@') {
|
|
return Err("URL contains userinfo (@) which is not allowed".to_string());
|
|
}
|
|
|
|
// Validate host
|
|
if host.is_empty() {
|
|
return Err("Empty host".to_string());
|
|
}
|
|
|
|
Ok(ParsedUrl {
|
|
scheme,
|
|
host: host.to_lowercase(),
|
|
path: path.to_string(),
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use crate::tools::wasm::allowlist::{AllowlistValidator, DenyReason};
|
|
use crate::tools::wasm::capabilities::EndpointPattern;
|
|
|
|
fn validator_with_patterns() -> AllowlistValidator {
|
|
AllowlistValidator::new(vec![
|
|
EndpointPattern::host("api.openai.com").with_path_prefix("/v1/"),
|
|
EndpointPattern::host("api.anthropic.com")
|
|
.with_path_prefix("/v1/messages")
|
|
.with_methods(vec!["POST".to_string()]),
|
|
EndpointPattern::host("*.example.com"),
|
|
])
|
|
}
|
|
|
|
#[test]
|
|
fn test_allowed_request() {
|
|
let validator = validator_with_patterns();
|
|
|
|
let result = validator.validate("https://api.openai.com/v1/chat/completions", "POST");
|
|
assert!(result.is_allowed());
|
|
}
|
|
|
|
#[test]
|
|
fn test_denied_wrong_host() {
|
|
let validator = validator_with_patterns();
|
|
|
|
let result = validator.validate("https://evil.com/steal/data", "GET");
|
|
assert!(!result.is_allowed());
|
|
|
|
if let super::AllowlistResult::Denied(reason) = result {
|
|
assert!(matches!(reason, DenyReason::HostNotAllowed(_)));
|
|
} else {
|
|
panic!("Expected denied");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_denied_wrong_path() {
|
|
let validator = validator_with_patterns();
|
|
|
|
let result = validator.validate("https://api.openai.com/v2/different", "GET");
|
|
assert!(!result.is_allowed());
|
|
|
|
if let super::AllowlistResult::Denied(reason) = result {
|
|
assert!(matches!(reason, DenyReason::PathNotAllowed { .. }));
|
|
} else {
|
|
panic!("Expected denied");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_denied_wrong_method() {
|
|
let validator = validator_with_patterns();
|
|
|
|
// Anthropic endpoint only allows POST
|
|
let result = validator.validate("https://api.anthropic.com/v1/messages", "GET");
|
|
assert!(!result.is_allowed());
|
|
|
|
if let super::AllowlistResult::Denied(reason) = result {
|
|
assert!(matches!(reason, DenyReason::MethodNotAllowed { .. }));
|
|
} else {
|
|
panic!("Expected denied");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_wildcard_host() {
|
|
let validator = validator_with_patterns();
|
|
|
|
let result = validator.validate("https://api.example.com/anything", "GET");
|
|
assert!(result.is_allowed());
|
|
|
|
let result = validator.validate("https://sub.api.example.com/anything", "GET");
|
|
assert!(result.is_allowed());
|
|
}
|
|
|
|
#[test]
|
|
fn test_require_https() {
|
|
let validator = validator_with_patterns();
|
|
|
|
let result = validator.validate("http://api.openai.com/v1/chat", "GET");
|
|
assert!(!result.is_allowed());
|
|
|
|
if let super::AllowlistResult::Denied(reason) = result {
|
|
assert!(matches!(reason, DenyReason::InsecureScheme(_)));
|
|
} else {
|
|
panic!("Expected denied");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_allow_http() {
|
|
let validator = validator_with_patterns().allow_http();
|
|
|
|
let result = validator.validate("http://api.example.com/test", "GET");
|
|
assert!(result.is_allowed());
|
|
}
|
|
|
|
#[test]
|
|
fn test_empty_allowlist() {
|
|
let validator = AllowlistValidator::new(vec![]);
|
|
|
|
let result = validator.validate("https://anything.com/", "GET");
|
|
assert!(!result.is_allowed());
|
|
|
|
if let super::AllowlistResult::Denied(reason) = result {
|
|
assert!(matches!(reason, DenyReason::EmptyAllowlist));
|
|
} else {
|
|
panic!("Expected denied");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_userinfo_rejected() {
|
|
let validator = validator_with_patterns();
|
|
|
|
// Userinfo in URL should be rejected to prevent allowlist bypass
|
|
let result = validator.validate("https://[email protected]/v1/chat", "GET");
|
|
assert!(!result.is_allowed());
|
|
|
|
if let super::AllowlistResult::Denied(reason) = result {
|
|
assert!(matches!(reason, DenyReason::InvalidUrl(_)));
|
|
} else {
|
|
panic!("Expected denied for userinfo URL");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_invalid_url() {
|
|
let validator = validator_with_patterns();
|
|
|
|
let result = validator.validate("not-a-url", "GET");
|
|
assert!(!result.is_allowed());
|
|
|
|
if let super::AllowlistResult::Denied(reason) = result {
|
|
assert!(matches!(reason, DenyReason::InvalidUrl(_)));
|
|
} else {
|
|
panic!("Expected denied");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_url_with_port() {
|
|
let validator =
|
|
AllowlistValidator::new(vec![EndpointPattern::host("localhost")]).allow_http();
|
|
|
|
let result = validator.validate("http://localhost:8080/api", "GET");
|
|
assert!(result.is_allowed());
|
|
}
|
|
|
|
#[test]
|
|
fn test_reject_url_with_userinfo() {
|
|
let validator = validator_with_patterns();
|
|
|
|
// Attacker uses userinfo to trick the parser: the allowlist sees
|
|
// "api.openai.com" but reqwest would actually connect to "evil.com".
|
|
let result = validator.validate("https://[email protected]/v1/steal", "GET");
|
|
assert!(!result.is_allowed());
|
|
|
|
if let super::AllowlistResult::Denied(reason) = result {
|
|
assert!(matches!(reason, DenyReason::InvalidUrl(_)));
|
|
} else {
|
|
panic!("Expected denied due to userinfo");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_reject_url_with_user_pass() {
|
|
let validator = validator_with_patterns();
|
|
|
|
let result = validator.validate("https://user:[email protected]/v1/chat", "GET");
|
|
assert!(!result.is_allowed());
|
|
}
|
|
}
|