Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 60 additions & 2 deletions crates/bashkit/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -1000,6 +1004,9 @@ pub struct BashBuilder {
/// Network allowlist for curl/wget builtins
#[cfg(feature = "http_client")]
network_allowlist: Option<NetworkAllowlist>,
/// Custom HTTP handler for request interception
#[cfg(feature = "http_client")]
http_handler: Option<Box<dyn network::HttpHandler>>,
/// Logging configuration
#[cfg(feature = "logging")]
log_config: Option<logging::LogConfig>,
Expand Down Expand Up @@ -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<bashkit::network::Response, String> {
/// 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<dyn network::HttpHandler>) -> Self {
self.http_handler = Some(handler);
self
}

/// Configure logging behavior.
///
/// When the `logging` feature is enabled, Bashkit can emit structured logs
Expand Down Expand Up @@ -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")]
Expand Down Expand Up @@ -1710,6 +1764,7 @@ impl BashBuilder {
custom_builtins: HashMap<String, Box<dyn Builtin>>,
history_file: Option<PathBuf>,
#[cfg(feature = "http_client")] network_allowlist: Option<NetworkAllowlist>,
#[cfg(feature = "http_client")] http_handler: Option<Box<dyn network::HttpHandler>>,
#[cfg(feature = "logging")] log_config: Option<logging::LogConfig>,
#[cfg(feature = "git")] git_config: Option<GitConfig>,
) -> Bash {
Expand Down Expand Up @@ -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);
}

Expand Down
69 changes: 69 additions & 0 deletions crates/bashkit/src/network/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Response, String>;
}

/// HTTP client with allowlist-based access control.
///
/// # Security Features
Expand All @@ -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<Box<dyn HttpHandler>>,
}

/// HTTP request method
Expand Down Expand Up @@ -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<dyn HttpHandler>) {
self.handler = Some(handler);
}

fn client(&self) -> Result<&Client> {
let client = self
.client
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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]
Expand Down
2 changes: 1 addition & 1 deletion crates/bashkit/src/network/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
54 changes: 54 additions & 0 deletions crates/bashkit/tests/network_security_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Response, String> {
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"));
}
}
Loading