plugin: harden https target validation

This commit is contained in:
Keisuke Hirata 2026-06-20 00:21:37 +09:00
parent 7377527f7c
commit 85683f17c3
No known key found for this signature in database

View File

@ -9,7 +9,7 @@
use std::collections::HashSet; use std::collections::HashSet;
use std::io::Read as _; use std::io::Read as _;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, ToSocketAddrs}; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, ToSocketAddrs};
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
@ -369,19 +369,23 @@ impl PluginHttpsClient for ReqwestPluginHttpsClient {
url: &reqwest::Url, url: &reqwest::Url,
limits: PluginHttpsLimits, limits: PluginHttpsLimits,
) -> Result<PluginHttpsResponse, PluginHttpsError> { ) -> Result<PluginHttpsResponse, PluginHttpsError> {
validate_dns_target(url)?; let pinned_resolution = resolve_https_target_for_client(url, &SystemPluginHttpsResolver)?;
let method = reqwest::Method::from_bytes(request.method.as_bytes()).map_err(|_| { let method = reqwest::Method::from_bytes(request.method.as_bytes()).map_err(|_| {
PluginHttpsError::new(format!("unsupported HTTPS method `{}`", request.method)) PluginHttpsError::new(format!("unsupported HTTPS method `{}`", request.method))
})?; })?;
let client = reqwest::blocking::Client::builder() let mut client_builder = reqwest::blocking::Client::builder()
.redirect(reqwest::redirect::Policy::none()) .redirect(reqwest::redirect::Policy::none())
.timeout(limits.timeout) .timeout(limits.timeout)
.no_proxy() .no_proxy()
.user_agent("yoi-plugin-https-host-api/0.1") .user_agent("yoi-plugin-https-host-api/0.1");
.build() if let Some(pinned_resolution) = &pinned_resolution {
.map_err(|error| { for domain in &pinned_resolution.domains {
PluginHttpsError::new(format!("HTTPS client build failed: {error}")) client_builder = client_builder.resolve_to_addrs(domain, &pinned_resolution.addrs);
})?; }
}
let client = client_builder.build().map_err(|error| {
PluginHttpsError::new(format!("HTTPS client build failed: {error}"))
})?;
let mut builder = client.request(method, url.clone()).timeout(limits.timeout); let mut builder = client.request(method, url.clone()).timeout(limits.timeout);
for header in &request.headers { for header in &request.headers {
let name = let name =
@ -598,10 +602,17 @@ fn authorize_https_allowlist(
} }
fn canonical_grant_host(host: &str) -> Option<String> { fn canonical_grant_host(host: &str) -> Option<String> {
let value = host.trim().trim_end_matches('.').to_ascii_lowercase(); let value = normalize_host_literal(host.trim());
if value.is_empty() { None } else { Some(value) } if value.is_empty() { None } else { Some(value) }
} }
fn normalize_host_literal(host: &str) -> String {
host.trim_end_matches('.')
.trim_start_matches('[')
.trim_end_matches(']')
.to_ascii_lowercase()
}
fn has_usable_https_grant(record: &ResolvedPluginRecord) -> bool { fn has_usable_https_grant(record: &ResolvedPluginRecord) -> bool {
record.grants.https.iter().any(|grant| { record.grants.https.iter().any(|grant| {
canonical_grant_host(&grant.host).is_some() canonical_grant_host(&grant.host).is_some()
@ -614,7 +625,7 @@ fn has_usable_https_grant(record: &ResolvedPluginRecord) -> bool {
fn canonical_host(url: &reqwest::Url) -> Result<String, PluginHttpsError> { fn canonical_host(url: &reqwest::Url) -> Result<String, PluginHttpsError> {
url.host_str() url.host_str()
.map(|host| host.trim_end_matches('.').to_ascii_lowercase()) .map(normalize_host_literal)
.filter(|host| !host.is_empty()) .filter(|host| !host.is_empty())
.ok_or_else(|| PluginHttpsError::new("HTTPS URL must include a host")) .ok_or_else(|| PluginHttpsError::new("HTTPS URL must include a host"))
} }
@ -638,28 +649,41 @@ fn validate_static_https_target(url: &reqwest::Url) -> Result<(), PluginHttpsErr
Ok(()) Ok(())
} }
fn validate_dns_target(url: &reqwest::Url) -> Result<(), PluginHttpsError> { fn resolve_https_target_for_client(
url: &reqwest::Url,
resolver: &dyn PluginHttpsResolver,
) -> Result<Option<PinnedHttpsResolution>, PluginHttpsError> {
let host = canonical_host(url)?; let host = canonical_host(url)?;
if host.parse::<IpAddr>().is_ok() { if host.parse::<IpAddr>().is_ok() {
return Ok(()); return Ok(None);
} }
let port = url let port = url
.port_or_known_default() .port_or_known_default()
.ok_or_else(|| PluginHttpsError::new("HTTPS URL uses a scheme without a default port"))?; .ok_or_else(|| PluginHttpsError::new("HTTPS URL uses a scheme without a default port"))?;
let mut resolved = false; let addrs = resolver.resolve(&host, port)?;
for addr in (host.as_str(), port).to_socket_addrs().map_err(|error| { if addrs.is_empty() {
PluginHttpsError::new(format!("DNS lookup failed for {:?}: {error}", host))
})? {
resolved = true;
validate_public_ip(addr.ip(), &host)?;
}
if !resolved {
return Err(PluginHttpsError::new(format!( return Err(PluginHttpsError::new(format!(
"DNS lookup for {:?} returned no addresses", "DNS lookup for {:?} returned no addresses",
host host
))); )));
} }
Ok(()) for addr in &addrs {
validate_public_ip(addr.ip(), &host)?;
}
let mut domains = Vec::new();
if let Some(raw_host) = url.host_str() {
let raw_host = raw_host
.trim_start_matches('[')
.trim_end_matches(']')
.to_ascii_lowercase();
if !raw_host.is_empty() {
domains.push(raw_host);
}
}
if !domains.contains(&host) {
domains.push(host);
}
Ok(Some(PinnedHttpsResolution { domains, addrs }))
} }
fn validate_public_ip(ip: IpAddr, host: &str) -> Result<(), PluginHttpsError> { fn validate_public_ip(ip: IpAddr, host: &str) -> Result<(), PluginHttpsError> {
@ -697,6 +721,9 @@ fn is_forbidden_ipv4(ip: Ipv4Addr) -> bool {
} }
fn is_forbidden_ipv6(ip: Ipv6Addr) -> bool { fn is_forbidden_ipv6(ip: Ipv6Addr) -> bool {
if let Some(mapped) = ipv6_embedded_ipv4(ip) {
return is_forbidden_ipv4(mapped);
}
ip.is_loopback() ip.is_loopback()
|| ip.is_unspecified() || ip.is_unspecified()
|| (ip.segments()[0] & 0xfe00) == 0xfc00 || (ip.segments()[0] & 0xfe00) == 0xfc00
@ -704,6 +731,22 @@ fn is_forbidden_ipv6(ip: Ipv6Addr) -> bool {
|| (ip.segments()[0] & 0xff00) == 0xff00 || (ip.segments()[0] & 0xff00) == 0xff00
} }
fn ipv6_embedded_ipv4(ip: Ipv6Addr) -> Option<Ipv4Addr> {
if let Some(mapped) = ip.to_ipv4_mapped() {
return Some(mapped);
}
let segments = ip.segments();
if segments[..6] == [0, 0, 0, 0, 0, 0] {
return Some(Ipv4Addr::new(
(segments[6] >> 8) as u8,
segments[6] as u8,
(segments[7] >> 8) as u8,
segments[7] as u8,
));
}
None
}
fn collect_https_response_headers(headers: &reqwest::header::HeaderMap) -> Vec<PluginHttpsHeader> { fn collect_https_response_headers(headers: &reqwest::header::HeaderMap) -> Vec<PluginHttpsHeader> {
headers headers
.iter() .iter()
@ -985,6 +1028,29 @@ trait PluginHttpsClient: Send + Sync {
} }
struct ReqwestPluginHttpsClient; struct ReqwestPluginHttpsClient;
struct SystemPluginHttpsResolver;
#[derive(Clone, Debug)]
struct PinnedHttpsResolution {
domains: Vec<String>,
addrs: Vec<SocketAddr>,
}
trait PluginHttpsResolver {
fn resolve(&self, host: &str, port: u16) -> Result<Vec<SocketAddr>, PluginHttpsError>;
}
impl PluginHttpsResolver for SystemPluginHttpsResolver {
fn resolve(&self, host: &str, port: u16) -> Result<Vec<SocketAddr>, PluginHttpsError> {
let mut addrs = Vec::new();
for addr in (host, port).to_socket_addrs().map_err(|error| {
PluginHttpsError::new(format!("DNS lookup failed for {:?}: {error}", host))
})? {
addrs.push(addr);
}
Ok(addrs)
}
}
#[derive(Debug)] #[derive(Debug)]
struct PluginHttpsError(String); struct PluginHttpsError(String);
@ -1910,6 +1976,34 @@ mod tests {
} }
} }
struct FakeHttpsResolver {
calls: Mutex<Vec<(String, u16)>>,
addrs: Vec<SocketAddr>,
}
impl FakeHttpsResolver {
fn new(addrs: Vec<SocketAddr>) -> Self {
Self {
calls: Mutex::new(Vec::new()),
addrs,
}
}
fn calls(&self) -> Vec<(String, u16)> {
self.calls.lock().expect("resolver calls lock").clone()
}
}
impl PluginHttpsResolver for FakeHttpsResolver {
fn resolve(&self, host: &str, port: u16) -> Result<Vec<SocketAddr>, PluginHttpsError> {
self.calls
.lock()
.expect("resolver calls lock")
.push((host.to_string(), port));
Ok(self.addrs.clone())
}
}
fn https_request_json(method: &str, url: &str) -> String { fn https_request_json(method: &str, url: &str) -> String {
json!({ "method": method, "url": url }).to_string() json!({ "method": method, "url": url }).to_string()
} }
@ -2124,6 +2218,61 @@ mod tests {
assert_eq!(client.call_count(), 0); assert_eq!(client.call_count(), 0);
} }
#[test]
fn ipv4_mapped_ipv6_targets_deny_before_network() {
let record = record_with_https_grant();
let client = MockHttpsClient::default();
for url in [
"https://[::ffff:127.0.0.1]/v1/data",
"https://[::ffff:10.0.0.1]/v1/data",
"https://[::ffff:169.254.169.254]/v1/data",
"https://[::10.0.0.1]/v1/data",
] {
let error = execute_plugin_https_request(
&record,
&client,
https_request_json("GET", url).as_bytes(),
)
.expect_err("mapped address denied");
assert!(
error.0.contains("local/private"),
"{url} produced {:?}",
error.0
);
}
assert_eq!(client.call_count(), 0);
}
#[test]
fn dns_resolution_is_pinned_to_validated_public_socket_addresses() {
let url = reqwest::Url::parse("https://api.example.test:8443/v1/data").unwrap();
let resolver = FakeHttpsResolver::new(vec!["93.184.216.34:8443".parse().unwrap()]);
let pinned = resolve_https_target_for_client(&url, &resolver)
.expect("resolution")
.expect("hostname resolution is pinned");
assert_eq!(
resolver.calls(),
vec![("api.example.test".to_string(), 8443)]
);
assert_eq!(pinned.domains, vec!["api.example.test".to_string()]);
assert_eq!(pinned.addrs, vec!["93.184.216.34:8443".parse().unwrap()]);
let mut builder = reqwest::blocking::Client::builder().no_proxy();
for domain in &pinned.domains {
builder = builder.resolve_to_addrs(domain, &pinned.addrs);
}
builder.build().expect("client accepts pinned resolver");
}
#[test]
fn dns_resolution_rejects_private_addresses_before_client_build() {
let url = reqwest::Url::parse("https://api.example.test/v1/data").unwrap();
let resolver = FakeHttpsResolver::new(vec!["127.0.0.1:443".parse().unwrap()]);
let error =
resolve_https_target_for_client(&url, &resolver).expect_err("private DNS answer");
assert!(error.0.contains("local/private"));
}
#[test] #[test]
fn timeout_and_secret_diagnostics_are_bounded_and_redacted() { fn timeout_and_secret_diagnostics_are_bounded_and_redacted() {
let record = record_with_https_grant(); let record = record_with_https_grant();