use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
use std::sync::LazyLock;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
const DEFAULT_REST_TIMEOUT_SECS: u64 = 30;
const DEFAULT_REST_CONNECT_TIMEOUT_SECS: u64 = 10;
const MAX_REST_REDIRECTS: usize = 10;
pub fn rest_client() -> reqwest::Client {
reqwest::Client::builder()
.timeout(env_secs("VTA_REST_TIMEOUT_SECS", DEFAULT_REST_TIMEOUT_SECS))
.connect_timeout(env_secs(
"VTA_REST_CONNECT_TIMEOUT_SECS",
DEFAULT_REST_CONNECT_TIMEOUT_SECS,
))
.redirect(same_origin_redirect_policy())
.build()
.expect("reqwest client with timeouts (TLS backend init)")
}
fn same_origin_redirect_policy() -> reqwest::redirect::Policy {
reqwest::redirect::Policy::custom(|attempt| {
if attempt.previous().len() > MAX_REST_REDIRECTS {
return attempt.error("too many redirects");
}
let same_origin = attempt
.previous()
.first()
.is_some_and(|original| same_origin(original, attempt.url()));
if same_origin {
attempt.follow()
} else {
attempt.stop()
}
})
}
fn same_origin(a: &reqwest::Url, b: &reqwest::Url) -> bool {
a.scheme() == b.scheme()
&& a.host_str() == b.host_str()
&& a.port_or_known_default() == b.port_or_known_default()
}
fn env_secs(var: &str, default: u64) -> Duration {
let secs = std::env::var(var)
.ok()
.and_then(|v| v.trim().parse::<u64>().ok())
.filter(|&n| n > 0)
.unwrap_or(default);
Duration::from_secs(secs)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum IpClass {
Public,
Loopback,
Private,
SharedAddressSpace,
LinkLocal,
Metadata,
Unspecified,
Broadcast,
Multicast,
Documentation,
Benchmarking,
Reserved,
}
impl IpClass {
pub fn is_public(self) -> bool {
self == IpClass::Public
}
pub fn describe(self) -> &'static str {
match self {
IpClass::Public => "public",
IpClass::Loopback => "loopback",
IpClass::Private => "private-network",
IpClass::SharedAddressSpace => "carrier-grade NAT",
IpClass::LinkLocal => "link-local",
IpClass::Metadata => "cloud metadata",
IpClass::Unspecified => "unspecified",
IpClass::Broadcast => "broadcast",
IpClass::Multicast => "multicast",
IpClass::Documentation => "documentation",
IpClass::Benchmarking => "benchmarking",
IpClass::Reserved => "reserved",
}
}
}
pub fn classify_ip(addr: IpAddr) -> IpClass {
match addr {
IpAddr::V4(a) => classify_ipv4(a),
IpAddr::V6(a) => classify_ipv6(a),
}
}
fn classify_ipv4(a: Ipv4Addr) -> IpClass {
let o = a.octets();
if o[0] == 0 {
IpClass::Unspecified
} else if a.is_loopback() {
IpClass::Loopback
} else if o == [100, 100, 100, 200] {
IpClass::Metadata
} else if a.is_private() {
IpClass::Private
} else if o[0] == 100 && (64..128).contains(&o[1]) {
IpClass::SharedAddressSpace
} else if a.is_link_local() {
IpClass::LinkLocal
} else if a.is_broadcast() {
IpClass::Broadcast
} else if a.is_multicast() {
IpClass::Multicast
} else if a.is_documentation() {
IpClass::Documentation
} else if o[0] == 198 && (o[1] & 0xfe) == 18 {
IpClass::Benchmarking
} else if (o[0] == 192 && o[1] == 0 && o[2] == 0) || o[0] >= 240 {
IpClass::Reserved
} else {
IpClass::Public
}
}
fn classify_ipv6(a: Ipv6Addr) -> IpClass {
if a.is_unspecified() {
return IpClass::Unspecified;
}
if a.is_loopback() {
return IpClass::Loopback;
}
if let Some(v4) = embedded_ipv4(a) {
return classify_ipv4(v4);
}
let s = a.segments();
if a == Ipv6Addr::new(0xfd00, 0x0ec2, 0, 0, 0, 0, 0, 0x0254) {
IpClass::Metadata
} else if a.is_multicast() {
IpClass::Multicast
} else if (s[0] & 0xfe00) == 0xfc00 {
IpClass::Private
} else if (s[0] & 0xffc0) == 0xfe80 {
IpClass::LinkLocal
} else if s[0] == 0x2001 && s[1] == 0x0db8 {
IpClass::Documentation
} else if s[0] == 0x2001 && s[1] == 0x0002 && s[2] == 0 {
IpClass::Benchmarking
} else if (s[0] & 0xffc0) == 0xfec0
|| (s[0] == 0x2001 && s[1] == 0)
|| (s[0] == 0x0064 && s[1] == 0xff9b && s[2] == 0x0001)
|| (s[0] == 0x0100 && s[1..4] == [0, 0, 0])
{
IpClass::Reserved
} else {
IpClass::Public
}
}
fn embedded_ipv4(a: Ipv6Addr) -> Option<Ipv4Addr> {
if a.is_loopback() || a.is_unspecified() {
return None;
}
if let Some(v4) = a.to_ipv4() {
return Some(v4);
}
let s = a.segments();
if s[0] == 0x0064 && s[1] == 0xff9b && s[2..6] == [0, 0, 0, 0] {
return Some(Ipv4Addr::from((u32::from(s[6]) << 16) | u32::from(s[7])));
}
if s[0] == 0x2002 {
return Some(Ipv4Addr::from((u32::from(s[1]) << 16) | u32::from(s[2])));
}
None
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum HostClass {
Ip(IpClass),
Localhost,
LocalName,
MetadataName,
Domain,
}
const METADATA_NAMES: &[&str] = &[
"metadata",
"metadata.google.internal",
"metadata.goog",
"instance-data",
"instance-data.ec2.internal",
];
pub fn classify_host(host: &url::Host<&str>) -> HostClass {
match host {
url::Host::Ipv4(a) => HostClass::Ip(classify_ipv4(*a)),
url::Host::Ipv6(a) => HostClass::Ip(classify_ipv6(*a)),
url::Host::Domain(d) => classify_domain(d),
}
}
fn classify_domain(domain: &str) -> HostClass {
let d = domain.trim_end_matches('.').to_ascii_lowercase();
if d == "localhost" {
HostClass::Localhost
} else if METADATA_NAMES.contains(&d.as_str()) {
HostClass::MetadataName
} else if d.is_empty()
|| !d.contains('.')
|| d.ends_with(".localhost")
|| d.ends_with(".local")
|| d.ends_with(".internal")
|| d.ends_with(".home.arpa")
{
HostClass::LocalName
} else {
HostClass::Domain
}
}
const FOREIGN_FETCH_TIMEOUT: Duration = Duration::from_secs(10);
pub const DEFAULT_MAX_FOREIGN_BODY: usize = 2 * 1024 * 1024;
#[derive(Debug, thiserror::Error)]
pub enum ForeignFetchError {
#[error("{0}")]
Blocked(String),
#[error("response body exceeds the {max}-byte cap")]
BodyTooLarge { max: usize },
#[error("reading response body failed: {0}")]
Read(String),
}
static FOREIGN_FETCH_CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.dns_resolver(PublicOnlyResolver)
.no_proxy()
.timeout(FOREIGN_FETCH_TIMEOUT)
.connect_timeout(FOREIGN_FETCH_TIMEOUT)
.build()
.expect("hardened foreign-fetch client builds from static config")
});
pub fn foreign_fetch_client() -> reqwest::Client {
FOREIGN_FETCH_CLIENT.clone()
}
#[derive(Debug)]
struct BlockedAddress {
host: String,
detail: String,
}
impl std::fmt::Display for BlockedAddress {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "refusing to connect to {}: {}", self.host, self.detail)
}
}
impl std::error::Error for BlockedAddress {}
#[derive(Debug, Clone, Copy)]
struct PublicOnlyResolver;
impl reqwest::dns::Resolve for PublicOnlyResolver {
fn resolve(&self, name: reqwest::dns::Name) -> reqwest::dns::Resolving {
Box::pin(async move {
let host = name.as_str().to_owned();
let addrs: Vec<SocketAddr> = tokio::net::lookup_host((host.as_str(), 0))
.await
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { Box::new(e) })?
.collect();
let vetted = vet_resolved(&host, addrs)
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { Box::new(e) })?;
Ok(Box::new(vetted.into_iter()) as reqwest::dns::Addrs)
})
}
}
fn vet_resolved(host: &str, addrs: Vec<SocketAddr>) -> Result<Vec<SocketAddr>, BlockedAddress> {
if addrs.is_empty() {
return Err(BlockedAddress {
host: host.to_owned(),
detail: "name resolved to no addresses".into(),
});
}
if let Some(bad) = addrs.iter().find(|a| !classify_ip(a.ip()).is_public()) {
return Err(BlockedAddress {
host: host.to_owned(),
detail: format!(
"name resolves to non-public {} address {}",
classify_ip(bad.ip()).describe(),
bad.ip()
),
});
}
Ok(addrs)
}
pub async fn read_body_capped(
mut resp: reqwest::Response,
max: usize,
) -> Result<Vec<u8>, ForeignFetchError> {
let mut buf = Vec::new();
while let Some(chunk) = resp
.chunk()
.await
.map_err(|e| ForeignFetchError::Read(e.to_string()))?
{
if buf.len() + chunk.len() > max {
return Err(ForeignFetchError::BodyTooLarge { max });
}
buf.extend_from_slice(&chunk);
}
Ok(buf)
}
pub fn guard_public_url(url: &str) -> Result<(), ForeignFetchError> {
let parsed = reqwest::Url::parse(url)
.map_err(|e| ForeignFetchError::Blocked(format!("invalid url {url}: {e}")))?;
if parsed.scheme() != "https" {
return Err(ForeignFetchError::Blocked(format!(
"url must be https (got scheme {})",
parsed.scheme()
)));
}
if parsed.username() != "" || parsed.password().is_some() {
return Err(ForeignFetchError::Blocked(
"url must not contain userinfo".into(),
));
}
let host = parsed
.host()
.ok_or_else(|| ForeignFetchError::Blocked("url missing host".into()))?;
match classify_host(&host) {
HostClass::Domain | HostClass::Ip(IpClass::Public) => Ok(()),
HostClass::Ip(class) => Err(ForeignFetchError::Blocked(format!(
"url points at non-public {} IP {host}",
class.describe()
))),
HostClass::Localhost | HostClass::LocalName | HostClass::MetadataName => Err(
ForeignFetchError::Blocked(format!("url points at non-public host name {host}")),
),
}
}
pub const ALLOW_PRIVATE_ENDPOINTS_ENV: &str = "VTA_ALLOW_PRIVATE_ENDPOINTS";
static ALLOW_PRIVATE_ENDPOINTS: AtomicBool = AtomicBool::new(false);
pub fn set_allow_private_endpoints(allow: bool) {
ALLOW_PRIVATE_ENDPOINTS.store(allow, Ordering::Relaxed);
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct EndpointPolicy {
pub allow_private: bool,
}
impl EndpointPolicy {
pub const fn public_only() -> Self {
Self {
allow_private: false,
}
}
pub const fn private_allowed() -> Self {
Self {
allow_private: true,
}
}
pub fn process_default() -> Self {
let from_env = std::env::var(ALLOW_PRIVATE_ENDPOINTS_ENV)
.map(|v| {
matches!(
v.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
)
})
.unwrap_or(false);
Self {
allow_private: from_env || ALLOW_PRIVATE_ENDPOINTS.load(Ordering::Relaxed),
}
}
}
fn is_loopback_host(host: &url::Host<&str>) -> bool {
match host {
url::Host::Domain(d) => d.trim_end_matches('.').eq_ignore_ascii_case("localhost"),
url::Host::Ipv4(ip) => ip.is_loopback(),
url::Host::Ipv6(ip) => ip.is_loopback(),
}
}
pub fn guard_vta_endpoint(
url: &str,
policy: EndpointPolicy,
) -> Result<reqwest::Url, ForeignFetchError> {
let parsed = reqwest::Url::parse(url)
.map_err(|e| ForeignFetchError::Blocked(format!("invalid VTA endpoint URL: {e}")))?;
let scheme = parsed.scheme();
if scheme != "https" && scheme != "http" {
return Err(ForeignFetchError::Blocked(format!(
"VTA endpoint uses unsupported scheme `{scheme}://`; only https:// (or http:// \
to a loopback host) is accepted"
)));
}
if parsed.username() != "" || parsed.password().is_some() {
return Err(ForeignFetchError::Blocked(
"VTA endpoint must not embed credentials (userinfo) in the URL".into(),
));
}
let host = parsed
.host()
.ok_or_else(|| ForeignFetchError::Blocked("VTA endpoint URL has no host".into()))?;
let origin = parsed.origin().ascii_serialization();
if is_loopback_host(&host) {
return Ok(parsed);
}
if scheme == "http" {
return Err(ForeignFetchError::Blocked(format!(
"refusing plaintext http:// VTA endpoint {origin}: only a loopback host \
(localhost, 127.0.0.0/8, ::1) may use http://; advertise an https:// endpoint"
)));
}
let embeds_ipv4 = matches!(host, url::Host::Ipv6(a) if embedded_ipv4(a).is_some());
let private_reason = match classify_host(&host) {
HostClass::Domain | HostClass::Ip(IpClass::Public) => return Ok(parsed),
HostClass::Ip(class @ (IpClass::Private | IpClass::SharedAddressSpace)) if !embeds_ipv4 => {
class.describe()
}
HostClass::LocalName => "private-network name",
HostClass::Ip(class) => {
return Err(ForeignFetchError::Blocked(format!(
"VTA endpoint {origin} is a {} address, which is never accepted",
class.describe()
)));
}
HostClass::Localhost | HostClass::MetadataName => {
return Err(ForeignFetchError::Blocked(format!(
"VTA endpoint {origin} is a reserved host name, which is never accepted"
)));
}
};
if policy.allow_private {
Ok(parsed)
} else {
Err(ForeignFetchError::Blocked(format!(
"VTA endpoint {origin} is a {private_reason} host; endpoints advertised in a \
DID document must be public by default. If this VTA is meant to be reached \
over a private network, set {ALLOW_PRIVATE_ENDPOINTS_ENV}=1 or pass \
--allow-private-endpoints"
)))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn env_secs_uses_default_when_unset_or_junk() {
assert_eq!(
env_secs("VTA_REST_TIMEOUT_SECS_DEFINITELY_UNSET_XYZ", 30),
Duration::from_secs(30)
);
}
#[test]
fn rest_client_builds() {
let _ = rest_client();
}
#[test]
fn guard_allows_public_https() {
guard_public_url("https://example.com/status/list").expect("public https ok");
}
#[test]
fn guard_blocks_plain_http() {
guard_public_url("http://example.com/status").expect_err("http blocked");
}
#[test]
fn guard_blocks_loopback() {
guard_public_url("https://127.0.0.1/x").expect_err("loopback blocked");
guard_public_url("https://127.1/x").expect_err("loopback short form blocked");
}
#[test]
fn guard_blocks_private_v4() {
guard_public_url("https://10.0.0.1/x").expect_err("10/8 blocked");
guard_public_url("https://192.168.1.5/x").expect_err("192.168 blocked");
guard_public_url("https://172.16.0.1/x").expect_err("172.16 blocked");
}
#[test]
fn guard_blocks_cloud_metadata() {
guard_public_url("https://169.254.169.254/latest/meta-data/")
.expect_err("link-local metadata blocked");
}
#[test]
fn guard_blocks_v6_internal() {
guard_public_url("https://[::1]/x").expect_err("v6 loopback blocked");
guard_public_url("https://[fc00::1]/x").expect_err("v6 ULA blocked");
guard_public_url("https://[fe80::1]/x").expect_err("v6 link-local blocked");
}
#[test]
fn guard_blocks_userinfo() {
guard_public_url("https://user:pass@example.com/x").expect_err("userinfo blocked");
}
#[test]
fn guard_blocks_garbage() {
guard_public_url("not a url").expect_err("garbage blocked");
}
const IP_BLOCK: &[&str] = &[
"127.0.0.1",
"127.255.255.254",
"0.0.0.0",
"0.1.2.3",
"10.0.0.1",
"172.16.0.1",
"172.31.255.255",
"192.168.0.1",
"169.254.169.254",
"169.254.170.2",
"100.64.0.1",
"100.100.100.200",
"100.127.255.254",
"192.0.0.1",
"198.18.0.1",
"198.19.255.255",
"192.0.2.1",
"198.51.100.1",
"203.0.113.1",
"224.0.0.1",
"239.255.255.250",
"240.0.0.1",
"255.255.255.255",
"::1",
"::",
"::ffff:127.0.0.1",
"::ffff:7f00:1",
"::ffff:169.254.169.254",
"::127.0.0.1",
"64:ff9b::7f00:1",
"64:ff9b::a9fe:a9fe",
"64:ff9b:1::a00:1",
"2002:7f00:1::1",
"2001:0:4136:e378::1",
"fc00::1",
"fd00::1",
"fd00:ec2::254",
"fe80::1",
"febf::1",
"fec0::1",
"ff02::1",
"2001:db8::1",
"100::1",
];
const IP_ALLOW: &[&str] = &[
"8.8.8.8",
"1.1.1.1",
"11.0.0.1",
"100.63.255.255",
"100.128.0.0",
"172.15.255.255",
"172.32.0.1",
"169.253.255.255",
"192.169.0.1",
"198.20.0.1",
"2606:4700:4700::1111",
"2001:4860:4860::8888",
"64:ff9b::808:808",
];
#[test]
fn classifier_blocks_every_non_public_vector() {
for v in IP_BLOCK {
let ip: IpAddr = v.parse().unwrap();
assert!(
!classify_ip(ip).is_public(),
"{v} must not classify as public"
);
}
}
#[test]
fn classifier_allows_every_public_vector() {
for v in IP_ALLOW {
let ip: IpAddr = v.parse().unwrap();
assert_eq!(classify_ip(ip), IpClass::Public, "{v} must be public");
}
}
#[test]
fn classifier_names_the_class() {
let c = |s: &str| classify_ip(s.parse().unwrap());
assert_eq!(c("169.254.169.254"), IpClass::LinkLocal);
assert_eq!(c("100.100.100.200"), IpClass::Metadata);
assert_eq!(c("fd00:ec2::254"), IpClass::Metadata);
assert_eq!(c("::ffff:10.0.0.1"), IpClass::Private);
assert_eq!(c("64:ff9b::a9fe:a9fe"), IpClass::LinkLocal);
assert_eq!(c("100.64.0.1"), IpClass::SharedAddressSpace);
assert_eq!(c("fd00::1"), IpClass::Private);
assert_eq!(c("198.18.0.1"), IpClass::Benchmarking);
}
#[test]
fn guard_public_url_blocks_url_vectors() {
for u in [
"https://2130706433/",
"https://0x7f000001/",
"https://017700000001/",
"https://0177.0.0.1/",
"https://0x7f.0.0.1/",
"https://127.1/",
"https://127.0.1/",
"https://0/",
"https://169.254.169.254./",
"https://%31%32%37.0.0.1/",
"https://①②⑦.0.0.1/",
"https://127。0。0。1/",
"https://[::ffff:127.0.0.1]/",
"https://[0:0:0:0:0:ffff:7f00:1]/",
"https://[::1]:8443/",
"https://example.com@127.0.0.1/",
"https://127.0.0.1\\@example.com/",
"https://user:pass@example.com/",
"https://100.100.100.200/",
"https://0x100000000/",
"https://1.2.3.4.5/",
"https://[fe80::1%25en0]/",
] {
assert!(guard_public_url(u).is_err(), "{u} must be refused");
}
}
#[test]
fn guard_public_url_blocks_local_names() {
for u in [
"https://localhost/",
"https://LOCALHOST./",
"https://svc.localhost/",
"https://printer.local/",
"https://kube-dns.kube-system.svc.cluster.local/",
"https://metadata.google.internal/",
"https://router.home.arpa/",
"https://metadata/",
] {
assert!(guard_public_url(u).is_err(), "{u} must be refused");
}
for u in [
"https://example.com/",
"https://example.com./",
"https://localhost.example.com/",
] {
assert!(guard_public_url(u).is_ok(), "{u} must be allowed");
}
}
#[test]
fn guard_public_url_blocks_non_https_schemes() {
for u in [
"http://example.com/",
"ws://example.com/",
"ftp://example.com/",
"file:///etc/passwd",
"gopher://example.com/",
"data:text/plain,x",
"javascript:alert(1)",
"blob:https://x/y",
] {
assert!(guard_public_url(u).is_err(), "{u} must be refused");
}
}
fn socks(ips: &[&str]) -> Vec<SocketAddr> {
ips.iter()
.map(|s| SocketAddr::new(s.parse().unwrap(), 0))
.collect()
}
#[test]
fn resolver_vets_every_answer() {
assert!(vet_resolved("a.test", socks(&["93.184.216.34"])).is_ok());
assert!(vet_resolved("b.test", socks(&["127.0.0.1"])).is_err());
assert!(vet_resolved("c.test", socks(&["93.184.216.34", "10.0.0.1"])).is_err());
assert!(vet_resolved("d.test", socks(&["::ffff:169.254.169.254"])).is_err());
assert!(vet_resolved("e.test", socks(&["64:ff9b::7f00:1"])).is_err());
assert!(vet_resolved("f.test", Vec::new()).is_err());
}
#[tokio::test]
async fn resolver_refuses_a_name_resolving_to_loopback() {
use reqwest::dns::Resolve;
let name: reqwest::dns::Name = "localhost".parse().unwrap();
let result = PublicOnlyResolver.resolve(name).await;
assert!(result.is_err(), "localhost resolves to loopback; must fail");
}
const PUBLIC: EndpointPolicy = EndpointPolicy::public_only();
const PRIVATE: EndpointPolicy = EndpointPolicy::private_allowed();
#[test]
fn vta_endpoint_accepts_public_https_and_loopback_http() {
for u in [
"https://vta.example.com",
"https://vta.example.com:8443/api",
"https://8.8.8.8",
"http://localhost:8000/",
"http://localhost:3000/tenant/vta",
"http://127.0.0.1:9099/",
"http://127.0.0.1:8100",
"http://[::1]:7037/",
"https://localhost:8443",
] {
assert!(
guard_vta_endpoint(u, PUBLIC).is_ok(),
"{u} must be accepted"
);
}
}
#[test]
fn vta_endpoint_refuses_always_blocked_targets_even_with_opt_in() {
for u in [
"http://169.254.169.254/latest/meta-data/",
"https://169.254.169.254/",
"https://[fe80::1]/",
"https://[fd00:ec2::254]/",
"https://100.100.100.200/",
"https://metadata.google.internal/",
"https://0.0.0.0/",
"https://[::]/",
"https://224.0.0.1/",
"https://255.255.255.255/",
"https://192.0.2.1/",
"https://[::ffff:127.0.0.1]/",
"https://[::ffff:10.0.0.5]/",
"https://user:pw@vta.example",
"https://user@vta.example",
"ftp://vta.example/",
"file:///etc/passwd",
"http://10.0.0.5/",
"http://vta.example.com/",
"http://localhost.example.com/",
"not a url",
] {
assert!(
guard_vta_endpoint(u, PRIVATE).is_err(),
"{u} must be refused even with private endpoints allowed"
);
}
}
#[test]
fn vta_endpoint_private_hosts_need_the_opt_in() {
for u in [
"https://10.0.0.5",
"https://192.168.1.10:8100",
"https://[fd00::1]/",
"https://100.64.0.1/",
"https://vta.internal/",
"https://vta.corp.local/",
"https://vta:8100/",
] {
let err = guard_vta_endpoint(u, PUBLIC).expect_err(u).to_string();
assert!(
err.contains(ALLOW_PRIVATE_ENDPOINTS_ENV)
&& err.contains("--allow-private-endpoints"),
"{u}: the refusal must name the opt-in, got: {err}"
);
assert!(
guard_vta_endpoint(u, PRIVATE).is_ok(),
"{u} must be accepted with the opt-in"
);
}
}
#[test]
fn vta_endpoint_refusal_never_echoes_credentials() {
let err = guard_vta_endpoint("https://user:s3cret@10.0.0.5/", PRIVATE)
.unwrap_err()
.to_string();
assert!(!err.contains("s3cret"), "{err}");
}
#[tokio::test]
async fn rest_client_stops_at_a_cross_origin_redirect() {
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let target = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(200))
.expect(0)
.mount(&target)
.await;
let origin = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/start"))
.respond_with(
ResponseTemplate::new(302).insert_header("location", format!("{}/x", target.uri())),
)
.mount(&origin)
.await;
let resp = rest_client()
.get(format!("{}/start", origin.uri()))
.send()
.await
.expect("send");
assert_eq!(
resp.status().as_u16(),
302,
"cross-origin redirect must not be followed"
);
}
#[tokio::test]
async fn rest_client_follows_a_same_origin_redirect() {
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/old"))
.respond_with(ResponseTemplate::new(308).insert_header("location", "/new"))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/new"))
.respond_with(ResponseTemplate::new(200).set_body_string("moved"))
.mount(&server)
.await;
let resp = rest_client()
.get(format!("{}/old", server.uri()))
.send()
.await
.expect("send");
assert_eq!(resp.status().as_u16(), 200);
assert_eq!(resp.text().await.unwrap(), "moved");
}
#[test]
fn same_origin_compares_scheme_host_and_effective_port() {
let u = |s: &str| reqwest::Url::parse(s).unwrap();
assert!(same_origin(
&u("https://a.example/x"),
&u("https://a.example:443/y")
));
assert!(!same_origin(
&u("https://a.example/"),
&u("http://a.example/")
));
assert!(!same_origin(
&u("https://a.example/"),
&u("https://b.example/")
));
assert!(!same_origin(
&u("https://a.example/"),
&u("https://a.example:8443/")
));
}
}