//! Fetching a job file from the presigned URL a `start_print` command carries. //! //! The URL is attacker-influenceable in the worst case (a misconfigured or //! compromised cloud, a bad object store) and the body is held in memory //! because `upload_project_file` takes a `Vec`, so everything here is //! bounded: size, time, redirects and scheme. use std::time::Duration; /// Hard ceiling on the body. A sliced `.3mf` is tens of megabytes (62 MB has /// been seen for a multi-plate project); 256 MiB is roughly four times that, /// so no honest job is refused, while a hostile URL cannot make a gateway on /// small hardware (a Pi-class box is the target) buffer its way into the OOM /// killer. Also well above anything a printer's own storage would take. pub const MAX_JOB_FILE_BYTES: u64 = 256 * 1024 * 1024; /// Whole-request budget, connect through last byte. 256 MiB at the slowest /// link worth supporting (~2 MiB/s) is just over two minutes, so 3 minutes /// covers the cap itself; anything slower than that is a stalled transfer, not /// a slow one. Presigned URLs live for minutes, so a longer wait would only /// be spent on a URL about to expire. Note `dispatch` runs inside the uplink /// loop, so this is also the longest a download can hold up heartbeats. pub const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(180); /// Separate and short: an unreachable host should fail in seconds, not use /// up the whole budget above. pub const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); /// Dev-only escape hatch for a local object store (MinIO) that speaks plain /// HTTP: needs `CONTINUUM_ALLOW_INSECURE_LOCAL_DOWNLOADS=true` AND a loopback /// or private-network (RFC 1918) address literal, so a presigned URL can never /// cross a public network in the clear. fn insecure_local_allowed(url: &reqwest::Url) -> bool { use std::net::IpAddr; if url.scheme() != "http" || !std::env::var("CONTINUUM_ALLOW_INSECURE_LOCAL_DOWNLOADS").is_ok_and(|v| v == "true") { return false; } let Some(ip) = url.host_str().and_then(|h| h.trim_matches(['[', ']']).parse::().ok()) else { return false; }; match ip { IpAddr::V4(v4) => v4.is_loopback() || v4.is_private(), IpAddr::V6(v6) => v6.is_loopback(), } } /// Downloads `url` fully into memory, or says why not. /// /// The error is a `String` naming the failure; the caller prefixes the stage. pub async fn fetch_job_file(url: &str) -> Result, String> { fetch_job_file_capped(url, MAX_JOB_FILE_BYTES).await } /// Same as [`fetch_job_file`] with the cap as a parameter, so tests can /// exercise the limit without serving 256 MiB. pub async fn fetch_job_file_capped(url: &str, max_bytes: u64) -> Result, String> { let parsed = reqwest::Url::parse(url).map_err(|err| format!("not a valid URL: {err}"))?; // A presigned URL carries its authority in the query string; sending it // over plain HTTP would expose it to anyone on the path. if parsed.scheme() != "https" && !insecure_local_allowed(&parsed) { return Err(format!("refusing non-HTTPS URL (scheme '{}')", parsed.scheme())); } let client = reqwest::Client::builder() .connect_timeout(CONNECT_TIMEOUT) .timeout(DOWNLOAD_TIMEOUT) // A presigned GET answers directly. Following a redirect would carry // us somewhere the signature never vouched for. .redirect(reqwest::redirect::Policy::none()) .build() .map_err(|err| format!("could not build HTTP client: {err}"))?; let mut response = client.get(parsed).send().await.map_err(|err| describe(&err))?; // Status before body. An expired presigned URL is a 403 with an XML // error document, which must never be mistaken for a project file. let status = response.status(); if !status.is_success() { let hint = if status == reqwest::StatusCode::FORBIDDEN { " (the presigned URL has probably expired)" } else { "" }; return Err(format!("server answered HTTP {status}{hint}")); } let expected = response.content_length(); if let Some(len) = expected.filter(|len| *len > max_bytes) { return Err(format!("file is {len} bytes, over the {max_bytes} byte limit")); } // Stream into a buffer so the cap holds even when the server sends no // Content-Length or lies about it. let mut body = Vec::with_capacity(expected.unwrap_or(0).min(max_bytes) as usize); while let Some(chunk) = response.chunk().await.map_err(|err| describe(&err))? { if body.len() as u64 + chunk.len() as u64 > max_bytes { return Err(format!("file exceeds the {max_bytes} byte limit")); } body.extend_from_slice(&chunk); } // A `.3mf` is a zip: a truncated one has lost its central directory and // is unreadable, so a short body is an error, not a smaller file. if let Some(len) = expected.filter(|len| *len != body.len() as u64) { return Err(format!("received {} bytes but Content-Length said {len}", body.len())); } if body.is_empty() { return Err("server returned an empty body".into()); } Ok(body) } /// reqwest's `Display` for a transport error is just "error sending request"; /// the useful part (timeout, connect, TLS) is in the flags and source chain. fn describe(err: &reqwest::Error) -> String { let kind = if err.is_timeout() { "timed out" } else if err.is_connect() { "could not connect" } else { "transfer failed" }; let mut msg = format!("{kind}: {err}"); let mut source = std::error::Error::source(err); while let Some(cause) = source { msg.push_str(&format!(": {cause}")); source = cause.source(); } // Never echo the URL back: its query string is the credential. match err.url() { Some(url) => msg.replace(url.as_str(), ""), None => msg, } } #[cfg(test)] mod tests { use super::*; /// Checked before any connection is made, so no server is needed. #[tokio::test] async fn a_non_https_url_is_refused_before_any_request() { let err = fetch_job_file("http://example.invalid/plate.3mf?sig=abc").await.unwrap_err(); assert!(err.contains("non-HTTPS"), "got: {err}"); assert!(fetch_job_file("not a url").await.is_err()); } }