From 0c9666e96b95da7766b47a43b02a6a77c31bfde7 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Fri, 21 Aug 2026 18:02:30 -0500 Subject: [PATCH 1/2] fix(fetch): enforce URL policy on agent probes --- crates/fetchkit/src/fetchers/default.rs | 32 +++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/crates/fetchkit/src/fetchers/default.rs b/crates/fetchkit/src/fetchers/default.rs index 9d7f7fe..1b894b3 100644 --- a/crates/fetchkit/src/fetchers/default.rs +++ b/crates/fetchkit/src/fetchers/default.rs @@ -786,6 +786,9 @@ pub(crate) async fn send_request_following_redirects( let mut redirect_chain = Vec::new(); for redirect_count in 0..=MAX_REDIRECTS { + // Every outbound request, including generated probes and redirect hops, must + // honor the caller's allow/block prefix policy. + super::validate_url_policy(¤t_url, options)?; // THREAT[TM-AUTH]: re-sign bot-auth headers per hop so each authority is covered. let request_headers = apply_bot_auth_if_enabled(headers.clone(), options, ¤t_url); // THREAT[TM-SSRF-001]/[TM-SSRF-005]: resolve-then-check produces the pinned addrs @@ -2030,6 +2033,35 @@ mod tests { assert!(content.contains("/openapi.json")); } + #[tokio::test] + async fn test_agent_resource_probes_honor_url_prefix_policy() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/docs/page")) + .respond_with( + ResponseTemplate::new(200) + .set_body_string("

Page

") + .insert_header("content-type", "text/html"), + ) + .mount(&server) + .await; + + let fetcher = DefaultFetcher::new(); + let options = FetchOptions { + enable_markdown: true, + allow_prefixes: vec![format!("{}/docs", server.uri())], + dns_policy: DnsPolicy::allow_all(), + ..Default::default() + }; + let request = FetchRequest::new(format!("{}/docs/page", server.uri())).as_markdown(); + fetcher.fetch(&request, &options).await.unwrap(); + + let requests = server.received_requests().await.unwrap(); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].method, reqwest::Method::GET); + assert_eq!(requests[0].url.path(), "/docs/page"); + } + #[tokio::test] async fn test_paywall_detection() { let server = MockServer::start().await; From d610b74d1efac4690534ccc99b6b18e551dd3188 Mon Sep 17 00:00:00 2001 From: "warp-factories[bot]" <243557089+warp-factories[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 01:09:20 +0000 Subject: [PATCH 2/2] test(fetch): cover redirect URL policy --- crates/fetchkit/src/fetchers/default.rs | 34 +++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/crates/fetchkit/src/fetchers/default.rs b/crates/fetchkit/src/fetchers/default.rs index 1b894b3..1c54ac3 100644 --- a/crates/fetchkit/src/fetchers/default.rs +++ b/crates/fetchkit/src/fetchers/default.rs @@ -1448,6 +1448,40 @@ mod tests { assert_eq!(response.status_code, 200); assert_eq!(response.content.as_deref(), Some("redirected")); } + #[tokio::test] + async fn test_redirect_hop_honors_url_prefix_policy() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/docs/start")) + .respond_with( + ResponseTemplate::new(302) + .insert_header("location", format!("{}/private", server.uri())), + ) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/private")) + .respond_with(ResponseTemplate::new(200)) + .mount(&server) + .await; + + let fetcher = DefaultFetcher::new(); + let options = FetchOptions { + allow_prefixes: vec![format!("{}/docs", server.uri())], + dns_policy: DnsPolicy::allow_all(), + ..Default::default() + }; + let request = FetchRequest::new(format!("{}/docs/start", server.uri())); + let result = fetcher.fetch(&request, &options).await; + + assert!(matches!(result, Err(FetchError::BlockedUrl))); + let requests = server.received_requests().await.unwrap(); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].url.path(), "/docs/start"); + assert!(!requests + .iter() + .any(|request| request.url.path() == "/private")); + } /// Build a synthetic redirect [`TransportResponse`] for redirect_target tests. fn redirect_response(status: u16, location: &str) -> TransportResponse {