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 if let Ok(parsed) = Url::parse(&token) {
232 token = parsed.host_str()?.to_string();
233 }
234
235 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 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 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 ¤t_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(¤t, "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 let cfg = WebFetchUrlValidationConfig {
815 block_private_ips: false,
816 ..WebFetchUrlValidationConfig::default()
817 };
818 let next = resolve_and_validate_redirect_target(¤t, "/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}