From 8247521fe459f38139b3cfa2c4be147fdedeab2e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Jan 2026 06:10:07 +0000 Subject: [PATCH] feat(bashkit): Phase 9 - Network allowlist and HTTP client Add secure network access with URL allowlist: NetworkAllowlist: - Empty allowlist blocks all URLs by default - Pattern matching on scheme, host, port, and path prefix - Support for multiple patterns - Optional allow_all() for testing HttpClient (behind "network" feature): - GET, POST, PUT, DELETE, HEAD, PATCH methods - Request/response handling with body and headers - Automatic timeout (30s default) - All requests validated against allowlist Security: Network disabled by default, explicit opt-in required. https://claude.ai/code/session_01A16cD8ztbTJs2PB2iHe1Ua --- crates/bashkit/Cargo.toml | 10 + crates/bashkit/src/error.rs | 4 + crates/bashkit/src/lib.rs | 5 + crates/bashkit/src/network/allowlist.rs | 330 ++++++++++++++++++++++++ crates/bashkit/src/network/client.rs | 192 ++++++++++++++ crates/bashkit/src/network/mod.rs | 20 ++ 6 files changed, 561 insertions(+) create mode 100644 crates/bashkit/src/network/allowlist.rs create mode 100644 crates/bashkit/src/network/client.rs create mode 100644 crates/bashkit/src/network/mod.rs diff --git a/crates/bashkit/Cargo.toml b/crates/bashkit/Cargo.toml index ded83651..94d6aa27 100644 --- a/crates/bashkit/Cargo.toml +++ b/crates/bashkit/Cargo.toml @@ -30,6 +30,16 @@ regex = { workspace = true } # Glob matching globset = { workspace = true } +# HTTP client (for curl) - optional, enabled with network feature +reqwest = { workspace = true, optional = true } + +# URL parsing +url = "2" + +[features] +default = [] +network = ["reqwest"] + [dev-dependencies] tokio-test = { workspace = true } pretty_assertions = { workspace = true } diff --git a/crates/bashkit/src/error.rs b/crates/bashkit/src/error.rs index 8b0b1576..cfbad505 100644 --- a/crates/bashkit/src/error.rs +++ b/crates/bashkit/src/error.rs @@ -28,4 +28,8 @@ pub enum Error { /// Resource limit exceeded. #[error("resource limit exceeded: {0}")] ResourceLimit(#[from] LimitExceeded), + + /// Network error. + #[error("network error: {0}")] + Network(String), } diff --git a/crates/bashkit/src/lib.rs b/crates/bashkit/src/lib.rs index cf67f7d5..92c4510e 100644 --- a/crates/bashkit/src/lib.rs +++ b/crates/bashkit/src/lib.rs @@ -22,12 +22,17 @@ mod error; mod fs; mod interpreter; mod limits; +mod network; mod parser; pub use error::{Error, Result}; pub use fs::{FileSystem, InMemoryFs, MountableFs, OverlayFs}; pub use interpreter::ExecResult; pub use limits::{ExecutionCounters, ExecutionLimits, LimitExceeded}; +pub use network::NetworkAllowlist; + +#[cfg(feature = "network")] +pub use network::HttpClient; use std::collections::HashMap; use std::path::PathBuf; diff --git a/crates/bashkit/src/network/allowlist.rs b/crates/bashkit/src/network/allowlist.rs new file mode 100644 index 00000000..9e520439 --- /dev/null +++ b/crates/bashkit/src/network/allowlist.rs @@ -0,0 +1,330 @@ +//! URL allowlist for network access control +//! +//! Provides a whitelist-based security model for network access. + +use std::collections::HashSet; +use url::Url; + +/// Network allowlist configuration. +/// +/// URLs must match an entry in the allowlist to be accessed. +/// An empty allowlist means all URLs are blocked. +#[derive(Debug, Clone, Default)] +pub struct NetworkAllowlist { + /// URL patterns that are allowed + /// Format: "scheme://host[:port][/path]" + /// Examples: "https://api.example.com", "https://example.com/api" + patterns: HashSet, + + /// If true, allow all URLs (dangerous - use only for testing) + allow_all: bool, +} + +/// Result of matching a URL against the allowlist +#[derive(Debug, Clone, PartialEq)] +pub enum UrlMatch { + /// URL is allowed + Allowed, + /// URL is blocked (not in allowlist) + Blocked { reason: String }, + /// URL is invalid + Invalid { reason: String }, +} + +impl NetworkAllowlist { + /// Create a new empty allowlist (blocks all URLs) + pub fn new() -> Self { + Self::default() + } + + /// Create an allowlist that allows all URLs. + /// + /// # Warning + /// + /// This is dangerous and should only be used for testing or + /// when the script is fully trusted. + pub fn allow_all() -> Self { + Self { + patterns: HashSet::new(), + allow_all: true, + } + } + + /// Add a URL pattern to the allowlist. + /// + /// # Pattern Format + /// + /// Patterns can be: + /// - Full URLs: "https://api.example.com/v1" + /// - Host only: "https://example.com" + /// - With port: "http://localhost:8080" + /// + /// A pattern matches if the requested URL's scheme, host, and port match, + /// and the requested path starts with the pattern's path (if specified). + pub fn allow(mut self, pattern: impl Into) -> Self { + self.patterns.insert(pattern.into()); + self + } + + /// Add multiple URL patterns to the allowlist. + pub fn allow_many(mut self, patterns: impl IntoIterator>) -> Self { + for pattern in patterns { + self.patterns.insert(pattern.into()); + } + self + } + + /// Check if a URL is allowed. + pub fn check(&self, url: &str) -> UrlMatch { + // Allow all if configured + if self.allow_all { + return UrlMatch::Allowed; + } + + // Empty allowlist blocks everything + if self.patterns.is_empty() { + return UrlMatch::Blocked { + reason: "no URLs are allowed (empty allowlist)".to_string(), + }; + } + + // Parse the URL + let parsed = match Url::parse(url) { + Ok(u) => u, + Err(e) => { + return UrlMatch::Invalid { + reason: format!("invalid URL: {}", e), + } + } + }; + + // Check against each pattern + for pattern in &self.patterns { + if self.matches_pattern(&parsed, pattern) { + return UrlMatch::Allowed; + } + } + + UrlMatch::Blocked { + reason: format!("URL not in allowlist: {}", url), + } + } + + /// Check if a parsed URL matches a pattern. + fn matches_pattern(&self, url: &Url, pattern: &str) -> bool { + // Parse the pattern as a URL + let pattern_url = match Url::parse(pattern) { + Ok(u) => u, + Err(_) => return false, + }; + + // Check scheme + if url.scheme() != pattern_url.scheme() { + return false; + } + + // Check host + match (url.host_str(), pattern_url.host_str()) { + (Some(url_host), Some(pattern_host)) => { + if url_host != pattern_host { + return false; + } + } + _ => return false, + } + + // Check port (use default ports if not specified) + let url_port = url.port_or_known_default(); + let pattern_port = pattern_url.port_or_known_default(); + if url_port != pattern_port { + return false; + } + + // Check path prefix (pattern path must be prefix of URL path) + let pattern_path = pattern_url.path(); + let url_path = url.path(); + + // If pattern path is "/" or empty, match any path + if pattern_path == "/" || pattern_path.is_empty() { + return true; + } + + // URL path must start with pattern path + if !url_path.starts_with(pattern_path) { + return false; + } + + // If pattern path doesn't end with /, ensure we're at a path boundary + if !pattern_path.ends_with('/') && url_path.len() > pattern_path.len() { + let next_char = url_path.chars().nth(pattern_path.len()); + if next_char != Some('/') && next_char != Some('?') && next_char != Some('#') { + return false; + } + } + + true + } + + /// Check if network access is enabled (has any patterns or allow_all) + pub fn is_enabled(&self) -> bool { + self.allow_all || !self.patterns.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_empty_allowlist_blocks_all() { + let allowlist = NetworkAllowlist::new(); + assert!(matches!( + allowlist.check("https://example.com"), + UrlMatch::Blocked { .. } + )); + } + + #[test] + fn test_allow_all() { + let allowlist = NetworkAllowlist::allow_all(); + assert_eq!( + allowlist.check("https://example.com"), + UrlMatch::Allowed + ); + assert_eq!( + allowlist.check("http://localhost:8080/anything"), + UrlMatch::Allowed + ); + } + + #[test] + fn test_exact_host_match() { + let allowlist = NetworkAllowlist::new().allow("https://api.example.com"); + + assert_eq!( + allowlist.check("https://api.example.com"), + UrlMatch::Allowed + ); + assert_eq!( + allowlist.check("https://api.example.com/"), + UrlMatch::Allowed + ); + assert_eq!( + allowlist.check("https://api.example.com/v1/users"), + UrlMatch::Allowed + ); + + // Different scheme + assert!(matches!( + allowlist.check("http://api.example.com"), + UrlMatch::Blocked { .. } + )); + + // Different host + assert!(matches!( + allowlist.check("https://other.example.com"), + UrlMatch::Blocked { .. } + )); + } + + #[test] + fn test_path_prefix_match() { + let allowlist = NetworkAllowlist::new().allow("https://api.example.com/v1"); + + // Matches path prefix + assert_eq!( + allowlist.check("https://api.example.com/v1"), + UrlMatch::Allowed + ); + assert_eq!( + allowlist.check("https://api.example.com/v1/"), + UrlMatch::Allowed + ); + assert_eq!( + allowlist.check("https://api.example.com/v1/users"), + UrlMatch::Allowed + ); + + // Does not match different path + assert!(matches!( + allowlist.check("https://api.example.com/v2"), + UrlMatch::Blocked { .. } + )); + + // Does not match partial path component + assert!(matches!( + allowlist.check("https://api.example.com/v10"), + UrlMatch::Blocked { .. } + )); + } + + #[test] + fn test_port_matching() { + let allowlist = NetworkAllowlist::new().allow("http://localhost:8080"); + + assert_eq!( + allowlist.check("http://localhost:8080/api"), + UrlMatch::Allowed + ); + + // Different port + assert!(matches!( + allowlist.check("http://localhost:3000"), + UrlMatch::Blocked { .. } + )); + + // Default HTTP port + assert!(matches!( + allowlist.check("http://localhost"), + UrlMatch::Blocked { .. } + )); + } + + #[test] + fn test_multiple_patterns() { + let allowlist = NetworkAllowlist::new() + .allow("https://api.example.com") + .allow("https://cdn.example.com") + .allow("http://localhost:3000"); + + assert_eq!( + allowlist.check("https://api.example.com/v1"), + UrlMatch::Allowed + ); + assert_eq!( + allowlist.check("https://cdn.example.com/assets/logo.png"), + UrlMatch::Allowed + ); + assert_eq!( + allowlist.check("http://localhost:3000/health"), + UrlMatch::Allowed + ); + + assert!(matches!( + allowlist.check("https://evil.com"), + UrlMatch::Blocked { .. } + )); + } + + #[test] + fn test_invalid_url() { + let allowlist = NetworkAllowlist::new().allow("https://example.com"); + + assert!(matches!( + allowlist.check("not a url"), + UrlMatch::Invalid { .. } + )); + } + + #[test] + fn test_is_enabled() { + let empty = NetworkAllowlist::new(); + assert!(!empty.is_enabled()); + + let with_pattern = NetworkAllowlist::new().allow("https://example.com"); + assert!(with_pattern.is_enabled()); + + let allow_all = NetworkAllowlist::allow_all(); + assert!(allow_all.is_enabled()); + } +} diff --git a/crates/bashkit/src/network/client.rs b/crates/bashkit/src/network/client.rs new file mode 100644 index 00000000..b4fbd856 --- /dev/null +++ b/crates/bashkit/src/network/client.rs @@ -0,0 +1,192 @@ +//! HTTP client for secure network access +//! +//! Provides a sandboxed HTTP client that respects the allowlist. + +use reqwest::Client; +use std::time::Duration; + +use super::allowlist::{NetworkAllowlist, UrlMatch}; +use crate::error::{Error, Result}; + +/// HTTP client with allowlist-based access control. +pub struct HttpClient { + client: Client, + allowlist: NetworkAllowlist, +} + +/// HTTP request method +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Method { + Get, + Post, + Put, + Delete, + Head, + Patch, +} + +impl Method { + fn as_reqwest(self) -> reqwest::Method { + match self { + Method::Get => reqwest::Method::GET, + Method::Post => reqwest::Method::POST, + Method::Put => reqwest::Method::PUT, + Method::Delete => reqwest::Method::DELETE, + Method::Head => reqwest::Method::HEAD, + Method::Patch => reqwest::Method::PATCH, + } + } +} + +/// HTTP response +#[derive(Debug)] +pub struct Response { + /// HTTP status code + pub status: u16, + /// Response headers (key-value pairs) + pub headers: Vec<(String, String)>, + /// Response body + pub body: Vec, +} + +impl Response { + /// Get the body as a UTF-8 string (lossy) + pub fn body_string(&self) -> String { + String::from_utf8_lossy(&self.body).into_owned() + } + + /// Check if the response was successful (2xx status) + pub fn is_success(&self) -> bool { + (200..300).contains(&self.status) + } +} + +impl HttpClient { + /// Create a new HTTP client with the given allowlist. + pub fn new(allowlist: NetworkAllowlist) -> Self { + let client = Client::builder() + .timeout(Duration::from_secs(30)) + .user_agent("bashkit/0.1.0") + .build() + .expect("failed to build HTTP client"); + + Self { client, allowlist } + } + + /// Create a client with custom timeout. + pub fn with_timeout(allowlist: NetworkAllowlist, timeout: Duration) -> Self { + let client = Client::builder() + .timeout(timeout) + .user_agent("bashkit/0.1.0") + .build() + .expect("failed to build HTTP client"); + + Self { client, allowlist } + } + + /// Make a GET request. + pub async fn get(&self, url: &str) -> Result { + self.request(Method::Get, url, None).await + } + + /// Make a POST request with optional body. + pub async fn post(&self, url: &str, body: Option<&[u8]>) -> Result { + self.request(Method::Post, url, body).await + } + + /// Make a PUT request with optional body. + pub async fn put(&self, url: &str, body: Option<&[u8]>) -> Result { + self.request(Method::Put, url, body).await + } + + /// Make a DELETE request. + pub async fn delete(&self, url: &str) -> Result { + self.request(Method::Delete, url, None).await + } + + /// Make an HTTP request. + pub async fn request( + &self, + method: Method, + url: &str, + body: Option<&[u8]>, + ) -> Result { + // Check allowlist + match self.allowlist.check(url) { + UrlMatch::Allowed => {} + UrlMatch::Blocked { reason } => { + return Err(Error::Network(format!("access denied: {}", reason))); + } + UrlMatch::Invalid { reason } => { + return Err(Error::Network(format!("invalid URL: {}", reason))); + } + } + + // Build request + let mut request = self.client.request(method.as_reqwest(), url); + + if let Some(body_data) = body { + request = request.body(body_data.to_vec()); + } + + // Send request + let response = request + .send() + .await + .map_err(|e| Error::Network(format!("request failed: {}", e)))?; + + // Extract response data + let status = response.status().as_u16(); + let headers: Vec<(String, String)> = response + .headers() + .iter() + .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string())) + .collect(); + + let body = response + .bytes() + .await + .map_err(|e| Error::Network(format!("failed to read response: {}", e)))? + .to_vec(); + + Ok(Response { + status, + headers, + body, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_blocked_by_empty_allowlist() { + let client = HttpClient::new(NetworkAllowlist::new()); + + let result = client.get("https://example.com").await; + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("access denied")); + } + + #[tokio::test] + async fn test_blocked_by_allowlist() { + let allowlist = NetworkAllowlist::new().allow("https://allowed.com"); + let client = HttpClient::new(allowlist); + + let result = client.get("https://blocked.com").await; + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("access denied")); + } + + // Note: Integration tests that actually make network requests + // should be in a separate test file and marked with #[ignore] + // to avoid network dependencies in unit tests. +} diff --git a/crates/bashkit/src/network/mod.rs b/crates/bashkit/src/network/mod.rs new file mode 100644 index 00000000..d228cf0f --- /dev/null +++ b/crates/bashkit/src/network/mod.rs @@ -0,0 +1,20 @@ +//! Network layer for BashKit +//! +//! Provides secure network access with URL allowlists. +//! +//! # Security Model +//! +//! - Network access is disabled by default +//! - URLs must match an entry in the allowlist +//! - Allowlist entries can match by scheme, host, and path prefix + +mod allowlist; + +#[cfg(feature = "network")] +mod client; + +#[allow(unused_imports)] // UrlMatch is used internally but may not be exported +pub use allowlist::{NetworkAllowlist, UrlMatch}; + +#[cfg(feature = "network")] +pub use client::HttpClient;