From 0c99fa253d59fcc6a87184947f0c2d2cabf7be08 Mon Sep 17 00:00:00 2001 From: Marco Allegretti Date: Thu, 5 Mar 2026 13:32:28 +0100 Subject: [PATCH] Harden registry installs: DNS SSRF checks, timeout, size cap --- backend/src/api/plugins.rs | 108 +++++++++++++++++++++++++++++++++---- 1 file changed, 99 insertions(+), 9 deletions(-) diff --git a/backend/src/api/plugins.rs b/backend/src/api/plugins.rs index f357f0f..c17c62c 100644 --- a/backend/src/api/plugins.rs +++ b/backend/src/api/plugins.rs @@ -16,6 +16,8 @@ use sqlx::PgPool; use sqlx::Row; use std::net::IpAddr; use std::sync::Arc; +use std::time::Duration; +use tokio::net::lookup_host; use uuid::Uuid; use crate::auth::AuthUser; @@ -443,7 +445,10 @@ fn verify_signature_if_required( Ok(Some(sig_arr.to_vec())) } -fn enforce_registry_allowlist(url: &Url, allowlist: &[String]) -> Result<(), (StatusCode, String)> { +async fn enforce_registry_allowlist( + url: &Url, + allowlist: &[String], +) -> Result<(), (StatusCode, String)> { let host = url.host_str().ok_or(( StatusCode::BAD_REQUEST, "Registry URL must include host".to_string(), @@ -477,11 +482,63 @@ fn enforce_registry_allowlist(url: &Url, allowlist: &[String]) -> Result<(), (St )); } - if !allowlist.is_empty() && !allowlist.iter().any(|h| h == host) { - return Err(( - StatusCode::FORBIDDEN, - "Registry host not in allowlist".to_string(), - )); + if !allowlist.is_empty() { + let allowed = allowlist.iter().any(|pattern| { + if pattern == "*" { + return true; + } + + if pattern.starts_with("*.") { + let suffix = &pattern[1..]; + host.ends_with(suffix) || host.eq_ignore_ascii_case(&pattern[2..]) + } else { + host.eq_ignore_ascii_case(pattern) + } + }); + + if !allowed { + return Err(( + StatusCode::FORBIDDEN, + "Registry host not in allowlist".to_string(), + )); + } + } + + let port = url.port_or_known_default().unwrap_or(443); + let lookup = tokio::time::timeout(Duration::from_secs(3), lookup_host((host, port))) + .await + .map_err(|_| { + ( + StatusCode::BAD_GATEWAY, + "Registry host DNS lookup timed out".to_string(), + ) + }) + .and_then(|res| { + res.map_err(|_| { + ( + StatusCode::BAD_GATEWAY, + "Registry host DNS lookup failed".to_string(), + ) + }) + })?; + + for addr in lookup { + let ip = addr.ip(); + let is_disallowed = match ip { + IpAddr::V4(v4) => { + v4.is_loopback() || v4.is_private() || v4.is_link_local() || v4.is_unspecified() + } + IpAddr::V6(v6) => { + v6.is_loopback() || v6.is_unique_local() || v6.is_unicast_link_local() || v6.is_unspecified() + } + }; + + if is_disallowed { + return Err(( + StatusCode::FORBIDDEN, + "Registry host is not allowed".to_string(), + )); + } } Ok(()) @@ -1049,9 +1106,18 @@ async fn install_registry_plugin_package( } } - enforce_registry_allowlist(&url, ®istry_allowlist)?; + enforce_registry_allowlist(&url, ®istry_allowlist).await?; - let res = reqwest::get(url.clone()) + const MAX_REGISTRY_BUNDLE_BYTES: u64 = 2 * 1024 * 1024; + + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(10)) + .build() + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let mut res = client + .get(url.clone()) + .send() .await .map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?; @@ -1059,7 +1125,31 @@ async fn install_registry_plugin_package( return Err((StatusCode::BAD_GATEWAY, "Registry fetch failed".to_string())); } - let bundle: UploadPluginPackageRequest = res.json().await.map_err(|_| { + if let Some(len) = res.content_length() { + if len > MAX_REGISTRY_BUNDLE_BYTES { + return Err(( + StatusCode::BAD_GATEWAY, + "Registry response too large".to_string(), + )); + } + } + + let mut body: Vec = Vec::new(); + while let Some(chunk) = res + .chunk() + .await + .map_err(|_| (StatusCode::BAD_GATEWAY, "Registry fetch failed".to_string()))? + { + body.extend_from_slice(&chunk); + if body.len() as u64 > MAX_REGISTRY_BUNDLE_BYTES { + return Err(( + StatusCode::BAD_GATEWAY, + "Registry response too large".to_string(), + )); + } + } + + let bundle: UploadPluginPackageRequest = serde_json::from_slice(&body).map_err(|_| { ( StatusCode::BAD_GATEWAY, "Invalid registry response".to_string(),