Files
optimclaw/src/tools/wasm/allowlist.rs
T
33ef0a6ea5 fix: security hardening across all layers (#35)
* 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]>
2026-02-13 05:25:20 +00:00

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());
}
}