Skip to main content

par_term/profile/dynamic/
fetch.rs

1//! HTTP fetch logic for remote dynamic profile sources.
2//!
3//! Fetches YAML profile lists from remote URLs with:
4//! - HTTPS-only policy (HTTP requires explicit opt-in)
5//! - Authentication header protection over HTTP
6//! - Configurable timeouts and response size limits
7//! - Automatic cache write on successful fetch
8
9use anyhow::Context;
10use par_term_config::DynamicProfileSource;
11
12use crate::profile::dynamic::cache::write_cache;
13
14/// Result of fetching profiles from a remote source.
15#[derive(Debug, Clone)]
16pub struct FetchResult {
17    /// The source URL that was fetched.
18    pub url: String,
19    /// Successfully parsed profiles (empty on error).
20    pub profiles: Vec<par_term_config::Profile>,
21    /// HTTP ETag header from the response.
22    pub etag: Option<String>,
23    /// Error message if the fetch failed.
24    pub error: Option<String>,
25}
26
27/// Fetch profiles from a remote URL.
28pub fn fetch_profiles(source: &DynamicProfileSource) -> FetchResult {
29    let url = &source.url;
30    crate::debug_info!("DYNAMIC_PROFILE", "Fetching profiles from {}", url);
31
32    match fetch_profiles_inner(source) {
33        Ok((profiles, etag)) => {
34            crate::debug_info!(
35                "DYNAMIC_PROFILE",
36                "Fetched {} profiles from {}",
37                profiles.len(),
38                url
39            );
40            if let Err(e) = write_cache(url, &profiles, etag.clone()) {
41                crate::debug_error!(
42                    "DYNAMIC_PROFILE",
43                    "Failed to cache profiles from {}: {}",
44                    url,
45                    e
46                );
47            }
48            FetchResult {
49                url: url.clone(),
50                profiles,
51                etag,
52                error: None,
53            }
54        }
55        Err(e) => {
56            crate::debug_error!("DYNAMIC_PROFILE", "Failed to fetch from {}: {}", url, e);
57            FetchResult {
58                url: url.clone(),
59                profiles: Vec::new(),
60                etag: None,
61                error: Some(e.to_string()),
62            }
63        }
64    }
65}
66
67/// Internal fetch implementation.
68fn fetch_profiles_inner(
69    source: &DynamicProfileSource,
70) -> anyhow::Result<(Vec<par_term_config::Profile>, Option<String>)> {
71    use par_term_config::url_policy::{Transport, validate_scheme};
72    use ureq::tls::{RootCerts, TlsConfig, TlsProvider};
73
74    // SECURITY: Profile data fetched over plain HTTP can be intercepted and
75    // replaced by a network-level attacker (MITM). A malicious profile could
76    // influence shell execution, environment, or other terminal behaviour.
77    // HTTPS is the default requirement; HTTP is an explicit opt-in via
78    // `allow_http_profiles: true`.
79    //
80    // ARC-006: the scheme policy is shared with the other caller-supplied-URL
81    // paths so the copies cannot drift. It allowlists https (plus http under the
82    // opt-in) rather than denylisting `file://`, which is what previously let
83    // `ftp:`, `data:` and single-slash `file:` through once the opt-in was set.
84    let transport = validate_scheme(&source.url, source.allow_http)
85        .map_err(|e| anyhow::anyhow!("Dynamic profile source rejected: {e}"))?;
86
87    if transport == Transport::Http {
88        // The opt-in permits plaintext transport, but never plaintext credentials:
89        // refuse the fetch outright rather than send these headers in the clear.
90        if source.headers.keys().any(|k| {
91            let lower = k.to_lowercase();
92            lower == "authorization" || lower.contains("token") || lower.contains("secret")
93        }) {
94            anyhow::bail!(
95                "Refusing to send authentication headers over insecure HTTP for {}. Use HTTPS.",
96                source.url
97            );
98        }
99
100        // User has explicitly opted in to HTTP — warn but proceed.
101        crate::debug_error!(
102            "DYNAMIC_PROFILE",
103            "SECURITY WARNING: {} is using insecure HTTP (not HTTPS). \
104             A MITM attacker could inject malicious profiles. Use HTTPS.",
105            source.url
106        );
107        log::warn!(
108            "par-term dynamic profile: fetching '{}' over insecure HTTP \
109             (allow_http_profiles is enabled). MITM injection of profiles is possible. \
110             Switch to HTTPS when possible.",
111            source.url
112        );
113    }
114
115    // Create an agent with the source-specific timeout
116    let tls_config = TlsConfig::builder()
117        .provider(TlsProvider::NativeTls)
118        .root_certs(RootCerts::PlatformVerifier)
119        .build();
120
121    let agent: ureq::Agent = ureq::Agent::config_builder()
122        .tls_config(tls_config)
123        // ureq follows up to 10 redirects and does not re-run the scheme check on
124        // each hop, so without this a `https://` source that answers 302 with an
125        // `http://` Location would be fetched in the clear — defeating the policy
126        // validated above. `https_only` applies to the whole redirect chain.
127        .https_only(transport == Transport::Https)
128        .timeout_global(Some(std::time::Duration::from_secs(
129            source.fetch_timeout_secs,
130        )))
131        .build()
132        .into();
133
134    let mut request = agent.get(&source.url);
135
136    for (key, value) in &source.headers {
137        request = request.header(key.as_str(), value.as_str());
138    }
139
140    let mut response = request
141        .call()
142        .with_context(|| format!("HTTP request failed for {}", source.url))?;
143
144    let etag = response
145        .headers()
146        .get("etag")
147        .and_then(|v| v.to_str().ok())
148        .map(|s| s.to_string());
149
150    let body = response
151        .body_mut()
152        .with_config()
153        .limit(source.max_size_bytes as u64)
154        .read_to_string()
155        .with_context(|| format!("Failed to read response body from {}", source.url))?;
156
157    let profiles: Vec<par_term_config::Profile> = serde_yaml_ng::from_str(&body)
158        .with_context(|| format!("Failed to parse YAML from {}", source.url))?;
159
160    Ok((profiles, etag))
161}