One bounded HTTP client for every server-side fetch; uploads refused while streaming
Every outbound fetch - Gitea content at boot and on reload, the raw asset proxy, the repo-info endpoint, the content-declared url resource - built its own reqwest client with no timeout and read whole bodies with .text(). `crate::http` is now the one client (5 s connect, 20 s per request, 5 redirects) with capped body readers: 2 MB for a url resource, 8 MB for a Gitea reply, 32 MB for a proxied asset; one byte over is an error, never a truncated document. The upload handler read a whole field into memory before comparing it to the 20 MB cap, and axum's 2 MB default body limit had made that cap unreachable anyway. It now reads chunk by chunk and refuses the moment the cap is crossed; the /upload route raises the body limit to match. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
d7f60eaa31
commit
c91bf59946
+64
@@ -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<reqwest::Client> = 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<Vec<u8>> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
let response = client().get(url).send().await?.error_for_status()?;
|
||||
text(response, cap).await
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
+7
-1
@@ -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))
|
||||
|
||||
+11
-11
@@ -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()))
|
||||
}
|
||||
|
||||
|
||||
+14
-6
@@ -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<u8> = 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());
|
||||
|
||||
Reference in New Issue
Block a user