From e2a0f38e08fd7c4b35da07856861c05f99e3d3c6 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Fri, 24 Jul 2026 01:10:59 -0500 Subject: [PATCH] fix(fetchers): enforce policy for API subrequests --- crates/fetchkit/src/fetchers/default.rs | 2 + .../src/fetchers/github_actions_run.rs | 30 +++++++++++ crates/fetchkit/src/fetchers/mod.rs | 54 ++++++++++--------- 3 files changed, 62 insertions(+), 24 deletions(-) diff --git a/crates/fetchkit/src/fetchers/default.rs b/crates/fetchkit/src/fetchers/default.rs index a738c09..8443ecd 100644 --- a/crates/fetchkit/src/fetchers/default.rs +++ b/crates/fetchkit/src/fetchers/default.rs @@ -616,6 +616,8 @@ pub(crate) async fn transport_request( pin_host: &str, pin_port: u16, ) -> Result { + super::validate_policy_url(&url, options)?; + let transport = options.transport(); let transport_method = if method == reqwest::Method::HEAD { TransportMethod::Head diff --git a/crates/fetchkit/src/fetchers/github_actions_run.rs b/crates/fetchkit/src/fetchers/github_actions_run.rs index 4bdf830..1acfe45 100644 --- a/crates/fetchkit/src/fetchers/github_actions_run.rs +++ b/crates/fetchkit/src/fetchers/github_actions_run.rs @@ -347,6 +347,36 @@ mod tests { } } + #[tokio::test] + async fn enforces_policy_on_api_requests_before_transport() { + let fetcher = GitHubActionsRunFetcher::new(); + let request = FetchRequest::new("http://github.com/o/r/actions/runs/123"); + let options = FetchOptions { + allow_prefixes: vec!["http://github.com/o/r/actions/runs".to_string()], + block_prefixes: vec!["https://api.github.com/".to_string()], + allowed_ports: vec![80], + blocked_hosts: vec!["api.github.com".to_string()], + dns_policy: crate::DnsPolicy::allow_all(), + transport: Some(std::sync::Arc::new(PanicTransport)), + ..Default::default() + }; + + let error = fetcher.fetch(&request, &options).await.unwrap_err(); + assert!(matches!(error, FetchError::BlockedUrl)); + } + + struct PanicTransport; + + #[async_trait::async_trait] + impl crate::transport::HttpTransport for PanicTransport { + async fn execute( + &self, + _req: crate::transport::TransportRequest, + ) -> Result { + panic!("policy must block before transport execution"); + } + } + #[test] fn formats_run_jobs_and_failed_steps() { let run = WorkflowRun { diff --git a/crates/fetchkit/src/fetchers/mod.rs b/crates/fetchkit/src/fetchers/mod.rs index 2d1d02e..835a322 100644 --- a/crates/fetchkit/src/fetchers/mod.rs +++ b/crates/fetchkit/src/fetchers/mod.rs @@ -210,30 +210,7 @@ impl FetcherRegistry { let parsed_url = Url::parse(&request.url).map_err(|_| FetchError::InvalidUrlScheme)?; - options.validate_url(&parsed_url)?; - - // THREAT[TM-INPUT-002]: Normalize URL before prefix matching to prevent - // encoding-based bypasses (case, trailing dots, default ports) - // THREAT[TM-INPUT-007]: URL-aware prefix matching prevents subdomain tricks - if !options.allow_prefixes.is_empty() { - let allowed = options - .allow_prefixes - .iter() - .any(|prefix| url_matches_policy_prefix(&parsed_url, prefix)); - if !allowed { - debug!(url = %request.url, "URL not in allow list"); - return Err(FetchError::BlockedUrl); - } - } - - if options - .block_prefixes - .iter() - .any(|prefix| url_matches_policy_prefix(&parsed_url, prefix)) - { - debug!(url = %request.url, "URL matched block list"); - return Err(FetchError::BlockedUrl); - } + validate_policy_url(&parsed_url, options)?; for fetcher in &self.fetchers { if fetcher.matches(&parsed_url) { @@ -304,6 +281,35 @@ async fn preflight_save_path<'a>( // before comparison, preventing bypasses via encoding, case, or trailing dots. // THREAT[TM-INPUT-007]: Compares parsed URL components (scheme, host, path) instead of // raw strings, so "http://internal.example.com" won't match "http://internal.example.com.evil.com". +pub(crate) fn validate_policy_url(url: &Url, options: &FetchOptions) -> Result<(), FetchError> { + options.validate_url(url)?; + + // THREAT[TM-INPUT-002]: Normalize URL before prefix matching to prevent + // encoding-based bypasses (case, trailing dots, default ports). + // THREAT[TM-INPUT-007]: URL-aware prefix matching prevents subdomain tricks. + if !options.allow_prefixes.is_empty() { + let allowed = options + .allow_prefixes + .iter() + .any(|prefix| url_matches_policy_prefix(url, prefix)); + if !allowed { + debug!(%url, "URL not in allow list"); + return Err(FetchError::BlockedUrl); + } + } + + if options + .block_prefixes + .iter() + .any(|prefix| url_matches_policy_prefix(url, prefix)) + { + debug!(%url, "URL matched block list"); + return Err(FetchError::BlockedUrl); + } + + Ok(()) +} + fn url_matches_policy_prefix(url: &Url, prefix: &str) -> bool { let Ok(prefix_url) = Url::parse(prefix) else { tracing::warn!(