1use std::time::Duration;
20use ureq::Agent;
21use ureq::http::{Response, StatusCode};
22use ureq::tls::{RootCerts, TlsConfig, TlsProvider};
23use ureq::{Body, Error as UreqError};
24
25const HTTP_TIMEOUT: Duration = Duration::from_secs(30);
27
28const MAX_REDIRECTS: u32 = 5;
36
37pub const MAX_DOWNLOAD_SIZE: u64 = 50 * 1024 * 1024;
39
40const ALLOWED_DOWNLOAD_HOSTS: &[&str] = &[
53 "github.com",
54 "api.github.com",
55 "objects.githubusercontent.com",
56 "github-releases.githubusercontent.com",
57 "release-assets.githubusercontent.com",
58];
59
60pub fn validate_download_url(url: &str) -> Result<(), String> {
70 let parsed =
71 url::Url::parse(url).map_err(|e| format!("Invalid URL '{}': {}", redact_url(url), e))?;
72
73 match parsed.scheme() {
75 "https" => {}
76 scheme => {
77 return Err(format!(
78 "Insecure URL scheme '{}' rejected; only HTTPS is allowed. \
79 URL: {}",
80 scheme,
81 redact_url(url)
82 ));
83 }
84 }
85
86 let host = parsed.host_str().unwrap_or("");
88 if !ALLOWED_DOWNLOAD_HOSTS.contains(&host) {
89 return Err(format!(
90 "URL host '{}' is not in the allowed list for download operations. \
91 Allowed hosts: {}. \
92 URL: {}",
93 host,
94 ALLOWED_DOWNLOAD_HOSTS.join(", "),
95 redact_url(url)
96 ));
97 }
98
99 Ok(())
100}
101
102fn redact_url(url: &str) -> String {
109 match url::Url::parse(url) {
110 Ok(parsed) => format!(
111 "{}://{}{}",
112 parsed.scheme(),
113 parsed.host_str().unwrap_or(""),
114 parsed.path()
115 ),
116 Err(_) => "<unparseable URL>".to_string(),
119 }
120}
121
122fn resolve_redirect(current: &str, location: &str) -> Result<String, String> {
129 let base = url::Url::parse(current)
130 .map_err(|e| format!("Could not parse the current download URL: {}", e))?;
131
132 let resolved = base.join(location).map_err(|e| {
133 format!(
134 "Download server redirected from {} to an unparseable Location: {}. \
135 Download aborted.",
136 redact_url(current),
137 e
138 )
139 })?;
140
141 let resolved = resolved.to_string();
142 if validate_download_url(&resolved).is_err() {
146 return Err(format!(
147 "Download server redirected from {} to {}, which is not an allowed \
148 download host. Download aborted — a redirect cannot move the \
149 download off GitHub. Allowed hosts: {}.",
150 redact_url(current),
151 redact_url(&resolved),
152 ALLOWED_DOWNLOAD_HOSTS.join(", ")
153 ));
154 }
155
156 Ok(resolved)
157}
158
159pub fn agent() -> Agent {
176 build_agent(true)
177}
178
179fn no_redirect_agent() -> Agent {
193 build_agent(false)
194}
195
196fn build_agent(follow_redirects: bool) -> Agent {
198 let tls_config = TlsConfig::builder()
199 .provider(TlsProvider::NativeTls)
200 .root_certs(RootCerts::PlatformVerifier)
201 .build();
202
203 let builder = Agent::config_builder()
204 .tls_config(tls_config)
205 .timeout_global(Some(HTTP_TIMEOUT))
206 .https_only(true);
207
208 let builder = if follow_redirects {
209 builder
210 } else {
211 builder.max_redirects(0)
212 };
213
214 builder.build().into()
215}
216
217fn describe_request_error(url: &str, error: &UreqError) -> String {
219 format!(
220 "Failed to fetch '{}': {}. \
221 Check your internet connection and try again.",
222 redact_url(url),
223 error
224 )
225}
226
227pub fn get_validated(url: &str, accept: Option<&str>) -> Result<Response<Body>, String> {
242 let agent = no_redirect_agent();
243 let mut current = url.to_string();
244
245 for _ in 0..=MAX_REDIRECTS {
246 validate_download_url(¤t)?;
248
249 let mut request = agent.get(¤t).header("User-Agent", "par-term");
250 if let Some(accept) = accept {
251 request = request.header("Accept", accept);
252 }
253
254 let response = request
255 .call()
256 .map_err(|e| describe_request_error(¤t, &e))?;
257
258 let status = response.status();
259 if !status.is_redirection() {
260 return Ok(response);
261 }
262
263 let location = redirect_location(&response, status, ¤t)?;
264 current = resolve_redirect(¤t, &location)?;
266 }
267
268 Err(format!(
269 "Download exceeded {} redirects starting from {}. \
270 Download aborted — this may indicate a redirect loop.",
271 MAX_REDIRECTS,
272 redact_url(url)
273 ))
274}
275
276fn redirect_location(
278 response: &Response<Body>,
279 status: StatusCode,
280 current: &str,
281) -> Result<String, String> {
282 response
283 .headers()
284 .get("location")
285 .and_then(|value| value.to_str().ok())
286 .map(str::to_string)
287 .ok_or_else(|| {
288 format!(
289 "Download server returned redirect status {} from {} with no usable \
290 Location header. Download aborted.",
291 status.as_u16(),
292 redact_url(current)
293 )
294 })
295}
296
297pub fn download_file(url: &str) -> Result<Vec<u8>, String> {
309 get_validated(url, None)?
310 .into_body()
311 .with_config()
312 .limit(MAX_DOWNLOAD_SIZE)
313 .read_to_vec()
314 .map_err(|e| {
315 format!(
316 "Failed to read downloaded content from '{}': {}. \
317 The response may have been truncated or the connection dropped.",
318 redact_url(url),
319 e
320 )
321 })
322}
323
324#[cfg(test)]
325mod tests {
326 use super::*;
327
328 #[test]
331 fn allowlisted_github_hosts_are_accepted() {
332 for url in [
333 "https://api.github.com/repos/paulrobello/par-term/releases/latest",
334 "https://github.com/paulrobello/par-term/releases/download/v1/shaders.zip",
335 "https://objects.githubusercontent.com/asset/123/shaders.zip",
336 "https://github-releases.githubusercontent.com/123/shaders.zip",
337 "https://release-assets.githubusercontent.com/asset/123/shaders.zip",
338 ] {
339 assert!(
340 validate_download_url(url).is_ok(),
341 "expected {url} to be accepted"
342 );
343 }
344 }
345
346 #[test]
347 fn allowlist_contains_the_host_release_downloads_actually_land_on() {
348 assert!(ALLOWED_DOWNLOAD_HOSTS.contains(&"release-assets.githubusercontent.com"));
353 }
354
355 #[test]
356 fn non_https_schemes_are_rejected() {
357 let err = validate_download_url("http://api.github.com/repos/x/y/releases/latest")
358 .expect_err("plain HTTP must be rejected");
359 assert!(err.contains("http"), "error should name the scheme: {err}");
360 assert!(err.contains("HTTPS"), "error should require HTTPS: {err}");
361
362 assert!(validate_download_url("file:///etc/passwd").is_err());
363 }
364
365 #[test]
366 fn off_allowlist_hosts_are_rejected() {
367 let err = validate_download_url("https://evil.example.com/shaders.zip")
368 .expect_err("an off-allowlist host must be rejected");
369 assert!(
370 err.contains("evil.example.com"),
371 "error should name the host: {err}"
372 );
373 assert!(
374 err.contains("allowed list"),
375 "error should mention the allowlist: {err}"
376 );
377
378 assert!(validate_download_url("https://fake.api.github.com/releases").is_err());
380 }
381
382 #[test]
383 fn rejection_messages_do_not_echo_query_credentials() {
384 let err = validate_download_url("https://evil.example.com/x?sig=SECRET&jwt=ALSOSECRET")
385 .expect_err("an off-allowlist host must be rejected");
386 assert!(!err.contains("SECRET"), "credentials leaked into: {err}");
387 }
388
389 #[test]
390 fn unparseable_urls_are_rejected_without_being_echoed() {
391 let err = validate_download_url("not a url?token=SECRET")
392 .expect_err("an unparseable URL must be rejected");
393 assert!(err.contains("Invalid URL"), "unexpected error: {err}");
394 assert!(!err.contains("SECRET"), "credentials leaked into: {err}");
395 }
396
397 #[test]
400 fn redact_url_drops_the_query_string() {
401 let redacted = redact_url(
403 "https://release-assets.githubusercontent.com/asset/1140148702/abc?sig=SECRET&jwt=ALSOSECRET",
404 );
405 assert_eq!(
406 redacted,
407 "https://release-assets.githubusercontent.com/asset/1140148702/abc"
408 );
409 assert!(!redacted.contains("SECRET"));
410 }
411
412 #[test]
413 fn redact_url_drops_userinfo() {
414 assert_eq!(
415 redact_url("https://user:SECRET@github.com/paulrobello/par-term"),
416 "https://github.com/paulrobello/par-term"
417 );
418 }
419
420 #[test]
421 fn redact_url_does_not_echo_an_unparseable_url() {
422 assert_eq!(redact_url("nonsense?token=SECRET"), "<unparseable URL>");
423 }
424
425 #[test]
428 fn redirect_to_an_allowlisted_host_is_accepted() {
429 let resolved = resolve_redirect(
430 "https://github.com/paulrobello/par-term/releases/download/v1/shaders.zip",
431 "https://release-assets.githubusercontent.com/asset/1?sig=abc",
432 )
433 .expect("an allowlisted redirect target must be accepted");
434 assert!(resolved.starts_with("https://release-assets.githubusercontent.com/"));
435 }
436
437 #[test]
438 fn relative_redirect_is_resolved_against_the_current_url() {
439 let resolved = resolve_redirect(
440 "https://github.com/paulrobello/par-term/releases/latest/download/shaders.zip",
441 "/paulrobello/par-term/releases/download/v1/shaders.zip",
442 )
443 .expect("a relative redirect on the same host must be accepted");
444 assert_eq!(
445 resolved,
446 "https://github.com/paulrobello/par-term/releases/download/v1/shaders.zip"
447 );
448 }
449
450 #[test]
451 fn redirect_off_the_allowlist_is_rejected() {
452 let err = resolve_redirect(
453 "https://github.com/paulrobello/par-term/releases/download/v1/shaders.zip",
454 "https://evil.example.com/shaders.zip",
455 )
456 .expect_err("an off-allowlist redirect target must be rejected");
457 assert!(
458 err.contains("evil.example.com"),
459 "error should name the rejected host: {err}"
460 );
461 }
462
463 #[test]
464 fn rejected_redirect_does_not_leak_query_credentials() {
465 let err = resolve_redirect(
468 "https://github.com/paulrobello/par-term/releases/download/v1/shaders.zip",
469 "https://evil.example.com/shaders.zip?sig=SECRET&jwt=ALSOSECRET",
470 )
471 .expect_err("an off-allowlist redirect target must be rejected");
472 assert!(!err.contains("SECRET"), "credentials leaked into: {err}");
473 }
474
475 #[test]
476 fn relative_redirect_cannot_escape_to_another_host() {
477 assert!(
479 resolve_redirect(
480 "https://github.com/paulrobello/par-term/releases/download/v1/shaders.zip",
481 "//evil.example.com/shaders.zip",
482 )
483 .is_err()
484 );
485 }
486
487 #[test]
488 fn redirect_downgrading_to_http_is_rejected() {
489 assert!(
490 resolve_redirect(
491 "https://github.com/paulrobello/par-term/releases/download/v1/shaders.zip",
492 "http://github.com/paulrobello/par-term/releases/download/v1/shaders.zip",
493 )
494 .is_err()
495 );
496 }
497
498 #[test]
505 #[ignore = "requires network access to github.com"]
506 fn live_release_redirect_chain_stays_on_allowlisted_hosts() {
507 let response = get_validated(
508 "https://github.com/paulrobello/par-term/releases/latest/download/shaders.zip",
509 None,
510 )
511 .expect("the real shader download must survive per-hop validation");
512 assert!(
513 response.status().is_success(),
514 "expected a 2xx after following redirects, got {}",
515 response.status()
516 );
517 }
518}