use std::time::{Duration, Instant};
use crate::{Result, TerraphimAutomataError};
pub const DEFAULT_MAX_RESPONSE_BYTES: u64 = 64 * 1024 * 1024;
pub const DEFAULT_MAX_REDIRECTS: usize = 5;
pub const DEFAULT_TOTAL_TIMEOUT: Duration = Duration::from_secs(30);
pub const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
#[derive(Debug, Clone)]
pub struct RemoteFetchPolicy {
pub allow_http: bool,
pub max_redirects: usize,
pub max_response_bytes: u64,
pub connect_timeout: Duration,
pub total_timeout: Duration,
}
impl Default for RemoteFetchPolicy {
fn default() -> Self {
Self {
allow_http: false,
max_redirects: DEFAULT_MAX_REDIRECTS,
max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES,
connect_timeout: DEFAULT_CONNECT_TIMEOUT,
total_timeout: DEFAULT_TOTAL_TIMEOUT,
}
}
}
impl RemoteFetchPolicy {
pub fn allow_http_local() -> Self {
Self {
allow_http: true,
..Self::default()
}
}
fn scheme_allowed(&self, scheme: &str) -> bool {
match scheme {
"https" => true,
"http" => self.allow_http,
_ => false,
}
}
}
const SENSITIVE_HEADERS: [&str; 6] = [
"authorization",
"cookie",
"x-api-key",
"proxy-authorization",
"www-authenticate",
"x-terraphim-token",
];
pub fn validate_redirect(
from: &reqwest::Url,
location: &str,
policy: &RemoteFetchPolicy,
) -> Result<reqwest::Url> {
let destination = from
.join(location)
.map_err(|e| TerraphimAutomataError::InvalidRedirect {
url: from.to_string(),
reason: format!("malformed Location header {location:?}: {e}"),
})?;
match destination.scheme() {
"https" => Ok(destination),
"http" if policy.allow_http => {
if from.scheme() == "https" {
Err(TerraphimAutomataError::InsecureRedirect {
from: from.to_string(),
to: destination.to_string(),
})
} else {
Ok(destination)
}
}
scheme => Err(TerraphimAutomataError::SchemeNotAllowed {
url: destination.to_string(),
scheme: scheme.to_string(),
}),
}
}
fn is_cross_origin(from: &reqwest::Url, to: &reqwest::Url) -> bool {
let origin = |u: &reqwest::Url| {
(
u.scheme().to_string(),
u.host_str().map(|h| h.to_lowercase()),
u.port_or_known_default(),
)
};
origin(from) != origin(to)
}
fn fetch_error(url: &reqwest::Url, e: reqwest::Error) -> TerraphimAutomataError {
TerraphimAutomataError::HttpTransport(format!("Failed to fetch {url}: {e}"))
}
pub async fn fetch_bytes(
url: &str,
extra_headers: &[(String, String)],
policy: &RemoteFetchPolicy,
) -> Result<Vec<u8>> {
let started = Instant::now();
fetch_bytes_inner(url, extra_headers, policy, started, 0).await
}
async fn fetch_bytes_inner(
url: &str,
extra_headers: &[(String, String)],
policy: &RemoteFetchPolicy,
started: Instant,
hops_used: usize,
) -> Result<Vec<u8>> {
let client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.connect_timeout(policy.connect_timeout)
.timeout(policy.total_timeout)
.user_agent("Terraphim-Automata/1.0")
.build()
.map_err(|e| TerraphimAutomataError::HttpTransport(format!("client build failed: {e}")))?;
let mut hops_used = hops_used;
let mut current: reqwest::Url =
url.parse()
.map_err(|_| TerraphimAutomataError::SchemeNotAllowed {
url: url.to_string(),
scheme: "(unparsable)".to_string(),
})?;
loop {
if !policy.scheme_allowed(current.scheme()) {
return Err(TerraphimAutomataError::SchemeNotAllowed {
url: current.to_string(),
scheme: current.scheme().to_string(),
});
}
let mut request = client
.get(current.clone())
.header("Accept", "application/json");
for (name, value) in extra_headers {
request = request.header(name, value);
}
let response = request.send().await.map_err(|e| fetch_error(¤t, e))?;
let status = response.status();
if status.is_redirection() {
if hops_used >= policy.max_redirects {
return Err(TerraphimAutomataError::TooManyRedirects {
url: current.to_string(),
max: policy.max_redirects,
});
}
let location = response
.headers()
.get(reqwest::header::LOCATION)
.and_then(|v| v.to_str().ok())
.ok_or_else(|| TerraphimAutomataError::InvalidRedirect {
url: current.to_string(),
reason: "redirect response without a Location header".to_string(),
})?
.to_string();
let destination = validate_redirect(¤t, &location, policy)?;
if is_cross_origin(¤t, &destination) {
let retained: Vec<(String, String)> = extra_headers
.iter()
.filter(|(name, _)| {
!SENSITIVE_HEADERS.contains(&name.to_ascii_lowercase().as_str())
})
.cloned()
.collect();
return Box::pin(fetch_bytes_inner(
destination.as_str(),
&retained,
policy,
started,
hops_used + 1,
))
.await;
}
hops_used += 1;
current = destination;
continue;
}
if !status.is_success() {
return Err(TerraphimAutomataError::HttpStatus {
url: current.to_string(),
status: status.as_u16(),
});
}
if let Some(length) = response.content_length() {
if length > policy.max_response_bytes {
return Err(TerraphimAutomataError::BodyTooLarge {
limit: policy.max_response_bytes,
});
}
}
let mut bytes: Vec<u8> = Vec::new();
let mut response = response;
while let Some(chunk) = response
.chunk()
.await
.map_err(|e| fetch_error(¤t, e))?
{
if bytes.len() as u64 + chunk.len() as u64 > policy.max_response_bytes {
return Err(TerraphimAutomataError::BodyTooLarge {
limit: policy.max_response_bytes,
});
}
bytes.extend_from_slice(&chunk);
}
return Ok(bytes);
}
}
pub async fn fetch_text(
url: &str,
extra_headers: &[(String, String)],
policy: &RemoteFetchPolicy,
) -> Result<String> {
let bytes = fetch_bytes(url, extra_headers, policy).await?;
String::from_utf8(bytes).map_err(|e| {
TerraphimAutomataError::InvalidThesaurus(format!(
"Remote response from {url} was not valid UTF-8: {e}"
))
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_policy_requires_https() {
let policy = RemoteFetchPolicy::default();
assert!(!policy.allow_http);
}
#[test]
fn validate_redirect_rejects_https_to_http_downgrade() {
let policy = RemoteFetchPolicy::allow_http_local();
let from: reqwest::Url = "https://example.com/a".parse().unwrap();
let result = validate_redirect(&from, "http://example.com/b", &policy);
assert!(matches!(
result,
Err(TerraphimAutomataError::InsecureRedirect { .. })
));
}
#[test]
fn validate_redirect_rejects_disallowed_scheme() {
let policy = RemoteFetchPolicy::default();
let from: reqwest::Url = "https://example.com/a".parse().unwrap();
let result = validate_redirect(&from, "http://example.com/b", &policy);
assert!(matches!(
result,
Err(TerraphimAutomataError::SchemeNotAllowed { .. })
));
let result = validate_redirect(&from, "ftp://example.com/b", &policy);
assert!(matches!(
result,
Err(TerraphimAutomataError::SchemeNotAllowed { .. })
));
}
#[test]
fn validate_redirect_resolves_relative_locations() {
let policy = RemoteFetchPolicy::allow_http_local();
let from: reqwest::Url = "http://localhost:1/dir/page".parse().unwrap();
let dest = validate_redirect(&from, "../target.json", &policy).unwrap();
assert_eq!(dest.as_str(), "http://localhost:1/target.json");
}
#[test]
fn validate_redirect_rejects_malformed_location() {
let policy = RemoteFetchPolicy::allow_http_local();
let from: reqwest::Url = "http://localhost:1/a".parse().unwrap();
let result = validate_redirect(&from, "http://[::1:99999", &policy);
assert!(result.is_err());
}
#[test]
fn cross_origin_detection_includes_port() {
let a: reqwest::Url = "http://127.0.0.1:9001/x".parse().unwrap();
let b: reqwest::Url = "http://127.0.0.1:9002/x".parse().unwrap();
let c: reqwest::Url = "http://127.0.0.1:9001/y".parse().unwrap();
assert!(is_cross_origin(&a, &b));
assert!(!is_cross_origin(&a, &c));
}
}