Skip to main content

microclaw_engine/internal/tool_runtime/
web_fetch.rs

1use std::collections::{HashMap, HashSet};
2use std::sync::{Mutex, OnceLock};
3use std::time::{Duration, Instant};
4
5use microclaw_core::text::floor_char_boundary;
6use reqwest::Url;
7use tracing::warn;
8
9use crate::internal::tool_runtime::web_content_validation::{
10    validate_web_content_with_config, WebContentValidationConfig,
11};
12use crate::internal::tool_runtime::web_html::{extract_primary_html, html_to_text};
13
14fn http_client(timeout_secs: u64) -> reqwest::Client {
15    static CLIENTS: OnceLock<Mutex<HashMap<u64, reqwest::Client>>> = OnceLock::new();
16    let cache = CLIENTS.get_or_init(|| Mutex::new(HashMap::new()));
17    let mut cache = cache.lock().unwrap_or_else(|e| e.into_inner());
18    if let Some(client) = cache.get(&timeout_secs) {
19        return client.clone();
20    }
21    let client = reqwest::Client::builder()
22        .timeout(Duration::from_secs(timeout_secs))
23        .redirect(reqwest::redirect::Policy::limited(5))
24        .user_agent("MicroClaw/1.0")
25        .build()
26        .expect("failed to build HTTP client");
27    cache.insert(timeout_secs, client.clone());
28    client
29}
30
31fn http_client_no_redirect(timeout_secs: u64) -> reqwest::Client {
32    static CLIENTS: OnceLock<Mutex<HashMap<u64, reqwest::Client>>> = OnceLock::new();
33    let cache = CLIENTS.get_or_init(|| Mutex::new(HashMap::new()));
34    let mut cache = cache.lock().unwrap_or_else(|e| e.into_inner());
35    if let Some(client) = cache.get(&timeout_secs) {
36        return client.clone();
37    }
38    let client = reqwest::Client::builder()
39        .timeout(Duration::from_secs(timeout_secs))
40        .redirect(reqwest::redirect::Policy::none())
41        .user_agent("MicroClaw/1.0")
42        .build()
43        .expect("failed to build HTTP client");
44    cache.insert(timeout_secs, client.clone());
45    client
46}
47
48#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
49#[serde(rename_all = "snake_case")]
50pub enum WebFetchFeedMode {
51    Allowlist,
52    Denylist,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
56#[serde(rename_all = "snake_case")]
57pub enum WebFetchFeedFormat {
58    Lines,
59    CsvFirstColumn,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
63pub struct WebFetchFeedSource {
64    #[serde(default = "default_enabled")]
65    pub enabled: bool,
66    #[serde(default = "default_feed_mode")]
67    pub mode: WebFetchFeedMode,
68    pub url: String,
69    #[serde(default = "default_feed_format")]
70    pub format: WebFetchFeedFormat,
71    #[serde(default = "default_feed_refresh_interval_secs")]
72    pub refresh_interval_secs: u64,
73    #[serde(default = "default_feed_timeout_secs")]
74    pub timeout_secs: u64,
75}
76
77#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
78pub struct WebFetchFeedSyncConfig {
79    #[serde(default)]
80    pub enabled: bool,
81    #[serde(default = "default_feed_fail_open")]
82    pub fail_open: bool,
83    #[serde(default = "default_feed_max_entries_per_source")]
84    pub max_entries_per_source: usize,
85    #[serde(default)]
86    pub sources: Vec<WebFetchFeedSource>,
87}
88
89#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
90pub struct WebFetchUrlValidationConfig {
91    #[serde(default = "default_enabled")]
92    pub enabled: bool,
93    #[serde(default = "default_allowed_schemes")]
94    pub allowed_schemes: Vec<String>,
95    #[serde(default)]
96    pub allowlist_hosts: Vec<String>,
97    #[serde(default)]
98    pub denylist_hosts: Vec<String>,
99    #[serde(default = "default_block_private_ips")]
100    pub block_private_ips: bool,
101    #[serde(default)]
102    pub feed_sync: WebFetchFeedSyncConfig,
103}
104
105struct FeedCacheEntry {
106    fetched_at: Instant,
107    entries: Vec<String>,
108}
109
110const fn default_enabled() -> bool {
111    true
112}
113
114const fn default_feed_mode() -> WebFetchFeedMode {
115    WebFetchFeedMode::Denylist
116}
117
118const fn default_feed_format() -> WebFetchFeedFormat {
119    WebFetchFeedFormat::Lines
120}
121
122const fn default_feed_refresh_interval_secs() -> u64 {
123    3600
124}
125
126const fn default_feed_timeout_secs() -> u64 {
127    10
128}
129
130const fn default_feed_fail_open() -> bool {
131    true
132}
133
134const fn default_feed_max_entries_per_source() -> usize {
135    10_000
136}
137
138const fn default_block_private_ips() -> bool {
139    true
140}
141
142fn default_allowed_schemes() -> Vec<String> {
143    vec!["https".to_string(), "http".to_string()]
144}
145
146impl Default for WebFetchFeedSyncConfig {
147    fn default() -> Self {
148        Self {
149            enabled: false,
150            fail_open: default_feed_fail_open(),
151            max_entries_per_source: default_feed_max_entries_per_source(),
152            sources: Vec::new(),
153        }
154    }
155}
156
157impl WebFetchFeedSyncConfig {
158    pub fn normalize(&mut self) {
159        if self.max_entries_per_source == 0 {
160            self.max_entries_per_source = default_feed_max_entries_per_source();
161        }
162
163        for source in &mut self.sources {
164            source.url = source.url.trim().to_string();
165            if source.refresh_interval_secs == 0 {
166                source.refresh_interval_secs = default_feed_refresh_interval_secs();
167            }
168            if source.timeout_secs == 0 {
169                source.timeout_secs = default_feed_timeout_secs();
170            }
171        }
172
173        self.sources.retain(|s| !s.url.is_empty());
174    }
175}
176
177impl Default for WebFetchUrlValidationConfig {
178    fn default() -> Self {
179        Self {
180            enabled: default_enabled(),
181            allowed_schemes: default_allowed_schemes(),
182            allowlist_hosts: Vec::new(),
183            denylist_hosts: Vec::new(),
184            block_private_ips: default_block_private_ips(),
185            feed_sync: WebFetchFeedSyncConfig::default(),
186        }
187    }
188}
189
190impl WebFetchUrlValidationConfig {
191    pub fn normalize(&mut self) {
192        self.allowed_schemes = self
193            .allowed_schemes
194            .drain(..)
195            .map(|v| v.trim().to_ascii_lowercase())
196            .filter(|v| !v.is_empty())
197            .collect();
198        if self.allowed_schemes.is_empty() {
199            self.allowed_schemes = default_allowed_schemes();
200        }
201
202        normalize_host_list(&mut self.allowlist_hosts);
203        normalize_host_list(&mut self.denylist_hosts);
204        self.feed_sync.normalize();
205    }
206}
207
208fn normalize_host_list(hosts: &mut Vec<String>) {
209    let mut seen = HashSet::new();
210    let mut normalized = Vec::new();
211
212    for host in hosts.drain(..) {
213        let Some(host) = normalize_host_candidate(&host) else {
214            continue;
215        };
216        if seen.insert(host.clone()) {
217            normalized.push(host);
218        }
219    }
220
221    *hosts = normalized;
222}
223
224fn normalize_host_candidate(input: &str) -> Option<String> {
225    let mut token = input.trim().to_string();
226    if token.is_empty() {
227        return None;
228    }
229
230    // Support feeds that include full URLs.
231    if let Ok(parsed) = Url::parse(&token) {
232        token = parsed.host_str()?.to_string();
233    }
234
235    // Support CSV and path-like entries.
236    if let Some((head, _)) = token.split_once('/') {
237        token = head.to_string();
238    }
239
240    token = token
241        .trim()
242        .trim_start_matches("*.")
243        .trim_start_matches('.')
244        .to_string();
245
246    // Drop :port for domain and IPv4 host entries.
247    if token.matches(':').count() == 1 {
248        if let Some((head, _)) = token.split_once(':') {
249            token = head.to_string();
250        }
251    }
252
253    token = token.trim_end_matches('.').to_ascii_lowercase();
254    if token.is_empty() {
255        None
256    } else {
257        Some(token)
258    }
259}
260
261fn host_matches_rule(host: &str, rule: &str) -> bool {
262    host == rule || host.ends_with(&format!(".{rule}"))
263}
264
265fn resolve_and_validate_redirect_target(
266    current_url: &Url,
267    location: &str,
268    url_validation: &WebFetchUrlValidationConfig,
269) -> Result<Url, String> {
270    let next = current_url
271        .join(location)
272        .map_err(|e| format!("invalid redirect target '{location}': {e}"))?;
273    validate_web_fetch_url(next.as_str(), url_validation.clone())?;
274    Ok(next)
275}
276
277fn feed_cache() -> &'static Mutex<HashMap<String, FeedCacheEntry>> {
278    static CACHE: OnceLock<Mutex<HashMap<String, FeedCacheEntry>>> = OnceLock::new();
279    CACHE.get_or_init(|| Mutex::new(HashMap::new()))
280}
281
282fn parse_feed_entries(raw: &str, format: &WebFetchFeedFormat, max_entries: usize) -> Vec<String> {
283    let mut seen = HashSet::new();
284    let mut out = Vec::new();
285
286    for line in raw.lines() {
287        let line = line.trim();
288        if line.is_empty() || line.starts_with('#') {
289            continue;
290        }
291
292        let candidate = match format {
293            WebFetchFeedFormat::Lines => line,
294            WebFetchFeedFormat::CsvFirstColumn => line.split(',').next().unwrap_or(""),
295        };
296
297        let Some(host) = normalize_host_candidate(candidate) else {
298            continue;
299        };
300
301        if seen.insert(host.clone()) {
302            out.push(host);
303            if out.len() >= max_entries {
304                break;
305            }
306        }
307    }
308
309    out
310}
311
312async fn fetch_feed_entries(
313    source: &WebFetchFeedSource,
314    fail_open: bool,
315    max_entries_per_source: usize,
316) -> Result<Vec<String>, String> {
317    if let Some(inline) = source.url.strip_prefix("inline:") {
318        return Ok(parse_feed_entries(
319            inline,
320            &source.format,
321            max_entries_per_source,
322        ));
323    }
324
325    let cache_key = format!("{}::{:?}", source.url, source.format);
326    let ttl = Duration::from_secs(source.refresh_interval_secs.max(1));
327
328    let stale_entries = {
329        let cache = feed_cache().lock().unwrap_or_else(|e| e.into_inner());
330        if let Some(entry) = cache.get(&cache_key) {
331            if entry.fetched_at.elapsed() < ttl {
332                return Ok(entry.entries.clone());
333            }
334            Some(entry.entries.clone())
335        } else {
336            None
337        }
338    };
339
340    let request_result = async {
341        let client = http_client(source.timeout_secs.max(1));
342        let resp = client
343            .get(&source.url)
344            .send()
345            .await
346            .map_err(|e| e.to_string())?;
347        if !resp.status().is_success() {
348            return Err(format!("feed returned HTTP {}", resp.status()));
349        }
350        let body = resp.text().await.map_err(|e| e.to_string())?;
351        Ok(parse_feed_entries(
352            &body,
353            &source.format,
354            max_entries_per_source,
355        ))
356    }
357    .await;
358
359    match request_result {
360        Ok(entries) => {
361            let mut cache = feed_cache().lock().unwrap_or_else(|e| e.into_inner());
362            cache.insert(
363                cache_key,
364                FeedCacheEntry {
365                    fetched_at: Instant::now(),
366                    entries: entries.clone(),
367                },
368            );
369            Ok(entries)
370        }
371        Err(err) => {
372            if let Some(stale) = stale_entries {
373                warn!(
374                    url = source.url,
375                    error = err,
376                    "Using stale feed cache after refresh error"
377                );
378                return Ok(stale);
379            }
380            if fail_open {
381                warn!(
382                    url = source.url,
383                    error = err,
384                    "Skipping feed due to fail_open=true"
385                );
386                Ok(Vec::new())
387            } else {
388                Err(format!("feed fetch failed for '{}': {err}", source.url))
389            }
390        }
391    }
392}
393
394pub async fn resolve_url_validation_config(
395    mut config: WebFetchUrlValidationConfig,
396) -> Result<WebFetchUrlValidationConfig, String> {
397    config.normalize();
398    if !config.enabled || !config.feed_sync.enabled || config.feed_sync.sources.is_empty() {
399        return Ok(config);
400    }
401
402    let fail_open = config.feed_sync.fail_open;
403    let max_entries_per_source = config.feed_sync.max_entries_per_source;
404    for source in config.feed_sync.sources.iter().filter(|s| s.enabled) {
405        let entries = fetch_feed_entries(source, fail_open, max_entries_per_source).await?;
406        match source.mode {
407            WebFetchFeedMode::Allowlist => config.allowlist_hosts.extend(entries),
408            WebFetchFeedMode::Denylist => config.denylist_hosts.extend(entries),
409        }
410    }
411
412    normalize_host_list(&mut config.allowlist_hosts);
413    normalize_host_list(&mut config.denylist_hosts);
414    Ok(config)
415}
416
417pub fn validate_web_fetch_url(
418    raw_url: &str,
419    mut config: WebFetchUrlValidationConfig,
420) -> Result<(), String> {
421    if !config.enabled {
422        return Ok(());
423    }
424    config.normalize();
425
426    let parsed = Url::parse(raw_url).map_err(|e| format!("invalid URL: {e}"))?;
427    let scheme = parsed.scheme().to_ascii_lowercase();
428    if !config.allowed_schemes.iter().any(|s| s == &scheme) {
429        return Err(format!(
430            "URL scheme '{}' is not allowed (allowed: {})",
431            scheme,
432            config.allowed_schemes.join(", ")
433        ));
434    }
435
436    let host = parsed
437        .host_str()
438        .ok_or_else(|| "URL must include a host".to_string())?
439        .to_ascii_lowercase();
440
441    if config
442        .denylist_hosts
443        .iter()
444        .any(|rule| host_matches_rule(&host, rule))
445    {
446        return Err(format!("URL host '{}' is denylisted", host));
447    }
448
449    if !config.allowlist_hosts.is_empty()
450        && !config
451            .allowlist_hosts
452            .iter()
453            .any(|rule| host_matches_rule(&host, rule))
454    {
455        return Err(format!("URL host '{}' is not in allowlist", host));
456    }
457
458    // SSRF guard: resolve hostname and reject private/internal targets unless
459    // the host is explicitly on the allowlist (operator opt-out).
460    if config.block_private_ips {
461        let allowlisted = config
462            .allowlist_hosts
463            .iter()
464            .any(|rule| host_matches_rule(&host, rule));
465        if !allowlisted {
466            crate::internal::tool_runtime::url_safety::check_url_private_ip(&parsed)?;
467        }
468    }
469
470    Ok(())
471}
472
473pub async fn fetch_url_with_timeout(url: &str, timeout_secs: u64) -> Result<String, String> {
474    fetch_url_with_timeout_and_validation(
475        url,
476        timeout_secs,
477        WebContentValidationConfig::default(),
478        WebFetchUrlValidationConfig::default(),
479    )
480    .await
481}
482
483pub async fn fetch_url_with_timeout_and_validation(
484    url: &str,
485    timeout_secs: u64,
486    validation: WebContentValidationConfig,
487    url_validation: WebFetchUrlValidationConfig,
488) -> Result<String, String> {
489    let effective_url_validation = resolve_url_validation_config(url_validation).await?;
490    validate_web_fetch_url(url, effective_url_validation.clone())?;
491
492    let client = http_client_no_redirect(timeout_secs.max(1));
493    let mut current_url = Url::parse(url).map_err(|e| format!("invalid URL: {e}"))?;
494    let mut redirects = 0usize;
495
496    let resp = loop {
497        let resp = client
498            .get(current_url.clone())
499            .send()
500            .await
501            .map_err(|e| e.to_string())?;
502
503        if !resp.status().is_redirection() {
504            break resp;
505        }
506
507        if redirects >= 5 {
508            return Err("too many redirects (max 5)".to_string());
509        }
510        redirects += 1;
511
512        let location = resp
513            .headers()
514            .get(reqwest::header::LOCATION)
515            .ok_or_else(|| "redirect response missing Location header".to_string())?
516            .to_str()
517            .map_err(|e| format!("invalid redirect Location header: {e}"))?;
518        current_url = resolve_and_validate_redirect_target(
519            &current_url,
520            location,
521            &effective_url_validation,
522        )?;
523    };
524
525    if !resp.status().is_success() {
526        return Err(format!("HTTP {}", resp.status()));
527    }
528
529    let body = resp.text().await.map_err(|e| e.to_string())?;
530    let primary = extract_primary_html(&body);
531    let text = html_to_text(primary);
532
533    if let Err(failure) = validate_web_content_with_config(&text, validation) {
534        warn!(
535            matched_rules = failure.rule_names.join(","),
536            "Blocked web_fetch content by validation"
537        );
538        return Err(failure.message());
539    }
540
541    const MAX_BYTES: usize = 20_000;
542    if text.len() > MAX_BYTES {
543        let truncated = &text[..floor_char_boundary(&text, MAX_BYTES)];
544        Ok(format!("{truncated}\n\n[Truncated at 20KB]"))
545    } else {
546        Ok(text)
547    }
548}
549
550pub async fn fetch_url(url: &str) -> Result<String, String> {
551    fetch_url_with_timeout(url, 15).await
552}
553
554#[cfg(test)]
555mod tests {
556    use std::sync::atomic::{AtomicBool, Ordering};
557    use std::sync::Arc;
558
559    use reqwest::Url;
560    use tokio::io::{AsyncReadExt, AsyncWriteExt};
561    use tokio::net::TcpListener;
562    use tokio::time::{timeout, Duration};
563
564    use super::{
565        fetch_url_with_timeout_and_validation, resolve_and_validate_redirect_target,
566        resolve_url_validation_config, validate_web_fetch_url, WebFetchFeedFormat,
567        WebFetchFeedMode, WebFetchFeedSource, WebFetchFeedSyncConfig, WebFetchUrlValidationConfig,
568    };
569    use crate::internal::tool_runtime::web_content_validation::WebContentValidationConfig;
570
571    #[test]
572    fn url_validation_allows_default_http_https() {
573        assert!(validate_web_fetch_url(
574            "https://example.com",
575            WebFetchUrlValidationConfig::default()
576        )
577        .is_ok());
578        assert!(validate_web_fetch_url(
579            "http://example.com/docs",
580            WebFetchUrlValidationConfig::default()
581        )
582        .is_ok());
583    }
584
585    #[test]
586    fn url_validation_blocks_scheme() {
587        let err =
588            validate_web_fetch_url("ftp://example.com", WebFetchUrlValidationConfig::default())
589                .unwrap_err();
590        assert!(err.contains("not allowed"));
591    }
592
593    #[test]
594    fn url_validation_blocks_denylist() {
595        let cfg = WebFetchUrlValidationConfig {
596            denylist_hosts: vec!["example.com".into()],
597            ..WebFetchUrlValidationConfig::default()
598        };
599        let err = validate_web_fetch_url("https://sub.example.com", cfg).unwrap_err();
600        assert!(err.contains("denylisted"));
601    }
602
603    #[test]
604    fn url_validation_enforces_allowlist() {
605        let cfg = WebFetchUrlValidationConfig {
606            allowlist_hosts: vec!["allowed.com".into()],
607            ..WebFetchUrlValidationConfig::default()
608        };
609        assert!(validate_web_fetch_url("https://api.allowed.com/v1", cfg.clone()).is_ok());
610        let err = validate_web_fetch_url("https://other.com", cfg).unwrap_err();
611        assert!(err.contains("allowlist"));
612    }
613
614    #[test]
615    fn url_validation_denylist_precedes_allowlist() {
616        let cfg = WebFetchUrlValidationConfig {
617            allowlist_hosts: vec!["example.com".into()],
618            denylist_hosts: vec!["example.com".into()],
619            ..WebFetchUrlValidationConfig::default()
620        };
621        let err = validate_web_fetch_url("https://api.example.com", cfg).unwrap_err();
622        assert!(err.contains("denylisted"));
623    }
624
625    #[test]
626    fn url_validation_can_be_disabled() {
627        let cfg = WebFetchUrlValidationConfig {
628            enabled: false,
629            allowed_schemes: vec!["https".into()],
630            allowlist_hosts: vec!["allowed.com".into()],
631            denylist_hosts: vec!["allowed.com".into()],
632            block_private_ips: true,
633            feed_sync: WebFetchFeedSyncConfig::default(),
634        };
635        assert!(validate_web_fetch_url("ftp://bad", cfg).is_ok());
636    }
637
638    #[tokio::test]
639    async fn feed_sync_merges_denylist_entries() {
640        let cfg = WebFetchUrlValidationConfig {
641            feed_sync: WebFetchFeedSyncConfig {
642                enabled: true,
643                fail_open: false,
644                max_entries_per_source: 100,
645                sources: vec![WebFetchFeedSource {
646                    enabled: true,
647                    mode: WebFetchFeedMode::Denylist,
648                    url: "inline:#comment\nblocked.example\n".to_string(),
649                    format: WebFetchFeedFormat::Lines,
650                    refresh_interval_secs: 3600,
651                    timeout_secs: 5,
652                }],
653            },
654            ..WebFetchUrlValidationConfig::default()
655        };
656
657        let resolved = resolve_url_validation_config(cfg).await.unwrap();
658        assert!(resolved
659            .denylist_hosts
660            .iter()
661            .any(|h| h == "blocked.example"));
662    }
663
664    #[tokio::test]
665    async fn feed_sync_supports_csv_first_column() {
666        let cfg = WebFetchUrlValidationConfig {
667            feed_sync: WebFetchFeedSyncConfig {
668                enabled: true,
669                fail_open: false,
670                max_entries_per_source: 100,
671                sources: vec![WebFetchFeedSource {
672                    enabled: true,
673                    mode: WebFetchFeedMode::Denylist,
674                    url: "inline:host,score\nfoo.example,99\nbar.example,88\n".to_string(),
675                    format: WebFetchFeedFormat::CsvFirstColumn,
676                    refresh_interval_secs: 3600,
677                    timeout_secs: 5,
678                }],
679            },
680            ..WebFetchUrlValidationConfig::default()
681        };
682
683        let resolved = resolve_url_validation_config(cfg).await.unwrap();
684        assert!(resolved.denylist_hosts.iter().any(|h| h == "foo.example"));
685        assert!(resolved.denylist_hosts.iter().any(|h| h == "bar.example"));
686    }
687
688    #[tokio::test]
689    async fn feed_sync_merges_allowlist_and_denylist_sources() {
690        let cfg = WebFetchUrlValidationConfig {
691            feed_sync: WebFetchFeedSyncConfig {
692                enabled: true,
693                fail_open: false,
694                max_entries_per_source: 100,
695                sources: vec![
696                    WebFetchFeedSource {
697                        enabled: true,
698                        mode: WebFetchFeedMode::Allowlist,
699                        url: "inline:https://allowed.example/path\n".to_string(),
700                        format: WebFetchFeedFormat::Lines,
701                        refresh_interval_secs: 3600,
702                        timeout_secs: 5,
703                    },
704                    WebFetchFeedSource {
705                        enabled: true,
706                        mode: WebFetchFeedMode::Denylist,
707                        url: "inline:blocked.example\n".to_string(),
708                        format: WebFetchFeedFormat::Lines,
709                        refresh_interval_secs: 3600,
710                        timeout_secs: 5,
711                    },
712                ],
713            },
714            ..WebFetchUrlValidationConfig::default()
715        };
716
717        let resolved = resolve_url_validation_config(cfg).await.unwrap();
718        assert!(resolved
719            .allowlist_hosts
720            .iter()
721            .any(|h| h == "allowed.example"));
722        assert!(resolved
723            .denylist_hosts
724            .iter()
725            .any(|h| h == "blocked.example"));
726    }
727
728    #[tokio::test]
729    async fn feed_sync_skips_disabled_sources() {
730        let cfg = WebFetchUrlValidationConfig {
731            feed_sync: WebFetchFeedSyncConfig {
732                enabled: true,
733                fail_open: false,
734                max_entries_per_source: 100,
735                sources: vec![WebFetchFeedSource {
736                    enabled: false,
737                    mode: WebFetchFeedMode::Denylist,
738                    url: "inline:blocked.example\n".to_string(),
739                    format: WebFetchFeedFormat::Lines,
740                    refresh_interval_secs: 3600,
741                    timeout_secs: 5,
742                }],
743            },
744            ..WebFetchUrlValidationConfig::default()
745        };
746
747        let resolved = resolve_url_validation_config(cfg).await.unwrap();
748        assert!(resolved.denylist_hosts.is_empty());
749    }
750
751    #[tokio::test]
752    async fn feed_sync_fail_closed_returns_error() {
753        let cfg = WebFetchUrlValidationConfig {
754            feed_sync: WebFetchFeedSyncConfig {
755                enabled: true,
756                fail_open: false,
757                max_entries_per_source: 100,
758                sources: vec![WebFetchFeedSource {
759                    enabled: true,
760                    mode: WebFetchFeedMode::Denylist,
761                    url: "http://127.0.0.1:9/unreachable".to_string(),
762                    format: WebFetchFeedFormat::Lines,
763                    refresh_interval_secs: 3600,
764                    timeout_secs: 1,
765                }],
766            },
767            ..WebFetchUrlValidationConfig::default()
768        };
769
770        let err = resolve_url_validation_config(cfg).await.unwrap_err();
771        assert!(err.contains("feed fetch failed"));
772    }
773
774    #[tokio::test]
775    async fn feed_sync_fail_open_skips_error() {
776        let cfg = WebFetchUrlValidationConfig {
777            feed_sync: WebFetchFeedSyncConfig {
778                enabled: true,
779                fail_open: true,
780                max_entries_per_source: 100,
781                sources: vec![WebFetchFeedSource {
782                    enabled: true,
783                    mode: WebFetchFeedMode::Denylist,
784                    url: "http://127.0.0.1:9/unreachable".to_string(),
785                    format: WebFetchFeedFormat::Lines,
786                    refresh_interval_secs: 3600,
787                    timeout_secs: 1,
788                }],
789            },
790            ..WebFetchUrlValidationConfig::default()
791        };
792
793        let resolved = resolve_url_validation_config(cfg).await.unwrap();
794        assert!(resolved.denylist_hosts.is_empty());
795    }
796
797    #[test]
798    fn redirect_validation_blocks_denylisted_target() {
799        let current = Url::parse("https://safe.example/start").unwrap();
800        let cfg = WebFetchUrlValidationConfig {
801            denylist_hosts: vec!["blocked.example".to_string()],
802            ..WebFetchUrlValidationConfig::default()
803        };
804        let err =
805            resolve_and_validate_redirect_target(&current, "https://blocked.example/path", &cfg)
806                .unwrap_err();
807        assert!(err.contains("denylisted"));
808    }
809
810    #[test]
811    fn redirect_validation_allows_relative_target() {
812        let current = Url::parse("https://safe.example/start").unwrap();
813        // Disable SSRF check for a hostname that doesn't resolve publicly.
814        let cfg = WebFetchUrlValidationConfig {
815            block_private_ips: false,
816            ..WebFetchUrlValidationConfig::default()
817        };
818        let next = resolve_and_validate_redirect_target(&current, "/next", &cfg).unwrap();
819        assert_eq!(next.as_str(), "https://safe.example/next");
820    }
821
822    #[tokio::test]
823    async fn fetch_blocks_redirect_to_denylisted_host() {
824        let final_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
825        let final_addr = final_listener.local_addr().unwrap();
826        let final_hit = Arc::new(AtomicBool::new(false));
827        let final_hit_clone = final_hit.clone();
828        let final_server = tokio::spawn(async move {
829            if let Ok(Ok((mut stream, _))) =
830                timeout(Duration::from_secs(2), final_listener.accept()).await
831            {
832                final_hit_clone.store(true, Ordering::SeqCst);
833                let mut buf = [0u8; 1024];
834                let _ = stream.read(&mut buf).await;
835                let _ = stream
836                    .write_all(
837                        b"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok",
838                    )
839                    .await;
840            }
841        });
842
843        let redirect_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
844        let redirect_addr = redirect_listener.local_addr().unwrap();
845        let redirect_server = tokio::spawn(async move {
846            let (mut stream, _) = redirect_listener.accept().await.unwrap();
847            let mut buf = [0u8; 1024];
848            let _ = stream.read(&mut buf).await;
849            let response = format!(
850                "HTTP/1.1 302 Found\r\nLocation: http://127.0.0.1:{}/final\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
851                final_addr.port()
852            );
853            let _ = stream.write_all(response.as_bytes()).await;
854        });
855
856        let start_url = format!("http://localhost:{}/start", redirect_addr.port());
857        let url_cfg = WebFetchUrlValidationConfig {
858            allowlist_hosts: vec!["localhost".to_string()],
859            denylist_hosts: vec!["127.0.0.1".to_string()],
860            ..WebFetchUrlValidationConfig::default()
861        };
862        let err = fetch_url_with_timeout_and_validation(
863            &start_url,
864            5,
865            WebContentValidationConfig::default(),
866            url_cfg,
867        )
868        .await
869        .unwrap_err();
870
871        redirect_server.await.unwrap();
872        final_server.await.unwrap();
873
874        assert!(err.contains("denylisted"));
875        assert!(
876            !final_hit.load(Ordering::SeqCst),
877            "should not request redirect target after URL policy rejection"
878        );
879    }
880}