use std::{
io::Read,
net::{IpAddr, Ipv4Addr, Ipv6Addr},
};
use async_trait::async_trait;
use http::{Request, Response, Uri};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use crate::{
http::{sanitize_for_log, AsyncHttpResolver, HttpResolverError, SyncHttpResolver},
Result,
};
#[derive(Debug)]
pub struct RestrictedResolver<T> {
inner: T,
allowed_hosts: Option<Vec<HostPattern>>,
}
impl<T> RestrictedResolver<T> {
pub fn new(inner: T) -> Self {
Self {
inner,
allowed_hosts: None,
}
}
#[allow(dead_code)] pub fn with_allowed_hosts(inner: T, allowed_hosts: Vec<HostPattern>) -> Self {
Self {
inner,
allowed_hosts: Some(allowed_hosts),
}
}
pub fn set_allowed_hosts(&mut self, allowed_hosts: Option<Vec<HostPattern>>) {
self.allowed_hosts = allowed_hosts;
}
#[allow(dead_code)] pub fn allowed_hosts(&self) -> Option<&[HostPattern]> {
self.allowed_hosts.as_deref()
}
fn is_uri_allowed(&self, uri: &Uri) -> bool {
self.allowed_hosts
.as_ref()
.map(|hosts| is_uri_allowed(hosts, uri))
.unwrap_or(true) }
}
impl<T: SyncHttpResolver> SyncHttpResolver for RestrictedResolver<T> {
fn http_resolve(
&self,
request: Request<Vec<u8>>,
) -> Result<Response<Box<dyn Read>>, HttpResolverError> {
if !self.is_uri_allowed(request.uri()) {
return Err(HttpResolverError::UriDisallowed {
uri: sanitize_for_log(&request.uri().to_string()),
});
}
self.inner.http_resolve(request)
}
}
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
impl<T: AsyncHttpResolver + Sync> AsyncHttpResolver for RestrictedResolver<T> {
async fn http_resolve_async(
&self,
request: Request<Vec<u8>>,
) -> Result<Response<Box<dyn Read>>, HttpResolverError> {
if !self.is_uri_allowed(request.uri()) {
return Err(HttpResolverError::UriDisallowed {
uri: sanitize_for_log(&request.uri().to_string()),
});
}
self.inner.http_resolve_async(request).await
}
}
#[cfg_attr(
feature = "json_schema",
derive(schemars::JsonSchema),
schemars(with = "String")
)]
#[derive(Debug, Clone, PartialEq)]
pub struct HostPattern {
pattern: String,
scheme: Option<String>,
host: Option<String>,
port: Option<String>,
}
impl HostPattern {
pub fn new(pattern: &str) -> Self {
let pattern = pattern.to_ascii_lowercase();
let (scheme, rest): (Option<String>, &str) =
if let Some(host) = pattern.strip_prefix("https://") {
(Some("https".to_owned()), host)
} else if let Some(host) = pattern.strip_prefix("http://") {
(Some("http".to_owned()), host)
} else {
(None, &pattern)
};
let (host, port) = if let Some((host, port)) = rest.rsplit_once(':') {
(host, Some(port.to_owned()))
} else {
(rest, None)
};
Self {
host: if host.is_empty() {
None
} else {
Some(host.to_owned())
},
pattern,
scheme,
port,
}
}
pub fn matches(&self, uri: &Uri) -> bool {
if let Some(allowed_host_pattern) = &self.host {
if let Some(host) = uri.host() {
let is_host_allowed = if let Some(suffix) = allowed_host_pattern.strip_prefix("*.")
{
let host = host.to_ascii_lowercase();
if host.len() <= suffix.len() || !host.ends_with(&suffix) {
false
} else {
host.as_bytes()[host.len() - suffix.len() - 1] == b'.'
}
} else {
allowed_host_pattern.eq_ignore_ascii_case(host)
};
let is_port_allowed =
self.port.as_deref() == uri.port().as_ref().map(|port| port.as_str());
if is_host_allowed && is_port_allowed {
if let Some(allowed_scheme) = &self.scheme {
if let Some(scheme) = uri.scheme() {
return scheme.as_str() == allowed_scheme;
}
} else {
return true;
}
}
}
} else if let Some(allowed_scheme) = &self.scheme {
if let Some(scheme) = uri.scheme() {
return scheme.as_str() == allowed_scheme;
}
}
false
}
}
impl From<&str> for HostPattern {
fn from(pattern: &str) -> Self {
Self::new(pattern)
}
}
impl Serialize for HostPattern {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&self.pattern.to_string())
}
}
impl<'de> Deserialize<'de> for HostPattern {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
Ok(HostPattern::new(&String::deserialize(deserializer)?))
}
}
pub(crate) fn is_uri_allowed(patterns: &[HostPattern], uri: &Uri) -> bool {
for pattern in patterns {
if pattern.matches(uri) {
return true;
}
}
false
}
const MAX_REDIRECTS: usize = 10;
#[derive(Debug)]
pub(crate) struct RedirectResolver<T> {
inner: T,
allow_redirects: bool,
}
impl<T> RedirectResolver<T> {
pub(crate) fn new(inner: T, allow_redirects: bool) -> Self {
Self {
inner,
allow_redirects,
}
}
fn redirect_target<B>(
&self,
from_uri: &Uri,
response: &Response<B>,
) -> Result<Option<Uri>, HttpResolverError> {
let Some(location) = redirect_location(response) else {
return Ok(None);
};
if !self.allow_redirects {
return Err(HttpResolverError::RedirectDisallowed {
uri: sanitize_for_log(&from_uri.to_string()),
location: sanitize_for_log(&location),
});
}
let target = resolve_redirect_target(from_uri, &location)?;
if host_is_non_global(&target) {
return Err(HttpResolverError::RedirectTargetDisallowed {
uri: sanitize_for_log(&from_uri.to_string()),
location: sanitize_for_log(&target.to_string()),
});
}
Ok(Some(target))
}
}
impl<T: SyncHttpResolver> SyncHttpResolver for RedirectResolver<T> {
fn http_resolve(
&self,
mut request: Request<Vec<u8>>,
) -> Result<Response<Box<dyn Read>>, HttpResolverError> {
for _ in 0..=MAX_REDIRECTS {
let from_uri = request.uri().clone();
let method = request.method().clone();
let headers = request.headers().clone();
let body = request.body().clone();
let response = self.inner.http_resolve(request)?;
match self.redirect_target(&from_uri, &response)? {
None => return Ok(response),
Some(target) => {
request = build_redirected_request(method, headers, body, target)?;
}
}
}
Err(HttpResolverError::TooManyRedirects {
uri: sanitize_for_log(&request.uri().to_string()),
})
}
}
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
impl<T: AsyncHttpResolver + Sync> AsyncHttpResolver for RedirectResolver<T> {
async fn http_resolve_async(
&self,
mut request: Request<Vec<u8>>,
) -> Result<Response<Box<dyn Read>>, HttpResolverError> {
for _ in 0..=MAX_REDIRECTS {
let from_uri = request.uri().clone();
let method = request.method().clone();
let headers = request.headers().clone();
let body = request.body().clone();
let response = self.inner.http_resolve_async(request).await?;
match self.redirect_target(&from_uri, &response)? {
None => return Ok(response),
Some(target) => {
request = build_redirected_request(method, headers, body, target)?;
}
}
}
Err(HttpResolverError::TooManyRedirects {
uri: sanitize_for_log(&request.uri().to_string()),
})
}
}
fn redirect_location<B>(response: &Response<B>) -> Option<String> {
if !response.status().is_redirection() {
return None;
}
response
.headers()
.get(http::header::LOCATION)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_owned())
}
fn resolve_redirect_target(base: &Uri, location: &str) -> Result<Uri, HttpResolverError> {
let base_url =
url::Url::parse(&base.to_string()).map_err(|e| HttpResolverError::Other(Box::new(e)))?;
let target = base_url
.join(location)
.map_err(|e| HttpResolverError::Other(Box::new(e)))?;
target
.as_str()
.parse::<Uri>()
.map_err(|e| HttpResolverError::Http(http::Error::from(e)))
}
fn build_redirected_request(
method: http::Method,
headers: http::HeaderMap,
body: Vec<u8>,
target: Uri,
) -> Result<Request<Vec<u8>>, HttpResolverError> {
let mut builder = Request::builder().method(method).uri(target);
for (name, value) in headers.iter() {
let drop = name == http::header::HOST
|| name == http::header::AUTHORIZATION
|| name == http::header::COOKIE
|| name == http::header::PROXY_AUTHORIZATION;
if !drop {
builder = builder.header(name, value);
}
}
builder.body(body).map_err(HttpResolverError::Http)
}
pub(crate) fn host_is_non_global(uri: &Uri) -> bool {
let Some(host) = uri.host() else {
return true;
};
let host = normalize_host(host);
if let Ok(ip) = host.parse::<IpAddr>() {
return ip_is_non_global(ip);
}
if looks_like_obfuscated_ip(&host) {
return true;
}
host == "localhost" || host.ends_with(".localhost")
}
fn normalize_host(host: &str) -> String {
let host = host
.strip_prefix('[')
.and_then(|h| h.strip_suffix(']'))
.unwrap_or(host);
let host = host.strip_suffix('.').unwrap_or(host);
host.to_ascii_lowercase()
}
fn looks_like_obfuscated_ip(host: &str) -> bool {
if host.is_empty() {
return false;
}
if host.bytes().all(|b| b.is_ascii_digit() || b == b'.') {
return true;
}
host.split('.')
.any(|label| label.starts_with("0x") || label.starts_with("0X"))
}
fn ip_is_non_global(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(v4) => ipv4_is_non_global(v4),
IpAddr::V6(v6) => ipv6_is_non_global(v6),
}
}
fn ipv4_is_non_global(ip: Ipv4Addr) -> bool {
let [a, b, ..] = ip.octets();
ip.is_unspecified() || a == 0 || ip.is_loopback() || ip.is_private() || ip.is_link_local() || ip.is_broadcast() || ip.is_documentation() || ip.is_multicast() || (a == 100 && (b & 0xc0) == 64) }
fn ipv6_is_non_global(ip: Ipv6Addr) -> bool {
if let Some(v4) = ip.to_ipv4_mapped() {
return ipv4_is_non_global(v4);
}
let segments = ip.segments();
ip.is_unspecified() || ip.is_loopback() || ip.is_multicast() || (segments[0] & 0xfe00) == 0xfc00 || (segments[0] & 0xffc0) == 0xfe80 }
#[cfg(test)]
mod test {
#![allow(clippy::panic, clippy::unwrap_used)]
use super::*;
struct NoopHttpResolver;
impl SyncHttpResolver for NoopHttpResolver {
fn http_resolve(
&self,
_request: Request<Vec<u8>>,
) -> Result<Response<Box<dyn Read>>, HttpResolverError> {
Ok(Response::new(Box::new(std::io::empty()) as Box<dyn Read>))
}
}
fn assert_allowed_uri(resolver: &impl SyncHttpResolver, uri: &'static str) {
let result = resolver.http_resolve(
Request::get(Uri::from_static(uri))
.body(Vec::new())
.unwrap(),
);
assert!(matches!(result, Ok(..)));
}
fn assert_disallowed_uri(resolver: &impl SyncHttpResolver, uri: &'static str) {
let result = resolver.http_resolve(
Request::get(Uri::from_static(uri))
.body(Vec::new())
.unwrap(),
);
assert!(matches!(
result,
Err(HttpResolverError::UriDisallowed { .. })
));
}
#[test]
fn allowed_http_request() {
let allowed_list = vec![
"*.prefix.contentauthenticity.org".into(),
"test.contentauthenticity.org".into(),
"fakecontentauthenticity.org".into(),
"https://*.contentauthenticity.org".into(),
"https://test.contentauthenticity.org".into(),
];
let restricted_resolver =
RestrictedResolver::with_allowed_hosts(NoopHttpResolver, allowed_list);
assert_allowed_uri(&restricted_resolver, "fakecontentauthenticity.org");
assert_allowed_uri(&restricted_resolver, "test.prefix.contentauthenticity.org");
assert_allowed_uri(&restricted_resolver, "https://test.contentauthenticity.org");
assert_allowed_uri(
&restricted_resolver,
"https://test2.contentauthenticity.org",
);
assert_disallowed_uri(&restricted_resolver, "test.test.contentauthenticity.org");
assert_disallowed_uri(
&restricted_resolver,
"https://test.prefix.fakecontentauthenticity.org",
);
assert_disallowed_uri(
&restricted_resolver,
"https://test.fakecontentauthenticity.org",
);
assert_disallowed_uri(&restricted_resolver, "https://contentauthenticity.org");
}
#[test]
fn allowed_none_http_request() {
let allowed_list = vec![];
let restricted_resolver =
RestrictedResolver::with_allowed_hosts(NoopHttpResolver, allowed_list);
assert_disallowed_uri(
&restricted_resolver,
"test.test.fakecontentauthenticity.org",
);
assert_disallowed_uri(
&restricted_resolver,
"https://test.prefix.fakecontentauthenticity.org",
);
assert_disallowed_uri(
&restricted_resolver,
"https://test.fakecontentauthenticity.org",
);
assert_disallowed_uri(&restricted_resolver, "https://contentauthenticity.org");
}
#[test]
fn wildcard_pattern() {
let pattern = HostPattern::new("*.contentauthenticity.org");
let uri = Uri::from_static("test.contentauthenticity.org");
assert!(pattern.matches(&uri));
let uri = Uri::from_static("contentauthenticity.org");
assert!(!pattern.matches(&uri));
let uri = Uri::from_static("fakecontentauthenticity.org");
assert!(!pattern.matches(&uri));
}
#[test]
fn wildcard_pattern_with_scheme() {
let pattern = HostPattern::new("https://*.contentauthenticity.org");
let uri = Uri::from_static("test.contentauthenticity.org");
assert!(!pattern.matches(&uri));
let uri = Uri::from_static("contentauthenticity.org");
assert!(!pattern.matches(&uri));
let uri = Uri::from_static("fakecontentauthenticity.org");
assert!(!pattern.matches(&uri));
let uri = Uri::from_static("https://test.contentauthenticity.org");
assert!(pattern.matches(&uri));
let uri = Uri::from_static("https://contentauthenticity.org");
assert!(!pattern.matches(&uri));
let uri = Uri::from_static("https://fakecontentauthenticity.org");
assert!(!pattern.matches(&uri));
let uri = Uri::from_static("http://test.contentauthenticity.org");
assert!(!pattern.matches(&uri));
}
#[test]
fn case_insensitive_pattern() {
let pattern = HostPattern::new("*.contentAuthenticity.org");
let uri = Uri::from_static("tEst.conTentauthenticity.orG");
assert!(pattern.matches(&uri));
}
#[test]
fn exact_pattern() {
let pattern = HostPattern::new("contentauthenticity.org");
let uri = Uri::from_static("contentauthenticity.org");
assert!(pattern.matches(&uri));
let uri = Uri::from_static("https://contentauthenticity.org");
assert!(pattern.matches(&uri));
let uri = Uri::from_static("http://contentauthenticity.org");
assert!(pattern.matches(&uri));
}
#[test]
fn exact_pattern_with_schema() {
let pattern = HostPattern::new("https://contentauthenticity.org");
let uri = Uri::from_static("https://contentauthenticity.org");
assert!(pattern.matches(&uri));
let uri = Uri::from_static("http://contentauthenticity.org");
assert!(!pattern.matches(&uri));
let uri = Uri::from_static("contentauthenticity.org");
assert!(!pattern.matches(&uri));
}
#[test]
fn exact_pattern_ip_address() {
let pattern = HostPattern::new("192.0.2.1");
let uri = Uri::from_static("192.0.2.1");
assert!(pattern.matches(&uri));
let uri = Uri::from_static("192.0.2.1.1");
assert!(!pattern.matches(&uri));
}
#[test]
fn exact_pattern_ip_address_with_port() {
let pattern = HostPattern::new("192.0.2.1:443");
let uri = Uri::from_static("192.0.2.1:443");
assert!(pattern.matches(&uri));
let uri = Uri::from_static("192.0.2.1");
assert!(!pattern.matches(&uri));
}
#[test]
fn exact_pattern_hostname_with_port() {
let pattern = HostPattern::new("contentauthenticity.org:8080");
let uri = Uri::from_static("contentauthenticity.org:8080");
assert!(pattern.matches(&uri));
let uri = Uri::from_static("contentauthenticity.org");
assert!(!pattern.matches(&uri));
}
#[test]
fn scheme_only_pattern() {
let pattern = HostPattern::new("https://");
let uri = Uri::from_static("https://contentauthenticity.org");
assert!(pattern.matches(&uri));
let uri = Uri::from_static("http://contentauthenticity.org");
assert!(!pattern.matches(&uri));
let uri = Uri::from_static("contentauthenticity.org");
assert!(!pattern.matches(&uri));
}
#[test]
fn invalid_pattern() {
let pattern = HostPattern::new("https:// ");
let uri = Uri::from_static("https://contentauthenticity.org");
assert!(!pattern.matches(&uri));
}
#[test]
fn test_restricted_generic_resolver() {
use crate::http::{HttpResolverError, SyncGenericResolver, SyncHttpResolver};
let inner = SyncGenericResolver::new();
let mut resolver = RestrictedResolver::new(inner);
resolver.set_allowed_hosts(Some(vec!["127.0.0.1".into()]));
let request = http::Request::get("http://127.0.0.1/test")
.body(vec![])
.unwrap();
let result = resolver.http_resolve(request);
assert!(!matches!(
result,
Err(HttpResolverError::UriDisallowed { .. })
));
let request = http::Request::get("http://example.com/test")
.body(vec![])
.unwrap();
let result = resolver.http_resolve(request);
assert!(matches!(
result,
Err(HttpResolverError::UriDisallowed { .. })
));
}
#[cfg(not(target_arch = "wasm32"))]
#[tokio::test]
async fn test_restricted_async_generic_resolver() {
use crate::http::{AsyncGenericResolver, AsyncHttpResolver, HttpResolverError};
let inner = AsyncGenericResolver::new();
let mut resolver = RestrictedResolver::new(inner);
resolver.set_allowed_hosts(Some(vec!["127.0.0.1".into()]));
let request = http::Request::get("http://127.0.0.1/test")
.body(vec![])
.unwrap();
let result = resolver.http_resolve_async(request).await;
assert!(!matches!(
result,
Err(HttpResolverError::UriDisallowed { .. })
));
let request = http::Request::get("http://example.com/test")
.body(vec![])
.unwrap();
let result = resolver.http_resolve_async(request).await;
assert!(matches!(
result,
Err(HttpResolverError::UriDisallowed { .. })
));
}
#[test]
fn non_global_hosts_are_rejected_as_redirect_targets() {
for uri in [
"http://169.254.169.254/latest/meta-data/", "http://127.0.0.1/", "http://127.1.2.3/", "http://10.0.0.1/", "http://172.16.5.4/", "http://192.168.1.1/", "http://100.64.0.1/", "http://0.0.0.0/", "http://[::1]/", "http://[fe80::1]/", "http://[fc00::1]/", "http://[::ffff:169.254.169.254]/", "http://localhost/", "http://api.localhost/", "http://LOCALHOST/", "http://127.0.0.1./", "http://2130706433/", "http://0x7f000001/", "http://127.0x1/", "http://0x7f.0x0.0x0.0x1/", "http://127.1/", ] {
assert!(
host_is_non_global(&Uri::from_static(uri)),
"expected {uri} to be denied"
);
}
}
#[test]
fn global_hosts_are_allowed_as_redirect_targets() {
for uri in [
"http://93.184.216.34/", "https://contentauthenticity.org/", "https://sub.example.com/path",
"http://[2606:2800:220:1:248:1893:25c8:1946]/", "http://cafe.example.com/", ] {
assert!(
!host_is_non_global(&Uri::from_static(uri)),
"expected {uri} to be allowed"
);
}
}
struct ScriptedChain;
impl ScriptedChain {
fn response(uri: &str) -> Response<Box<dyn Read>> {
let empty = || Box::new(std::io::empty()) as Box<dyn Read>;
let redirect_to = |location: &str| {
Response::builder()
.status(302)
.header(http::header::LOCATION, location)
.body(empty())
.unwrap()
};
if uri.contains("/final") {
Response::builder().status(200).body(empty()).unwrap()
} else if uri.contains("/to-public") {
redirect_to("http://example.com/final")
} else if uri.contains("/to-internal") {
redirect_to("http://169.254.169.254/latest/meta-data/")
} else if uri.contains("/loop") {
redirect_to("http://example.com/loop")
} else {
Response::builder().status(404).body(empty()).unwrap()
}
}
}
impl SyncHttpResolver for ScriptedChain {
fn http_resolve(
&self,
request: Request<Vec<u8>>,
) -> Result<Response<Box<dyn Read>>, HttpResolverError> {
Ok(Self::response(&request.uri().to_string()))
}
}
fn scripted_get(
resolver: &impl SyncHttpResolver,
uri: &'static str,
) -> Result<Response<Box<dyn Read>>, HttpResolverError> {
resolver.http_resolve(
Request::get(Uri::from_static(uri))
.body(Vec::new())
.unwrap(),
)
}
#[test]
fn redirect_resolver_follows_redirect_to_public_host() {
let resolver = RedirectResolver::new(ScriptedChain, true);
let response = scripted_get(&resolver, "http://example.com/to-public").unwrap();
assert_eq!(response.status(), 200);
}
#[test]
fn redirect_resolver_blocks_redirect_to_internal_host() {
let resolver = RedirectResolver::new(ScriptedChain, true);
let result = scripted_get(&resolver, "http://example.com/to-internal");
assert!(matches!(
result,
Err(HttpResolverError::RedirectTargetDisallowed { .. })
));
}
#[test]
fn redirect_resolver_refuses_redirect_when_disabled() {
let resolver = RedirectResolver::new(ScriptedChain, false);
let result = scripted_get(&resolver, "http://example.com/to-public");
assert!(matches!(
result,
Err(HttpResolverError::RedirectDisallowed { .. })
));
}
#[test]
fn redirect_resolver_caps_redirect_chain() {
let resolver = RedirectResolver::new(ScriptedChain, true);
let result = scripted_get(&resolver, "http://example.com/loop");
assert!(matches!(
result,
Err(HttpResolverError::TooManyRedirects { .. })
));
}
#[test]
fn resolve_redirect_target_handles_relative_locations() {
let base = Uri::from_static("https://example.com/a/b");
let target = resolve_redirect_target(&base, "/c/d").unwrap();
assert_eq!(target.to_string(), "https://example.com/c/d");
let target = resolve_redirect_target(&base, "https://other.example.org/x").unwrap();
assert_eq!(target.to_string(), "https://other.example.org/x");
}
#[test]
fn sanitize_for_log_escapes_control_chars_and_truncates() {
let out = sanitize_for_log("http://x/\r\nSet-Cookie: evil");
assert!(!out.contains('\n') && !out.contains('\r'));
assert!(out.contains("\\r") && out.contains("\\n"));
let out = sanitize_for_log(&"a".repeat(1000));
assert!(out.ends_with('…'));
assert!(out.chars().count() <= 257);
}
#[test]
fn build_redirected_request_drops_credential_headers() {
use http::header;
let mut headers = http::HeaderMap::new();
headers.insert(header::AUTHORIZATION, "Bearer secret".parse().unwrap());
headers.insert(header::COOKIE, "sid=secret".parse().unwrap());
headers.insert(header::HOST, "old.example.com".parse().unwrap());
headers.insert(header::ACCEPT, "application/json".parse().unwrap());
let req = build_redirected_request(
http::Method::GET,
headers,
Vec::new(),
Uri::from_static("https://new.example.com/x"),
)
.unwrap();
assert!(req.headers().get(header::AUTHORIZATION).is_none());
assert!(req.headers().get(header::COOKIE).is_none());
assert!(req.headers().get(header::HOST).is_none());
assert_eq!(
req.headers().get(header::ACCEPT).unwrap(),
"application/json"
);
}
}