Skip to content

Commit a3dbfdc

Browse files
authored
fix(fetchers): enforce fetch options on YouTube secondary requests (#117)
Run `options.validate_url` against the oEmbed and timedtext URLs before issuing the YouTube secondary requests. Switch the YouTube client to `redirect::Policy::none()` to prevent unvalidated redirect hops. Plugs an egress-policy/SSRF bypass where a `youtu.be` input could lead to unchecked `www.youtube.com` fetches.
1 parent 1315516 commit a3dbfdc

1 file changed

Lines changed: 22 additions & 6 deletions

File tree

crates/fetchkit/src/fetchers/youtube.rs

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ impl Fetcher for YouTubeFetcher {
102102
let mut client_builder = reqwest::Client::builder()
103103
.connect_timeout(API_TIMEOUT)
104104
.timeout(API_TIMEOUT)
105-
.redirect(reqwest::redirect::Policy::limited(3));
105+
.redirect(reqwest::redirect::Policy::none());
106106

107107
if !options.respect_proxy_env {
108108
client_builder = client_builder.no_proxy();
@@ -120,6 +120,7 @@ impl Fetcher for YouTubeFetcher {
120120
// Fetch oEmbed metadata
121121
// The canonical URL only contains safe ASCII chars, so it can be passed directly
122122
let mut oembed_url = Url::parse("https://www.youtube.com/oembed").unwrap();
123+
options.validate_url(&oembed_url)?;
123124
oembed_url
124125
.query_pairs_mut()
125126
.append_pair("url", &canonical_url)
@@ -144,8 +145,7 @@ impl Fetcher for YouTubeFetcher {
144145
let author_url = oembed.as_ref().and_then(|o| o.author_url.clone());
145146

146147
// Attempt transcript extraction via timedtext API
147-
let transcript =
148-
fetch_transcript(&client, &ua_header, &video_id, options.max_body_size).await;
148+
let transcript = fetch_transcript(&client, &ua_header, &video_id, options).await;
149149

150150
let content = format_youtube_response(
151151
&title,
@@ -173,16 +173,19 @@ async fn fetch_transcript(
173173
client: &reqwest::Client,
174174
ua: &HeaderValue,
175175
video_id: &str,
176-
max_body_size: Option<usize>,
176+
options: &FetchOptions,
177177
) -> Option<String> {
178178
// Try the legacy timedtext API (auto-generated English captions)
179179
let timedtext_url = format!(
180180
"https://www.youtube.com/api/timedtext?v={}&lang=en&fmt=srv3",
181181
video_id
182182
);
183183

184+
let timedtext_url = Url::parse(&timedtext_url).ok()?;
185+
options.validate_url(&timedtext_url).ok()?;
186+
184187
let resp = client
185-
.get(&timedtext_url)
188+
.get(timedtext_url.as_str())
186189
.header(USER_AGENT, ua.clone())
187190
.send()
188191
.await
@@ -193,7 +196,7 @@ async fn fetch_transcript(
193196
}
194197

195198
let xml = resp.text().await.ok()?;
196-
if let Some(max_body_size) = max_body_size {
199+
if let Some(max_body_size) = options.max_body_size {
197200
if xml.len() > max_body_size {
198201
return None;
199202
}
@@ -475,6 +478,19 @@ mod tests {
475478
assert!(segments.is_empty());
476479
}
477480

481+
#[tokio::test]
482+
async fn test_fetch_blocked_secondary_host() {
483+
let fetcher = YouTubeFetcher::new();
484+
let request = FetchRequest::new("https://youtu.be/dQw4w9WgXcQ");
485+
let options = FetchOptions {
486+
blocked_hosts: vec![".youtube.com".to_string()],
487+
..Default::default()
488+
};
489+
490+
let result = fetcher.fetch(&request, &options).await;
491+
assert!(matches!(result, Err(FetchError::BlockedUrl)));
492+
}
493+
478494
#[test]
479495
fn test_decode_xml_entities() {
480496
assert_eq!(decode_xml_entities("a &amp; b"), "a & b");

0 commit comments

Comments
 (0)