Skip to main content

par_term_update/
http.rs

1//! HTTP client helper with native-tls support for the self-update subsystem.
2//!
3//! # Security Design
4//!
5//! All network requests made by the self-update subsystem go through
6//! [`validate_update_url`] before any network I/O occurs. Two invariants are
7//! enforced:
8//!
9//! 1. **HTTPS only** — plain HTTP, `file://`, and any other non-HTTPS scheme are
10//!    rejected unconditionally. This prevents a network-level attacker from
11//!    downgrading the connection and serving a malicious binary.
12//!
13//! 2. **Host allowlist** — only the four GitHub hostnames in [`ALLOWED_HOSTS`] are
14//!    accepted. This prevents a compromised DNS server or a SSRF-style redirect from
15//!    pointing the updater at an attacker-controlled server.
16//!
17//! Additionally, response bodies are capped at [`MAX_API_RESPONSE_SIZE`] (API calls)
18//! and [`MAX_DOWNLOAD_SIZE`] (binary downloads) to prevent memory exhaustion, and
19//! downloaded binaries are checked for the correct platform magic bytes via
20//! [`validate_binary_content`].
21
22use std::time::Duration;
23use ureq::Agent;
24use ureq::tls::{RootCerts, TlsConfig, TlsProvider};
25
26/// Global timeout for all HTTP operations (30 seconds).
27const HTTP_TIMEOUT: Duration = Duration::from_secs(30);
28
29/// Maximum response body size for API responses (10 MB).
30pub const MAX_API_RESPONSE_SIZE: u64 = 10 * 1024 * 1024;
31
32/// Maximum response body size for file downloads (50 MB).
33pub const MAX_DOWNLOAD_SIZE: u64 = 50 * 1024 * 1024;
34
35/// Allowlisted hostnames for update-related network requests.
36///
37/// Only requests to GitHub's primary API and CDN hosts are permitted.
38/// Any other host is rejected regardless of the URL path, preventing SSRF
39/// or DNS-rebinding attacks that could redirect update traffic to an
40/// attacker-controlled server.
41const ALLOWED_HOSTS: &[&str] = &[
42    "github.com",
43    "api.github.com",
44    "objects.githubusercontent.com",
45    "github-releases.githubusercontent.com",
46];
47
48/// Validate that a URL is safe to use for update operations.
49///
50/// Enforces:
51/// - HTTPS scheme only (no HTTP, ftp, file://, etc.)
52/// - Host must be in the GitHub allowlist
53///
54/// Returns `Ok(())` if the URL is acceptable, or an error string describing
55/// why it was rejected.
56pub fn validate_update_url(url: &str) -> Result<(), String> {
57    let parsed = url::Url::parse(url).map_err(|e| format!("Invalid URL '{}': {}", url, e))?;
58
59    // Enforce HTTPS only — plain HTTP can be intercepted and downgraded.
60    match parsed.scheme() {
61        "https" => {}
62        scheme => {
63            return Err(format!(
64                "Insecure URL scheme '{}' rejected; only HTTPS is allowed. \
65                 URL: {}",
66                scheme, url
67            ));
68        }
69    }
70
71    // Enforce domain allowlist — reject any host not operated by GitHub.
72    let host = parsed.host_str().unwrap_or("");
73    if !ALLOWED_HOSTS.contains(&host) {
74        return Err(format!(
75            "URL host '{}' is not in the allowed list for update operations. \
76             Allowed hosts: {}. \
77             URL: {}",
78            host,
79            ALLOWED_HOSTS.join(", "),
80            url
81        ));
82    }
83
84    Ok(())
85}
86
87/// Create a new HTTP agent configured with native-tls and a global timeout.
88pub fn agent() -> Agent {
89    let tls_config = TlsConfig::builder()
90        .provider(TlsProvider::NativeTls)
91        .root_certs(RootCerts::PlatformVerifier)
92        .build();
93
94    Agent::config_builder()
95        .tls_config(tls_config)
96        .timeout_global(Some(HTTP_TIMEOUT))
97        .build()
98        .into()
99}
100
101/// Download a file from a URL and return its bytes.
102///
103/// Validates the URL against the allowed-host allowlist before making
104/// any network request. Response body is limited to [`MAX_DOWNLOAD_SIZE`]
105/// (50 MB) to prevent memory exhaustion from malicious or misbehaving servers.
106///
107/// # Errors
108///
109/// Returns an error if:
110/// - The URL fails allowlist validation (wrong host or non-HTTPS scheme)
111/// - The HTTP request fails (DNS, connection, TLS, or non-2xx response)
112/// - Reading the response body fails or exceeds the size limit
113pub fn download_file(url: &str) -> Result<Vec<u8>, String> {
114    // Validate URL before making any network request.
115    validate_update_url(url)?;
116
117    let bytes = agent()
118        .get(url)
119        .header("User-Agent", "par-term")
120        .call()
121        .map_err(|e| {
122            format!(
123                "Failed to download '{}': {}. \
124                 Check your internet connection and try again. \
125                 If the problem persists, download manually from: \
126                 https://github.com/paulrobello/par-term/releases",
127                url, e
128            )
129        })?
130        .into_body()
131        .with_config()
132        .limit(MAX_DOWNLOAD_SIZE)
133        .read_to_vec()
134        .map_err(|e| {
135            format!(
136                "Failed to read downloaded content from '{}': {}. \
137                 The response may have been truncated or the connection dropped.",
138                url, e
139            )
140        })?;
141
142    Ok(bytes)
143}
144
145/// Validate that downloaded binary content is plausible for the current platform.
146///
147/// This is a lightweight sanity check — not a security guarantee — that catches
148/// obviously wrong content (e.g., an HTML error page served instead of a binary).
149///
150/// On macOS, the content must begin with a ZIP local-file signature (`PK\x03\x04`)
151/// because macOS releases are distributed as `.zip` archives.
152/// On Linux, the content must begin with the ELF magic bytes (`\x7fELF`).
153/// On Windows, the content must begin with the PE `MZ` header.
154///
155/// Returns `Ok(())` if the content looks valid, or an error string with
156/// a human-readable description of what was expected vs. found.
157pub fn validate_binary_content(data: &[u8]) -> Result<(), String> {
158    let os = std::env::consts::OS;
159
160    match os {
161        "macos" => {
162            // macOS releases ship as ZIP archives
163            if data.len() < 4 || &data[..4] != b"PK\x03\x04" {
164                let preview = format_bytes_preview(data);
165                return Err(format!(
166                    "Downloaded content does not look like a ZIP archive (expected PK\\x03\\x04 \
167                     header for macOS release). Got: {}. \
168                     This may indicate a corrupt download or an unexpected server response. \
169                     Please try again or download manually from: \
170                     https://github.com/paulrobello/par-term/releases",
171                    preview
172                ));
173            }
174        }
175        "linux" => {
176            // Linux releases are raw ELF binaries
177            if data.len() < 4 || &data[..4] != b"\x7fELF" {
178                let preview = format_bytes_preview(data);
179                return Err(format!(
180                    "Downloaded content does not look like an ELF binary (expected \\x7fELF \
181                     header for Linux release). Got: {}. \
182                     This may indicate a corrupt download or an unexpected server response. \
183                     Please try again or download manually from: \
184                     https://github.com/paulrobello/par-term/releases",
185                    preview
186                ));
187            }
188        }
189        "windows" => {
190            // Windows releases are PE executables
191            if data.len() < 2 || &data[..2] != b"MZ" {
192                let preview = format_bytes_preview(data);
193                return Err(format!(
194                    "Downloaded content does not look like a Windows executable (expected MZ \
195                     header for Windows release). Got: {}. \
196                     This may indicate a corrupt download or an unexpected server response. \
197                     Please try again or download manually from: \
198                     https://github.com/paulrobello/par-term/releases",
199                    preview
200                ));
201            }
202        }
203        other => {
204            // Unknown platform — log a warning but do not block the update.
205            log::warn!(
206                "Binary content validation skipped: unknown platform '{}'. \
207                 Proceeding without magic-byte check.",
208                other
209            );
210        }
211    }
212
213    Ok(())
214}
215
216/// Format the first few bytes of a buffer as a human-readable hex + ASCII preview.
217///
218/// Used in error messages to help diagnose what was actually downloaded.
219fn format_bytes_preview(data: &[u8]) -> String {
220    let take = data.len().min(16);
221    let hex: Vec<String> = data[..take].iter().map(|b| format!("{:02x}", b)).collect();
222    let ascii: String = data[..take]
223        .iter()
224        .map(|&b| if b.is_ascii_graphic() { b as char } else { '.' })
225        .collect();
226    format!("[{}] \"{}\"", hex.join(" "), ascii)
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    // --- validate_update_url ---
234
235    #[test]
236    fn test_valid_api_github_com() {
237        assert!(
238            validate_update_url(
239                "https://api.github.com/repos/paulrobello/par-term/releases/latest"
240            )
241            .is_ok()
242        );
243    }
244
245    #[test]
246    fn test_valid_objects_githubusercontent_com() {
247        assert!(validate_update_url(
248            "https://objects.githubusercontent.com/github-production-release-asset-123/par-term-linux-x86_64"
249        )
250        .is_ok());
251    }
252
253    #[test]
254    fn test_valid_github_releases() {
255        assert!(
256            validate_update_url(
257                "https://github-releases.githubusercontent.com/123/par-term-linux-x86_64"
258            )
259            .is_ok()
260        );
261    }
262
263    #[test]
264    fn test_valid_github_com() {
265        assert!(validate_update_url("https://github.com/paulrobello/par-term/releases").is_ok());
266    }
267
268    #[test]
269    fn test_rejected_http_scheme() {
270        let result =
271            validate_update_url("http://api.github.com/repos/paulrobello/par-term/releases/latest");
272        assert!(result.is_err());
273        let msg = result.unwrap_err();
274        assert!(
275            msg.contains("http"),
276            "Error should mention the bad scheme: {msg}"
277        );
278        assert!(
279            msg.contains("HTTPS"),
280            "Error should mention HTTPS requirement: {msg}"
281        );
282    }
283
284    #[test]
285    fn test_rejected_file_scheme() {
286        let result = validate_update_url("file:///etc/passwd");
287        assert!(result.is_err());
288        let msg = result.unwrap_err();
289        assert!(
290            msg.contains("file"),
291            "Error should mention the bad scheme: {msg}"
292        );
293    }
294
295    #[test]
296    fn test_rejected_unknown_host() {
297        let result = validate_update_url("https://evil.example.com/par-term-linux-x86_64");
298        assert!(result.is_err());
299        let msg = result.unwrap_err();
300        assert!(
301            msg.contains("evil.example.com"),
302            "Error should name the rejected host: {msg}"
303        );
304        assert!(
305            msg.contains("allowed list"),
306            "Error should mention the allowlist: {msg}"
307        );
308    }
309
310    #[test]
311    fn test_rejected_lookalike_host() {
312        // Subdomain-of-allowed is NOT the same as the allowed host itself.
313        let result = validate_update_url("https://fake.api.github.com/releases");
314        assert!(result.is_err());
315    }
316
317    #[test]
318    fn test_rejected_invalid_url() {
319        let result = validate_update_url("not a url at all");
320        assert!(result.is_err());
321        let msg = result.unwrap_err();
322        assert!(
323            msg.contains("Invalid URL"),
324            "Error should mention parse failure: {msg}"
325        );
326    }
327
328    // --- validate_binary_content ---
329
330    #[test]
331    #[cfg(target_os = "macos")]
332    fn test_macos_valid_zip() {
333        // ZIP local-file header magic
334        let data = b"PK\x03\x04rest of zip content";
335        assert!(validate_binary_content(data).is_ok());
336    }
337
338    #[test]
339    #[cfg(target_os = "macos")]
340    fn test_macos_invalid_not_zip() {
341        let data = b"<html>404 Not Found</html>";
342        let result = validate_binary_content(data);
343        assert!(result.is_err());
344        let msg = result.unwrap_err();
345        assert!(msg.contains("ZIP"), "Error should mention ZIP: {msg}");
346    }
347
348    #[test]
349    #[cfg(target_os = "linux")]
350    fn test_linux_valid_elf() {
351        let data = b"\x7fELFrest of elf binary";
352        assert!(validate_binary_content(data).is_ok());
353    }
354
355    #[test]
356    #[cfg(target_os = "linux")]
357    fn test_linux_invalid_not_elf() {
358        let data = b"<html>404 Not Found</html>";
359        let result = validate_binary_content(data);
360        assert!(result.is_err());
361        let msg = result.unwrap_err();
362        assert!(msg.contains("ELF"), "Error should mention ELF: {msg}");
363    }
364
365    #[test]
366    #[cfg(windows)]
367    fn test_windows_valid_pe() {
368        let data = b"MZrest of PE binary";
369        assert!(validate_binary_content(data).is_ok());
370    }
371
372    #[test]
373    #[cfg(windows)]
374    fn test_windows_invalid_not_pe() {
375        let data = b"<html>404 Not Found</html>";
376        let result = validate_binary_content(data);
377        assert!(result.is_err());
378        let msg = result.unwrap_err();
379        assert!(msg.contains("MZ"), "Error should mention MZ: {msg}");
380    }
381
382    #[test]
383    fn test_validate_binary_content_empty() {
384        // Empty data should fail on all recognized platforms since headers are
385        // missing, and pass on unknown platforms (no-op path).
386        let data: &[u8] = &[];
387        let os = std::env::consts::OS;
388        let result = validate_binary_content(data);
389        match os {
390            "macos" | "linux" | "windows" => {
391                assert!(result.is_err(), "Empty data should be rejected on {os}");
392            }
393            _ => {
394                // Unknown platform: validation is skipped, so result is Ok.
395                assert!(result.is_ok());
396            }
397        }
398    }
399
400    // --- format_bytes_preview ---
401
402    #[test]
403    fn test_format_bytes_preview_short() {
404        let preview = format_bytes_preview(b"PK");
405        assert!(
406            preview.contains("50 4b"),
407            "Should contain hex for 'PK': {preview}"
408        );
409        assert!(
410            preview.contains("PK"),
411            "Should contain ASCII for 'PK': {preview}"
412        );
413    }
414
415    #[test]
416    fn test_format_bytes_preview_non_ascii() {
417        let preview = format_bytes_preview(b"\x7f\x00\xff");
418        // Non-printable bytes should appear as '.'
419        assert!(
420            preview.contains("..."),
421            "Non-printable bytes should show as dots: {preview}"
422        );
423    }
424}