diff --git a/src/http.rs b/src/http.rs new file mode 100644 index 0000000..37e65ae --- /dev/null +++ b/src/http.rs @@ -0,0 +1,64 @@ +//! The one outbound HTTP client, bounded. Every server-side fetch - +//! Gitea content at boot and on reload, the raw-asset proxy, the +//! repo-info endpoint, and the content-declared `url` resource - goes +//! through here, so a slow or oversized upstream can cost a request at +//! most `TIMEOUT` and `cap` bytes, never a thread or the heap. Before +//! this module each call site built its own client with no timeout and +//! read whole bodies with `.text()`. +#![cfg(feature = "ssr")] + +use std::sync::OnceLock; +use std::time::Duration; + +use openidconnect::reqwest; + +/// Whole-request budget: connect, headers, and body. +pub const TIMEOUT: Duration = Duration::from_secs(20); +const CONNECT_TIMEOUT: Duration = Duration::from_secs(5); + +/// A content-declared URL resource: JSON for one card. +pub const URL_RESOURCE_CAP: usize = 2 * 1024 * 1024; +/// A Gitea API reply or one content file (a tree listing, a YAML page). +pub const GITEA_CAP: usize = 8 * 1024 * 1024; +/// A content-shipped asset proxied to the browser (an image, a font). +pub const ASSET_CAP: usize = 32 * 1024 * 1024; + +pub fn client() -> &'static reqwest::Client { + static CLIENT: OnceLock = OnceLock::new(); + CLIENT.get_or_init(|| { + reqwest::Client::builder() + .connect_timeout(CONNECT_TIMEOUT) + .timeout(TIMEOUT) + .redirect(reqwest::redirect::Policy::limited(5)) + .user_agent(concat!("portal/", env!("CARGO_PKG_VERSION"))) + .build() + .expect("a client with static options always builds") + }) +} + +/// Reads a body up to `cap` bytes; one byte more is an error, not a +/// truncation, so a caller never parses half a document. +pub async fn bytes(mut response: reqwest::Response, cap: usize) -> anyhow::Result> { + let hint = response.content_length().unwrap_or(0) as usize; + if hint > cap { + anyhow::bail!("response of {hint} bytes exceeds the cap of {cap}"); + } + let mut out = Vec::with_capacity(hint.min(cap)); + while let Some(chunk) = response.chunk().await? { + if out.len() + chunk.len() > cap { + anyhow::bail!("response exceeds the cap of {cap} bytes"); + } + out.extend_from_slice(&chunk); + } + Ok(out) +} + +pub async fn text(response: reqwest::Response, cap: usize) -> anyhow::Result { + Ok(String::from_utf8(bytes(response, cap).await?)?) +} + +/// `GET url`, status checked, body read under `cap`. +pub async fn get_text(url: &str, cap: usize) -> anyhow::Result { + let response = client().get(url).send().await?.error_for_status()?; + text(response, cap).await +} diff --git a/src/lib.rs b/src/lib.rs index 9bb6325..622816b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -19,6 +19,9 @@ pub mod needs; #[cfg(feature = "ssr")] pub mod local; +#[cfg(feature = "ssr")] +pub mod http; + #[cfg(feature = "ssr")] pub mod server; diff --git a/src/main.rs b/src/main.rs index dceaa38..6bf0fc4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -145,7 +145,13 @@ async fn main() -> anyhow::Result<()> { .route("/auth/callback", get(oidc::callback)) .route("/auth/logout", get(oidc::logout)) .route("/api/{*fn_name}", any(server_fn_handler)) - .route("/upload", post(upload::upload)) + // axum caps request bodies at 2 MB by default, which had made the + // 20 MB upload limit unreachable; raise the fence on this route only, + // with room for the multipart framing. + .route( + "/upload", + post(upload::upload).layer(axum::extract::DefaultBodyLimit::max(upload::MAX_UPLOAD_BYTES + 64 * 1024)), + ) .route("/gitea-repo", get(content::gitea_repo_handler)) // Content-shipped assets (hero module etc.), same-origin. .route("/site/{*path}", get(content::site_asset_handler)) diff --git a/src/resource.rs b/src/resource.rs index 7afdb1c..8611e75 100644 --- a/src/resource.rs +++ b/src/resource.rs @@ -260,21 +260,20 @@ async fn fetch_gitea_json( for (k, v) in params { url.query_pairs_mut().append_pair(k, v); } - let client = openidconnect::reqwest::Client::new(); - let mut req = client.get(url.clone()); + let mut req = crate::http::client().get(url.clone()); if let Ok(token) = std::env::var("GITEA_API_TOKEN") { if !token.is_empty() { req = req.header("Authorization", format!("token {token}")); } } - let text = req + let response = req .send() .await .and_then(|r| r.error_for_status()) - .map_err(|e| ServerFnError::new(format!("fetching {url}: {e}")))? - .text() + .map_err(|e| ServerFnError::new(format!("fetching {url}: {e}")))?; + let text = crate::http::text(response, crate::http::GITEA_CAP) .await - .map_err(|e| ServerFnError::new(e.to_string()))?; + .map_err(|e| ServerFnError::new(format!("reading {url}: {e}")))?; serde_json::from_str(&text).map_err(|e| ServerFnError::new(e.to_string())) } @@ -341,16 +340,17 @@ async fn fetch_url_json( for (k, v) in &leftover { parsed.query_pairs_mut().append_pair(k, v); } - let client = openidconnect::reqwest::Client::new(); - let text = client + let response = crate::http::client() .get(parsed.clone()) .send() .await .and_then(|r| r.error_for_status()) - .map_err(|e| ServerFnError::new(format!("fetching {parsed}: {e}")))? - .text() + .map_err(|e| ServerFnError::new(format!("fetching {parsed}: {e}")))?; + // Content picks the URL; the cap is what keeps a runaway endpoint + // from becoming a runaway allocation here. + let text = crate::http::text(response, crate::http::URL_RESOURCE_CAP) .await - .map_err(|e| ServerFnError::new(e.to_string()))?; + .map_err(|e| ServerFnError::new(format!("reading {parsed}: {e}")))?; serde_json::from_str(&text).map_err(|e| ServerFnError::new(e.to_string())) } diff --git a/src/upload.rs b/src/upload.rs index b1c455b..fa3c73c 100644 --- a/src/upload.rs +++ b/src/upload.rs @@ -15,7 +15,7 @@ use serde::Serialize; use crate::server::AppState; -const MAX_UPLOAD_BYTES: usize = 20 * 1024 * 1024; +pub const MAX_UPLOAD_BYTES: usize = 20 * 1024 * 1024; #[derive(Clone)] pub struct Garage { @@ -84,12 +84,20 @@ pub async fn upload( .unwrap_or("application/octet-stream") .to_string(); let filename = field.file_name().unwrap_or("upload").to_string(); - let bytes = field - .bytes() + // Chunk by chunk, refused the moment the cap is crossed: an + // oversized body is never buffered whole before being turned + // away. The route's body limit (main.rs) is the outer fence. + let mut bytes: Vec = Vec::new(); + let mut field = field; + while let Some(chunk) = field + .chunk() .await - .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?; - if bytes.len() > MAX_UPLOAD_BYTES { - return Err((StatusCode::PAYLOAD_TOO_LARGE, "file too large".to_string())); + .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))? + { + if bytes.len() + chunk.len() > MAX_UPLOAD_BYTES { + return Err((StatusCode::PAYLOAD_TOO_LARGE, "file too large".to_string())); + } + bytes.extend_from_slice(&chunk); } let key = format!("{}-{filename}", uuid::Uuid::new_v4());