Harden registry installs: DNS SSRF checks, timeout, size cap

This commit is contained in:
Marco Allegretti 2026-03-05 13:32:28 +01:00
parent dc6647efbf
commit 0c99fa253d

View file

@ -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,12 +482,64 @@ fn enforce_registry_allowlist(url: &Url, allowlist: &[String]) -> Result<(), (St
));
}
if !allowlist.is_empty() && !allowlist.iter().any(|h| h == host) {
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, &registry_allowlist)?;
enforce_registry_allowlist(&url, &registry_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<u8> = 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(),