Skip to main content

par_term/
http.rs

1//! HTTP client helper with native-tls support.
2//!
3//! This module provides a configured HTTP agent that uses native-tls
4//! for TLS connections, which works better in VM environments where
5//! ring/rustls may have issues.
6//!
7//! # Security
8//!
9//! [`validate_download_url`] enforces HTTPS-only and a host allowlist for
10//! shader-download URLs, matching the validation used by the self-update
11//! subsystem in `par-term-update`. [`get_validated`] additionally re-applies
12//! that allowlist to *every* redirect hop rather than only to the URL the caller
13//! passed in.
14//!
15//! Per-hop revalidation is hardening, not a fix for an observed exploit: no
16//! redirect off an allowlisted GitHub host has been demonstrated. It closes the
17//! gap that the allowlist was only ever checked once, before the first request.
18
19use std::time::Duration;
20use ureq::Agent;
21use ureq::http::{Response, StatusCode};
22use ureq::tls::{RootCerts, TlsConfig, TlsProvider};
23use ureq::{Body, Error as UreqError};
24
25/// Global timeout for all HTTP operations (30 seconds).
26const HTTP_TIMEOUT: Duration = Duration::from_secs(30);
27
28/// Maximum number of redirects followed manually by [`get_validated`].
29///
30/// A `shaders.zip` download takes two hops, measured against the live release:
31/// `github.com/…/releases/latest/download/shaders.zip` →
32/// `github.com/…/releases/download/<tag>/shaders.zip` →
33/// `release-assets.githubusercontent.com/…`. This leaves headroom for GitHub to
34/// add another hop without letting a redirect loop run indefinitely.
35const MAX_REDIRECTS: u32 = 5;
36
37/// Maximum response body size for file downloads (50 MB).
38pub const MAX_DOWNLOAD_SIZE: u64 = 50 * 1024 * 1024;
39
40/// Allowlisted hostnames for shader-download network requests.
41///
42/// Only requests to GitHub's primary API and CDN hosts are permitted.
43/// Any other host is rejected regardless of the URL path, preventing
44/// SSRF or DNS-rebinding attacks that could redirect download traffic
45/// to an attacker-controlled server.
46///
47/// `release-assets.githubusercontent.com` is where GitHub currently terminates
48/// a release-asset download, and it is **load-bearing**: `shaders.zip`'s
49/// `browser_download_url` redirects there, so removing it breaks every download
50/// once redirects are revalidated per hop. The two older `*.githubusercontent.com`
51/// CDN names are kept because GitHub has rotated this host before.
52const 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
60/// Validate that a URL is safe to use for shader download operations.
61///
62/// Enforces:
63/// - HTTPS scheme only (no HTTP, ftp, file://, etc.)
64/// - Host must be in the GitHub allowlist
65///
66/// Returns `Ok(())` if the URL is acceptable, or an error string describing
67/// why it was rejected. URLs are redacted in those messages — see
68/// [`redact_url`].
69pub 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    // Enforce HTTPS only — plain HTTP can be intercepted and downgraded.
74    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    // Enforce domain allowlist — reject any host not operated by GitHub.
87    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
102/// Render a URL for logs and error messages with its query string removed.
103///
104/// GitHub's final release-asset hop carries short-lived credentials in the query
105/// (`?sig=…&jwt=…`). Those are bearer-equivalent, so the full URL must never
106/// reach the debug log — only scheme, host and path do. Userinfo is dropped for
107/// the same reason.
108fn 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        // Unparseable URLs never reach the network, and printing the raw string
117        // would defeat the redaction, so describe it instead of echoing it.
118        Err(_) => "<unparseable URL>".to_string(),
119    }
120}
121
122/// Resolve a `Location` header against the URL that produced it, then validate it.
123///
124/// `Location` is allowed to be relative (RFC 9110 §10.2.2), so it is joined onto
125/// the current URL before the allowlist is applied. Both halves matter: joining
126/// without validating is the open-redirect hole, and validating without joining
127/// rejects legitimate relative redirects.
128fn 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    // Deliberately does *not* forward `validate_download_url`'s message: naming
143    // the allowlist twice in one error reads badly, and the caller needs to see
144    // both ends of the redirect.
145    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
159/// Create a new HTTP agent configured with native-tls and a global timeout.
160///
161/// This explicitly configures native-tls as the TLS provider, which uses
162/// the system's TLS library (Schannel on Windows, OpenSSL on Linux,
163/// Security.framework on macOS).
164///
165/// We use PlatformVerifier to use the system's built-in root certificates.
166///
167/// A global timeout of 30 seconds is applied to prevent hanging on
168/// unresponsive servers. Callers reading response bodies should use
169/// `body.with_config().limit(N)` to enforce size limits.
170///
171/// `https_only(true)` means a redirect that downgrades the chain to plain HTTP
172/// fails instead of being followed. This agent still follows redirects itself,
173/// so it does **not** revalidate the host per hop; callers that need that must
174/// use [`get_validated`].
175pub fn agent() -> Agent {
176    build_agent(true)
177}
178
179/// Agent used by [`get_validated`], with ureq's own redirect following disabled.
180///
181/// ureq's default is to follow up to ten redirects with no revalidation, which
182/// would let an allowlisted host hand the download off to anywhere. Setting
183/// `max_redirects(0)` surfaces each 3xx to [`get_validated`], which re-applies
184/// the allowlist before making the next request.
185///
186/// Zero specifically is load-bearing, and raising it to a small non-zero number
187/// to "let ureq handle a hop" would break every download. ureq gates erroring on
188/// `max_redirects > 0 && max_redirects_will_error`, and the latter defaults to
189/// true, so only zero returns the 3xx response that [`get_validated`] needs in
190/// order to read `Location`. Any other value turns a redirect into
191/// `TooManyRedirects` instead.
192fn no_redirect_agent() -> Agent {
193    build_agent(false)
194}
195
196/// Shared TLS, timeout and HTTPS configuration for both agents.
197fn 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
217/// Describe a failed request without leaking the query string.
218fn 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
227/// Perform a GET, following redirects manually with the allowlist re-applied at
228/// every hop.
229///
230/// The URL the caller passes is validated before the first request, and each
231/// `Location` is resolved and validated before the next one. A redirect to a
232/// host outside `ALLOWED_DOWNLOAD_HOSTS`, a redirect with no `Location`, and a
233/// chain longer than [`MAX_REDIRECTS`] are all hard errors — none of them fall
234/// through to reading a body. A 3xx body is never read.
235///
236/// # Errors
237///
238/// Returns an error if the URL or any redirect target fails allowlist
239/// validation, if the request fails (DNS, connection, TLS), if a redirect
240/// carries no usable `Location`, or if the chain exceeds [`MAX_REDIRECTS`].
241pub 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        // Re-validated on every iteration, not just the first.
247        validate_download_url(&current)?;
248
249        let mut request = agent.get(&current).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(&current, &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, &current)?;
264        // The 3xx body is intentionally never read.
265        current = resolve_redirect(&current, &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
276/// Extract the `Location` header from a redirect response.
277fn 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
297/// Download a file from a URL and return its bytes.
298///
299/// Validates the URL against the allowlist before making any network request,
300/// and again at every redirect hop. The body is limited to [`MAX_DOWNLOAD_SIZE`]
301/// (50 MB) to prevent memory exhaustion from a malicious or misbehaving server.
302///
303/// # Errors
304///
305/// Returns an error if the URL, or any redirect target, fails allowlist
306/// validation; if the HTTP request fails; or if reading the body fails or
307/// exceeds the size limit.
308pub 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    // --- validate_download_url ---
329
330    #[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        // Load-bearing: `shaders.zip`'s `browser_download_url` 302s here, and
349        // per-hop validation means dropping this host breaks every shader
350        // download. Verified against the live chain by
351        // `live_release_redirect_chain_stays_on_allowlisted_hosts`.
352        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        // A subdomain of an allowed host is not the allowed host.
379        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    // --- redact_url ---
398
399    #[test]
400    fn redact_url_drops_the_query_string() {
401        // GitHub's final asset hop carries `sig` and `jwt` credentials here.
402        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    // --- resolve_redirect ---
426
427    #[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        // The rejection message must name the host without echoing the query,
466        // because a GitHub asset URL carries `sig` and `jwt` credentials there.
467        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        // A protocol-relative Location changes host while looking relative.
478        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    /// Live check that the real release-download chain still terminates on an
499    /// allowlisted host, and that `max_redirects(0)` surfaces the 3xx to
500    /// [`get_validated`] rather than erroring.
501    ///
502    /// Ignored by default because it needs the network. Run with
503    /// `cargo test -p par-term --lib -- --ignored redirect_chain`.
504    #[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}