From 3c9a0e9f961c94f88face244f66a8ab576d2c71c Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Tue, 31 Mar 2026 10:34:17 +0000 Subject: [PATCH] feat(network): custom HTTP handler / fetch interception callback Add HttpHandler trait that embedders can implement to intercept, proxy, log, cache, or mock HTTP requests. The allowlist check happens before the handler is called, preserving the security boundary. Includes builder API (.http_handler()) and integration tests. Closes #911 --- crates/bashkit/src/lib.rs | 62 ++++++++++++++++- crates/bashkit/src/network/client.rs | 69 +++++++++++++++++++ crates/bashkit/src/network/mod.rs | 2 +- .../bashkit/tests/network_security_tests.rs | 54 +++++++++++++++ 4 files changed, 184 insertions(+), 3 deletions(-) diff --git a/crates/bashkit/src/lib.rs b/crates/bashkit/src/lib.rs index 7e52e4d4..9b95a321 100644 --- a/crates/bashkit/src/lib.rs +++ b/crates/bashkit/src/lib.rs @@ -451,7 +451,11 @@ pub use scripted_tool::{ }; #[cfg(feature = "http_client")] -pub use network::HttpClient; +pub use network::{HttpClient, HttpHandler}; + +/// Re-exported network response type for custom HTTP handler implementations. +#[cfg(feature = "http_client")] +pub use network::Response as HttpResponse; #[cfg(feature = "git")] pub use git::GitClient; @@ -1000,6 +1004,9 @@ pub struct BashBuilder { /// Network allowlist for curl/wget builtins #[cfg(feature = "http_client")] network_allowlist: Option, + /// Custom HTTP handler for request interception + #[cfg(feature = "http_client")] + http_handler: Option>, /// Logging configuration #[cfg(feature = "logging")] log_config: Option, @@ -1167,6 +1174,51 @@ impl BashBuilder { self } + /// Set a custom HTTP handler for request interception. + /// + /// The handler is called after the URL allowlist check, so the security + /// boundary stays in bashkit. Use this for: + /// - Corporate proxies + /// - Logging/auditing + /// - Caching responses + /// - Rate limiting + /// - Mocking HTTP responses in tests + /// + /// # Example + /// + /// ```ignore + /// use bashkit::network::HttpHandler; + /// + /// struct MyHandler; + /// + /// #[async_trait::async_trait] + /// impl HttpHandler for MyHandler { + /// async fn request( + /// &self, + /// method: &str, + /// url: &str, + /// body: Option<&[u8]>, + /// headers: &[(String, String)], + /// ) -> Result { + /// Ok(bashkit::network::Response { + /// status: 200, + /// headers: vec![], + /// body: b"mocked".to_vec(), + /// }) + /// } + /// } + /// + /// let bash = Bash::builder() + /// .network(NetworkAllowlist::allow_all()) + /// .http_handler(Box::new(MyHandler)) + /// .build(); + /// ``` + #[cfg(feature = "http_client")] + pub fn http_handler(mut self, handler: Box) -> Self { + self.http_handler = Some(handler); + self + } + /// Configure logging behavior. /// /// When the `logging` feature is enabled, Bashkit can emit structured logs @@ -1625,6 +1677,8 @@ impl BashBuilder { self.history_file, #[cfg(feature = "http_client")] self.network_allowlist, + #[cfg(feature = "http_client")] + self.http_handler, #[cfg(feature = "logging")] self.log_config, #[cfg(feature = "git")] @@ -1710,6 +1764,7 @@ impl BashBuilder { custom_builtins: HashMap>, history_file: Option, #[cfg(feature = "http_client")] network_allowlist: Option, + #[cfg(feature = "http_client")] http_handler: Option>, #[cfg(feature = "logging")] log_config: Option, #[cfg(feature = "git")] git_config: Option, ) -> Bash { @@ -1754,7 +1809,10 @@ impl BashBuilder { // Configure HTTP client for network builtins #[cfg(feature = "http_client")] if let Some(allowlist) = network_allowlist { - let client = network::HttpClient::new(allowlist); + let mut client = network::HttpClient::new(allowlist); + if let Some(handler) = http_handler { + client.set_handler(handler); + } interpreter.set_http_client(client); } diff --git a/crates/bashkit/src/network/client.rs b/crates/bashkit/src/network/client.rs index 5b4213c3..aed5d5c0 100644 --- a/crates/bashkit/src/network/client.rs +++ b/crates/bashkit/src/network/client.rs @@ -34,6 +34,31 @@ pub const MAX_TIMEOUT_SECS: u64 = 600; /// Minimum allowed timeout (1 second) - prevents instant timeouts that waste resources pub const MIN_TIMEOUT_SECS: u64 = 1; +/// Trait for custom HTTP request handling. +/// +/// Embedders can implement this trait to intercept, proxy, log, cache, +/// or mock HTTP requests made by scripts running in the sandbox. +/// +/// The allowlist check happens _before_ the handler is called, so the +/// security boundary stays in bashkit. +/// +/// # Default +/// +/// When no custom handler is set, `HttpClient` uses `reqwest` directly. +#[async_trait::async_trait] +pub trait HttpHandler: Send + Sync { + /// Handle an HTTP request and return a response. + /// + /// Called after the URL has been validated against the allowlist. + async fn request( + &self, + method: &str, + url: &str, + body: Option<&[u8]>, + headers: &[(String, String)], + ) -> std::result::Result; +} + /// HTTP client with allowlist-based access control. /// /// # Security Features @@ -48,6 +73,8 @@ pub struct HttpClient { default_timeout: Duration, /// Maximum response body size in bytes max_response_bytes: usize, + /// Optional custom HTTP handler for request interception + handler: Option>, } /// HTTP request method @@ -134,9 +161,19 @@ impl HttpClient { allowlist, default_timeout: timeout, max_response_bytes, + handler: None, } } + /// Set a custom HTTP handler for request interception. + /// + /// The handler is called after the URL allowlist check, so the security + /// boundary stays in bashkit. The default reqwest-based handler is used + /// when no custom handler is set. + pub fn set_handler(&mut self, handler: Box) { + self.handler = Some(handler); + } + fn client(&self) -> Result<&Client> { let client = self .client @@ -201,6 +238,22 @@ impl HttpClient { } } + // Delegate to custom handler if set + if let Some(handler) = &self.handler { + let method_str = match method { + Method::Get => "GET", + Method::Post => "POST", + Method::Put => "PUT", + Method::Delete => "DELETE", + Method::Head => "HEAD", + Method::Patch => "PATCH", + }; + return handler + .request(method_str, url, body, headers) + .await + .map_err(Error::Network); + } + // Build request let mut request = self.client()?.request(method.as_reqwest(), url); @@ -353,6 +406,22 @@ impl HttpClient { } } + // Delegate to custom handler if set (timeouts are the handler's responsibility) + if let Some(handler) = &self.handler { + let method_str = match method { + Method::Get => "GET", + Method::Post => "POST", + Method::Put => "PUT", + Method::Delete => "DELETE", + Method::Head => "HEAD", + Method::Patch => "PATCH", + }; + return handler + .request(method_str, url, body, headers) + .await + .map_err(Error::Network); + } + // Use the custom timeout client if any timeout is specified, otherwise use default client let client = if timeout_secs.is_some() || connect_timeout_secs.is_some() { // Clamp timeout values to safe range [MIN_TIMEOUT_SECS, MAX_TIMEOUT_SECS] diff --git a/crates/bashkit/src/network/mod.rs b/crates/bashkit/src/network/mod.rs index c758c941..933afccc 100644 --- a/crates/bashkit/src/network/mod.rs +++ b/crates/bashkit/src/network/mod.rs @@ -86,4 +86,4 @@ mod client; pub use allowlist::{NetworkAllowlist, UrlMatch}; #[cfg(feature = "http_client")] -pub use client::{HttpClient, Method, Response}; +pub use client::{HttpClient, HttpHandler, Method, Response}; diff --git a/crates/bashkit/tests/network_security_tests.rs b/crates/bashkit/tests/network_security_tests.rs index 6cc22c2c..0e9ea9a3 100644 --- a/crates/bashkit/tests/network_security_tests.rs +++ b/crates/bashkit/tests/network_security_tests.rs @@ -842,3 +842,57 @@ mod decompression_security { assert!(result.stdout.contains("access denied") || result.stderr.contains("access denied")); } } + +// ============================================================================= +// CUSTOM HTTP HANDLER TESTS +// ============================================================================= + +mod custom_handler { + use super::*; + use bashkit::{HttpHandler, HttpResponse as Response}; + + struct MockHandler; + + #[async_trait::async_trait] + impl HttpHandler for MockHandler { + async fn request( + &self, + _method: &str, + _url: &str, + _body: Option<&[u8]>, + _headers: &[(String, String)], + ) -> std::result::Result { + Ok(Response { + status: 200, + headers: vec![("content-type".to_string(), "text/plain".to_string())], + body: b"mocked-response".to_vec(), + }) + } + } + + #[tokio::test] + async fn custom_handler_intercepts_requests() { + let allowlist = NetworkAllowlist::allow_all(); + let mut bash = Bash::builder() + .network(allowlist) + .http_handler(Box::new(MockHandler)) + .build(); + + let result = bash.exec("curl -s https://example.com").await.unwrap(); + assert_eq!(result.stdout.trim(), "mocked-response"); + } + + #[tokio::test] + async fn custom_handler_allowlist_still_enforced() { + // Even with a custom handler, the allowlist should be checked first + let allowlist = NetworkAllowlist::new(); // empty = blocks all + let mut bash = Bash::builder() + .network(allowlist) + .http_handler(Box::new(MockHandler)) + .build(); + + let result = bash.exec("curl -s https://example.com 2>&1").await.unwrap(); + // Should be blocked by allowlist, not reaching the handler + assert!(result.stdout.contains("access denied") || result.stderr.contains("access denied")); + } +}