Skip to content

Commit 9339490

Browse files
authored
feat(network): custom HTTP handler / fetch interception callback (#921)
Closes #911
1 parent e97a1ff commit 9339490

File tree

4 files changed

+184
-3
lines changed

4 files changed

+184
-3
lines changed

crates/bashkit/src/lib.rs

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -451,7 +451,11 @@ pub use scripted_tool::{
451451
};
452452

453453
#[cfg(feature = "http_client")]
454-
pub use network::HttpClient;
454+
pub use network::{HttpClient, HttpHandler};
455+
456+
/// Re-exported network response type for custom HTTP handler implementations.
457+
#[cfg(feature = "http_client")]
458+
pub use network::Response as HttpResponse;
455459

456460
#[cfg(feature = "git")]
457461
pub use git::GitClient;
@@ -1000,6 +1004,9 @@ pub struct BashBuilder {
10001004
/// Network allowlist for curl/wget builtins
10011005
#[cfg(feature = "http_client")]
10021006
network_allowlist: Option<NetworkAllowlist>,
1007+
/// Custom HTTP handler for request interception
1008+
#[cfg(feature = "http_client")]
1009+
http_handler: Option<Box<dyn network::HttpHandler>>,
10031010
/// Logging configuration
10041011
#[cfg(feature = "logging")]
10051012
log_config: Option<logging::LogConfig>,
@@ -1167,6 +1174,51 @@ impl BashBuilder {
11671174
self
11681175
}
11691176

1177+
/// Set a custom HTTP handler for request interception.
1178+
///
1179+
/// The handler is called after the URL allowlist check, so the security
1180+
/// boundary stays in bashkit. Use this for:
1181+
/// - Corporate proxies
1182+
/// - Logging/auditing
1183+
/// - Caching responses
1184+
/// - Rate limiting
1185+
/// - Mocking HTTP responses in tests
1186+
///
1187+
/// # Example
1188+
///
1189+
/// ```ignore
1190+
/// use bashkit::network::HttpHandler;
1191+
///
1192+
/// struct MyHandler;
1193+
///
1194+
/// #[async_trait::async_trait]
1195+
/// impl HttpHandler for MyHandler {
1196+
/// async fn request(
1197+
/// &self,
1198+
/// method: &str,
1199+
/// url: &str,
1200+
/// body: Option<&[u8]>,
1201+
/// headers: &[(String, String)],
1202+
/// ) -> Result<bashkit::network::Response, String> {
1203+
/// Ok(bashkit::network::Response {
1204+
/// status: 200,
1205+
/// headers: vec![],
1206+
/// body: b"mocked".to_vec(),
1207+
/// })
1208+
/// }
1209+
/// }
1210+
///
1211+
/// let bash = Bash::builder()
1212+
/// .network(NetworkAllowlist::allow_all())
1213+
/// .http_handler(Box::new(MyHandler))
1214+
/// .build();
1215+
/// ```
1216+
#[cfg(feature = "http_client")]
1217+
pub fn http_handler(mut self, handler: Box<dyn network::HttpHandler>) -> Self {
1218+
self.http_handler = Some(handler);
1219+
self
1220+
}
1221+
11701222
/// Configure logging behavior.
11711223
///
11721224
/// When the `logging` feature is enabled, Bashkit can emit structured logs
@@ -1625,6 +1677,8 @@ impl BashBuilder {
16251677
self.history_file,
16261678
#[cfg(feature = "http_client")]
16271679
self.network_allowlist,
1680+
#[cfg(feature = "http_client")]
1681+
self.http_handler,
16281682
#[cfg(feature = "logging")]
16291683
self.log_config,
16301684
#[cfg(feature = "git")]
@@ -1710,6 +1764,7 @@ impl BashBuilder {
17101764
custom_builtins: HashMap<String, Box<dyn Builtin>>,
17111765
history_file: Option<PathBuf>,
17121766
#[cfg(feature = "http_client")] network_allowlist: Option<NetworkAllowlist>,
1767+
#[cfg(feature = "http_client")] http_handler: Option<Box<dyn network::HttpHandler>>,
17131768
#[cfg(feature = "logging")] log_config: Option<logging::LogConfig>,
17141769
#[cfg(feature = "git")] git_config: Option<GitConfig>,
17151770
) -> Bash {
@@ -1754,7 +1809,10 @@ impl BashBuilder {
17541809
// Configure HTTP client for network builtins
17551810
#[cfg(feature = "http_client")]
17561811
if let Some(allowlist) = network_allowlist {
1757-
let client = network::HttpClient::new(allowlist);
1812+
let mut client = network::HttpClient::new(allowlist);
1813+
if let Some(handler) = http_handler {
1814+
client.set_handler(handler);
1815+
}
17581816
interpreter.set_http_client(client);
17591817
}
17601818

crates/bashkit/src/network/client.rs

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,31 @@ pub const MAX_TIMEOUT_SECS: u64 = 600;
3434
/// Minimum allowed timeout (1 second) - prevents instant timeouts that waste resources
3535
pub const MIN_TIMEOUT_SECS: u64 = 1;
3636

37+
/// Trait for custom HTTP request handling.
38+
///
39+
/// Embedders can implement this trait to intercept, proxy, log, cache,
40+
/// or mock HTTP requests made by scripts running in the sandbox.
41+
///
42+
/// The allowlist check happens _before_ the handler is called, so the
43+
/// security boundary stays in bashkit.
44+
///
45+
/// # Default
46+
///
47+
/// When no custom handler is set, `HttpClient` uses `reqwest` directly.
48+
#[async_trait::async_trait]
49+
pub trait HttpHandler: Send + Sync {
50+
/// Handle an HTTP request and return a response.
51+
///
52+
/// Called after the URL has been validated against the allowlist.
53+
async fn request(
54+
&self,
55+
method: &str,
56+
url: &str,
57+
body: Option<&[u8]>,
58+
headers: &[(String, String)],
59+
) -> std::result::Result<Response, String>;
60+
}
61+
3762
/// HTTP client with allowlist-based access control.
3863
///
3964
/// # Security Features
@@ -48,6 +73,8 @@ pub struct HttpClient {
4873
default_timeout: Duration,
4974
/// Maximum response body size in bytes
5075
max_response_bytes: usize,
76+
/// Optional custom HTTP handler for request interception
77+
handler: Option<Box<dyn HttpHandler>>,
5178
}
5279

5380
/// HTTP request method
@@ -134,9 +161,19 @@ impl HttpClient {
134161
allowlist,
135162
default_timeout: timeout,
136163
max_response_bytes,
164+
handler: None,
137165
}
138166
}
139167

168+
/// Set a custom HTTP handler for request interception.
169+
///
170+
/// The handler is called after the URL allowlist check, so the security
171+
/// boundary stays in bashkit. The default reqwest-based handler is used
172+
/// when no custom handler is set.
173+
pub fn set_handler(&mut self, handler: Box<dyn HttpHandler>) {
174+
self.handler = Some(handler);
175+
}
176+
140177
fn client(&self) -> Result<&Client> {
141178
let client = self
142179
.client
@@ -201,6 +238,22 @@ impl HttpClient {
201238
}
202239
}
203240

241+
// Delegate to custom handler if set
242+
if let Some(handler) = &self.handler {
243+
let method_str = match method {
244+
Method::Get => "GET",
245+
Method::Post => "POST",
246+
Method::Put => "PUT",
247+
Method::Delete => "DELETE",
248+
Method::Head => "HEAD",
249+
Method::Patch => "PATCH",
250+
};
251+
return handler
252+
.request(method_str, url, body, headers)
253+
.await
254+
.map_err(Error::Network);
255+
}
256+
204257
// Build request
205258
let mut request = self.client()?.request(method.as_reqwest(), url);
206259

@@ -353,6 +406,22 @@ impl HttpClient {
353406
}
354407
}
355408

409+
// Delegate to custom handler if set (timeouts are the handler's responsibility)
410+
if let Some(handler) = &self.handler {
411+
let method_str = match method {
412+
Method::Get => "GET",
413+
Method::Post => "POST",
414+
Method::Put => "PUT",
415+
Method::Delete => "DELETE",
416+
Method::Head => "HEAD",
417+
Method::Patch => "PATCH",
418+
};
419+
return handler
420+
.request(method_str, url, body, headers)
421+
.await
422+
.map_err(Error::Network);
423+
}
424+
356425
// Use the custom timeout client if any timeout is specified, otherwise use default client
357426
let client = if timeout_secs.is_some() || connect_timeout_secs.is_some() {
358427
// Clamp timeout values to safe range [MIN_TIMEOUT_SECS, MAX_TIMEOUT_SECS]

crates/bashkit/src/network/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,4 +86,4 @@ mod client;
8686
pub use allowlist::{NetworkAllowlist, UrlMatch};
8787

8888
#[cfg(feature = "http_client")]
89-
pub use client::{HttpClient, Method, Response};
89+
pub use client::{HttpClient, HttpHandler, Method, Response};

crates/bashkit/tests/network_security_tests.rs

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -842,3 +842,57 @@ mod decompression_security {
842842
assert!(result.stdout.contains("access denied") || result.stderr.contains("access denied"));
843843
}
844844
}
845+
846+
// =============================================================================
847+
// CUSTOM HTTP HANDLER TESTS
848+
// =============================================================================
849+
850+
mod custom_handler {
851+
use super::*;
852+
use bashkit::{HttpHandler, HttpResponse as Response};
853+
854+
struct MockHandler;
855+
856+
#[async_trait::async_trait]
857+
impl HttpHandler for MockHandler {
858+
async fn request(
859+
&self,
860+
_method: &str,
861+
_url: &str,
862+
_body: Option<&[u8]>,
863+
_headers: &[(String, String)],
864+
) -> std::result::Result<Response, String> {
865+
Ok(Response {
866+
status: 200,
867+
headers: vec![("content-type".to_string(), "text/plain".to_string())],
868+
body: b"mocked-response".to_vec(),
869+
})
870+
}
871+
}
872+
873+
#[tokio::test]
874+
async fn custom_handler_intercepts_requests() {
875+
let allowlist = NetworkAllowlist::allow_all();
876+
let mut bash = Bash::builder()
877+
.network(allowlist)
878+
.http_handler(Box::new(MockHandler))
879+
.build();
880+
881+
let result = bash.exec("curl -s https://example.com").await.unwrap();
882+
assert_eq!(result.stdout.trim(), "mocked-response");
883+
}
884+
885+
#[tokio::test]
886+
async fn custom_handler_allowlist_still_enforced() {
887+
// Even with a custom handler, the allowlist should be checked first
888+
let allowlist = NetworkAllowlist::new(); // empty = blocks all
889+
let mut bash = Bash::builder()
890+
.network(allowlist)
891+
.http_handler(Box::new(MockHandler))
892+
.build();
893+
894+
let result = bash.exec("curl -s https://example.com 2>&1").await.unwrap();
895+
// Should be blocked by allowlist, not reaching the handler
896+
assert!(result.stdout.contains("access denied") || result.stderr.contains("access denied"));
897+
}
898+
}

0 commit comments

Comments
 (0)