use std::time::Duration;
use ureq::Agent;
use ureq::http::{Response, StatusCode};
use ureq::tls::{RootCerts, TlsConfig, TlsProvider};
use ureq::{Body, Error as UreqError};
const HTTP_TIMEOUT: Duration = Duration::from_secs(30);
const MAX_REDIRECTS: u32 = 5;
pub const MAX_DOWNLOAD_SIZE: u64 = 50 * 1024 * 1024;
const ALLOWED_DOWNLOAD_HOSTS: &[&str] = &[
"github.com",
"api.github.com",
"objects.githubusercontent.com",
"github-releases.githubusercontent.com",
"release-assets.githubusercontent.com",
];
pub fn validate_download_url(url: &str) -> Result<(), String> {
let parsed =
url::Url::parse(url).map_err(|e| format!("Invalid URL '{}': {}", redact_url(url), e))?;
match parsed.scheme() {
"https" => {}
scheme => {
return Err(format!(
"Insecure URL scheme '{}' rejected; only HTTPS is allowed. \
URL: {}",
scheme,
redact_url(url)
));
}
}
let host = parsed.host_str().unwrap_or("");
if !ALLOWED_DOWNLOAD_HOSTS.contains(&host) {
return Err(format!(
"URL host '{}' is not in the allowed list for download operations. \
Allowed hosts: {}. \
URL: {}",
host,
ALLOWED_DOWNLOAD_HOSTS.join(", "),
redact_url(url)
));
}
Ok(())
}
fn redact_url(url: &str) -> String {
match url::Url::parse(url) {
Ok(parsed) => format!(
"{}://{}{}",
parsed.scheme(),
parsed.host_str().unwrap_or(""),
parsed.path()
),
Err(_) => "<unparseable URL>".to_string(),
}
}
fn resolve_redirect(current: &str, location: &str) -> Result<String, String> {
let base = url::Url::parse(current)
.map_err(|e| format!("Could not parse the current download URL: {}", e))?;
let resolved = base.join(location).map_err(|e| {
format!(
"Download server redirected from {} to an unparseable Location: {}. \
Download aborted.",
redact_url(current),
e
)
})?;
let resolved = resolved.to_string();
if validate_download_url(&resolved).is_err() {
return Err(format!(
"Download server redirected from {} to {}, which is not an allowed \
download host. Download aborted — a redirect cannot move the \
download off GitHub. Allowed hosts: {}.",
redact_url(current),
redact_url(&resolved),
ALLOWED_DOWNLOAD_HOSTS.join(", ")
));
}
Ok(resolved)
}
pub fn agent() -> Agent {
build_agent(true)
}
fn no_redirect_agent() -> Agent {
build_agent(false)
}
fn build_agent(follow_redirects: bool) -> Agent {
let tls_config = TlsConfig::builder()
.provider(TlsProvider::NativeTls)
.root_certs(RootCerts::PlatformVerifier)
.build();
let builder = Agent::config_builder()
.tls_config(tls_config)
.timeout_global(Some(HTTP_TIMEOUT))
.https_only(true);
let builder = if follow_redirects {
builder
} else {
builder.max_redirects(0)
};
builder.build().into()
}
fn describe_request_error(url: &str, error: &UreqError) -> String {
format!(
"Failed to fetch '{}': {}. \
Check your internet connection and try again.",
redact_url(url),
error
)
}
pub fn get_validated(url: &str, accept: Option<&str>) -> Result<Response<Body>, String> {
let agent = no_redirect_agent();
let mut current = url.to_string();
for _ in 0..=MAX_REDIRECTS {
validate_download_url(¤t)?;
let mut request = agent.get(¤t).header("User-Agent", "par-term");
if let Some(accept) = accept {
request = request.header("Accept", accept);
}
let response = request
.call()
.map_err(|e| describe_request_error(¤t, &e))?;
let status = response.status();
if !status.is_redirection() {
return Ok(response);
}
let location = redirect_location(&response, status, ¤t)?;
current = resolve_redirect(¤t, &location)?;
}
Err(format!(
"Download exceeded {} redirects starting from {}. \
Download aborted — this may indicate a redirect loop.",
MAX_REDIRECTS,
redact_url(url)
))
}
fn redirect_location(
response: &Response<Body>,
status: StatusCode,
current: &str,
) -> Result<String, String> {
response
.headers()
.get("location")
.and_then(|value| value.to_str().ok())
.map(str::to_string)
.ok_or_else(|| {
format!(
"Download server returned redirect status {} from {} with no usable \
Location header. Download aborted.",
status.as_u16(),
redact_url(current)
)
})
}
pub fn download_file(url: &str) -> Result<Vec<u8>, String> {
get_validated(url, None)?
.into_body()
.with_config()
.limit(MAX_DOWNLOAD_SIZE)
.read_to_vec()
.map_err(|e| {
format!(
"Failed to read downloaded content from '{}': {}. \
The response may have been truncated or the connection dropped.",
redact_url(url),
e
)
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn allowlisted_github_hosts_are_accepted() {
for url in [
"https://api.github.com/repos/paulrobello/par-term/releases/latest",
"https://github.com/paulrobello/par-term/releases/download/v1/shaders.zip",
"https://objects.githubusercontent.com/asset/123/shaders.zip",
"https://github-releases.githubusercontent.com/123/shaders.zip",
"https://release-assets.githubusercontent.com/asset/123/shaders.zip",
] {
assert!(
validate_download_url(url).is_ok(),
"expected {url} to be accepted"
);
}
}
#[test]
fn allowlist_contains_the_host_release_downloads_actually_land_on() {
assert!(ALLOWED_DOWNLOAD_HOSTS.contains(&"release-assets.githubusercontent.com"));
}
#[test]
fn non_https_schemes_are_rejected() {
let err = validate_download_url("http://api.github.com/repos/x/y/releases/latest")
.expect_err("plain HTTP must be rejected");
assert!(err.contains("http"), "error should name the scheme: {err}");
assert!(err.contains("HTTPS"), "error should require HTTPS: {err}");
assert!(validate_download_url("file:///etc/passwd").is_err());
}
#[test]
fn off_allowlist_hosts_are_rejected() {
let err = validate_download_url("https://evil.example.com/shaders.zip")
.expect_err("an off-allowlist host must be rejected");
assert!(
err.contains("evil.example.com"),
"error should name the host: {err}"
);
assert!(
err.contains("allowed list"),
"error should mention the allowlist: {err}"
);
assert!(validate_download_url("https://fake.api.github.com/releases").is_err());
}
#[test]
fn rejection_messages_do_not_echo_query_credentials() {
let err = validate_download_url("https://evil.example.com/x?sig=SECRET&jwt=ALSOSECRET")
.expect_err("an off-allowlist host must be rejected");
assert!(!err.contains("SECRET"), "credentials leaked into: {err}");
}
#[test]
fn unparseable_urls_are_rejected_without_being_echoed() {
let err = validate_download_url("not a url?token=SECRET")
.expect_err("an unparseable URL must be rejected");
assert!(err.contains("Invalid URL"), "unexpected error: {err}");
assert!(!err.contains("SECRET"), "credentials leaked into: {err}");
}
#[test]
fn redact_url_drops_the_query_string() {
let redacted = redact_url(
"https://release-assets.githubusercontent.com/asset/1140148702/abc?sig=SECRET&jwt=ALSOSECRET",
);
assert_eq!(
redacted,
"https://release-assets.githubusercontent.com/asset/1140148702/abc"
);
assert!(!redacted.contains("SECRET"));
}
#[test]
fn redact_url_drops_userinfo() {
assert_eq!(
redact_url("https://user:SECRET@github.com/paulrobello/par-term"),
"https://github.com/paulrobello/par-term"
);
}
#[test]
fn redact_url_does_not_echo_an_unparseable_url() {
assert_eq!(redact_url("nonsense?token=SECRET"), "<unparseable URL>");
}
#[test]
fn redirect_to_an_allowlisted_host_is_accepted() {
let resolved = resolve_redirect(
"https://github.com/paulrobello/par-term/releases/download/v1/shaders.zip",
"https://release-assets.githubusercontent.com/asset/1?sig=abc",
)
.expect("an allowlisted redirect target must be accepted");
assert!(resolved.starts_with("https://release-assets.githubusercontent.com/"));
}
#[test]
fn relative_redirect_is_resolved_against_the_current_url() {
let resolved = resolve_redirect(
"https://github.com/paulrobello/par-term/releases/latest/download/shaders.zip",
"/paulrobello/par-term/releases/download/v1/shaders.zip",
)
.expect("a relative redirect on the same host must be accepted");
assert_eq!(
resolved,
"https://github.com/paulrobello/par-term/releases/download/v1/shaders.zip"
);
}
#[test]
fn redirect_off_the_allowlist_is_rejected() {
let err = resolve_redirect(
"https://github.com/paulrobello/par-term/releases/download/v1/shaders.zip",
"https://evil.example.com/shaders.zip",
)
.expect_err("an off-allowlist redirect target must be rejected");
assert!(
err.contains("evil.example.com"),
"error should name the rejected host: {err}"
);
}
#[test]
fn rejected_redirect_does_not_leak_query_credentials() {
let err = resolve_redirect(
"https://github.com/paulrobello/par-term/releases/download/v1/shaders.zip",
"https://evil.example.com/shaders.zip?sig=SECRET&jwt=ALSOSECRET",
)
.expect_err("an off-allowlist redirect target must be rejected");
assert!(!err.contains("SECRET"), "credentials leaked into: {err}");
}
#[test]
fn relative_redirect_cannot_escape_to_another_host() {
assert!(
resolve_redirect(
"https://github.com/paulrobello/par-term/releases/download/v1/shaders.zip",
"//evil.example.com/shaders.zip",
)
.is_err()
);
}
#[test]
fn redirect_downgrading_to_http_is_rejected() {
assert!(
resolve_redirect(
"https://github.com/paulrobello/par-term/releases/download/v1/shaders.zip",
"http://github.com/paulrobello/par-term/releases/download/v1/shaders.zip",
)
.is_err()
);
}
#[test]
#[ignore = "requires network access to github.com"]
fn live_release_redirect_chain_stays_on_allowlisted_hosts() {
let response = get_validated(
"https://github.com/paulrobello/par-term/releases/latest/download/shaders.zip",
None,
)
.expect("the real shader download must survive per-hop validation");
assert!(
response.status().is_success(),
"expected a 2xx after following redirects, got {}",
response.status()
);
}
}