Enforce outbound policy at each HTTP connection

This commit is contained in:
ms2k 2026-07-17 03:22:06 +09:00
commit 12c59ee70f
8 changed files with 1397 additions and 4 deletions

View file

@ -0,0 +1,22 @@
[package]
name = "remora-outbound"
edition.workspace = true
publish = false
rust-version.workspace = true
version.workspace = true
[dependencies]
http = "1.4.0"
ipnet = "2.12.0"
reqwest = { version = "0.12.28", default-features = false, features = [
"rustls-tls-webpki-roots",
] }
thiserror.workspace = true
tokio.workspace = true
url = "2.5.8"
[dev-dependencies]
rcgen = { version = "0.14.8", default-features = false, features = ["ring"] }
rustls = { version = "0.23.42", default-features = false, features = ["ring", "std", "tls12"] }
tokio = { workspace = true, features = ["macros", "net", "rt-multi-thread", "sync", "time"] }
tokio-rustls = { version = "0.26.4", default-features = false, features = ["ring", "tls12"] }

View file

@ -0,0 +1,35 @@
mod policy;
mod resolver;
mod transport;
mod url;
pub use policy::{AllowedEndpoint, OutboundPolicy, OutboundPolicyBuilder};
pub use resolver::{Resolver, SystemResolver};
pub use transport::{OutboundClient, OutboundClientBuilder, OutboundRequest, OutboundResponse};
pub use url::{CanonicalUrl, Origin};
use thiserror::Error;
#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
pub enum OutboundError {
#[error("outbound URL is invalid")]
InvalidUrl,
#[error("outbound scheme and port are denied")]
SchemePortDenied,
#[error("outbound name resolution failed")]
ResolutionFailed,
#[error("outbound name resolution returned no addresses")]
EmptyResolution,
#[error("outbound name resolution contains a denied address")]
AddressDenied,
#[error("outbound request headers are invalid")]
InvalidHeaders,
#[error("outbound redirect is invalid")]
InvalidRedirect,
#[error("outbound redirect limit exceeded")]
RedirectLimit,
#[error("outbound HTTPS downgrade is denied")]
HttpsDowngrade,
#[error("outbound transport failed")]
Transport,
}

View file

@ -0,0 +1,185 @@
use std::{
collections::HashSet,
net::{IpAddr, Ipv4Addr, Ipv6Addr},
};
use ipnet::IpNet;
use crate::{CanonicalUrl, OutboundError, Resolver};
const METADATA_V4: &str = "169.254.169.254";
const METADATA_V6: &str = "fd00:ec2::254";
#[derive(Clone, Debug)]
pub struct OutboundPolicy {
allowed_scheme_ports: HashSet<(String, u16)>,
allowed_non_global: Vec<IpNet>,
max_redirects: usize,
}
#[derive(Clone, Debug)]
pub struct OutboundPolicyBuilder {
policy: OutboundPolicy,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AllowedEndpoint {
address: IpAddr,
port: u16,
}
impl OutboundPolicy {
#[must_use]
pub fn https_only() -> Self {
OutboundPolicyBuilder::new().build()
}
pub async fn authorize<R: Resolver>(
&self,
url: &CanonicalUrl,
resolver: &R,
) -> Result<AllowedEndpoint, OutboundError> {
let port = url.port().ok_or(OutboundError::SchemePortDenied)?;
if !self
.allowed_scheme_ports
.iter()
.any(|(scheme, allowed_port)| scheme == url.scheme() && *allowed_port == port)
{
return Err(OutboundError::SchemePortDenied);
}
let addresses = if let Some(address) = url.literal_address() {
vec![address]
} else {
resolver
.resolve(url.host_str(), port)
.await
.map_err(|_| OutboundError::ResolutionFailed)?
};
let address = *addresses.first().ok_or(OutboundError::EmptyResolution)?;
if addresses
.iter()
.any(|address| !self.address_allowed(*address))
{
return Err(OutboundError::AddressDenied);
}
Ok(AllowedEndpoint { address, port })
}
#[must_use]
pub fn max_redirects(&self) -> usize {
self.max_redirects
}
fn address_allowed(&self, address: IpAddr) -> bool {
let effective = match address {
IpAddr::V6(address) => address.to_ipv4_mapped().map_or(address.into(), IpAddr::V4),
address => address,
};
if effective.to_string() == METADATA_V4 || effective.to_string() == METADATA_V6 {
return false;
}
if self
.allowed_non_global
.iter()
.any(|network| network.contains(&effective))
{
return true;
}
is_global_unicast(effective)
}
}
impl Default for OutboundPolicy {
fn default() -> Self {
Self::https_only()
}
}
impl OutboundPolicyBuilder {
#[must_use]
pub fn new() -> Self {
Self {
policy: OutboundPolicy {
allowed_scheme_ports: HashSet::from([("https".to_owned(), 443)]),
allowed_non_global: Vec::new(),
max_redirects: 5,
},
}
}
#[must_use]
pub fn allow_scheme_port(mut self, scheme: impl Into<String>, port: u16) -> Self {
self.policy
.allowed_scheme_ports
.insert((scheme.into().to_ascii_lowercase(), port));
self
}
#[must_use]
pub fn allow_non_global(mut self, network: IpNet) -> Self {
self.policy.allowed_non_global.push(network);
self
}
#[must_use]
pub fn max_redirects(mut self, max_redirects: usize) -> Self {
self.policy.max_redirects = max_redirects;
self
}
#[must_use]
pub fn build(self) -> OutboundPolicy {
self.policy
}
}
impl Default for OutboundPolicyBuilder {
fn default() -> Self {
Self::new()
}
}
impl AllowedEndpoint {
#[must_use]
pub fn address(&self) -> IpAddr {
self.address
}
#[must_use]
pub fn port(&self) -> u16 {
self.port
}
}
fn is_global_unicast(address: IpAddr) -> bool {
match address {
IpAddr::V4(address) => is_global_ipv4(address),
IpAddr::V6(address) => is_global_ipv6(address),
}
}
fn is_global_ipv4(address: Ipv4Addr) -> bool {
if address.is_unspecified()
|| address.is_loopback()
|| address.is_private()
|| address.is_link_local()
|| address.is_documentation()
|| address.is_broadcast()
|| address.is_multicast()
{
return false;
}
let [first, second, third, _] = address.octets();
!((first == 100 && (64..=127).contains(&second))
|| (first == 192 && second == 0 && third == 0)
|| (first == 192 && second == 88 && third == 99)
|| (first == 198 && (second == 18 || second == 19))
|| first >= 240)
}
fn is_global_ipv6(address: Ipv6Addr) -> bool {
let segments = address.segments();
segments[0] & 0xe000 == 0x2000
&& !((segments[0] == 0x2001 && (segments[1] <= 0x01ff || segments[1] == 0x0db8))
|| segments[0] & 0xfff0 == 0x3ff0)
}

View file

@ -0,0 +1,25 @@
use std::{future::Future, io, net::IpAddr};
pub trait Resolver: Send + Sync {
fn resolve(
&self,
host: &str,
port: u16,
) -> impl Future<Output = io::Result<Vec<IpAddr>>> + Send;
}
#[derive(Clone, Copy, Debug, Default)]
pub struct SystemResolver;
impl Resolver for SystemResolver {
async fn resolve(&self, host: &str, port: u16) -> io::Result<Vec<IpAddr>> {
let addresses = tokio::net::lookup_host((host, port)).await?;
let mut unique = Vec::new();
for address in addresses {
if !unique.contains(&address.ip()) {
unique.push(address.ip());
}
}
Ok(unique)
}
}

View file

@ -0,0 +1,182 @@
use std::net::SocketAddr;
use http::{HeaderMap, HeaderName, Method, StatusCode};
use reqwest::{Certificate, redirect::Policy};
use crate::{CanonicalUrl, OutboundError, OutboundPolicy, Resolver};
const REDIRECTS: [StatusCode; 5] = [
StatusCode::MOVED_PERMANENTLY,
StatusCode::FOUND,
StatusCode::SEE_OTHER,
StatusCode::TEMPORARY_REDIRECT,
StatusCode::PERMANENT_REDIRECT,
];
#[derive(Clone, Debug)]
pub struct OutboundClient<R> {
resolver: R,
policy: OutboundPolicy,
roots: Vec<Certificate>,
}
#[derive(Clone, Debug)]
pub struct OutboundClientBuilder<R> {
client: OutboundClient<R>,
}
#[derive(Clone, Debug)]
pub struct OutboundRequest {
method: Method,
url: CanonicalUrl,
headers: HeaderMap,
}
#[derive(Debug)]
pub struct OutboundResponse {
final_url: CanonicalUrl,
response: reqwest::Response,
}
impl<R: Resolver> OutboundClient<R> {
#[must_use]
pub fn builder(resolver: R, policy: OutboundPolicy) -> OutboundClientBuilder<R> {
OutboundClientBuilder {
client: Self {
resolver,
policy,
roots: Vec::new(),
},
}
}
pub async fn execute(
&self,
mut request: OutboundRequest,
) -> Result<OutboundResponse, OutboundError> {
let mut redirects = 0;
loop {
let endpoint = self.policy.authorize(&request.url, &self.resolver).await?;
let client = self.client_for(&request.url, &endpoint)?;
let response = client
.request(request.method.clone(), request.url.as_url().clone())
.headers(request.headers.clone())
.send()
.await
.map_err(|_| OutboundError::Transport)?;
if !REDIRECTS.contains(&response.status()) {
return Ok(OutboundResponse {
final_url: request.url,
response,
});
}
if redirects == self.policy.max_redirects() {
return Err(OutboundError::RedirectLimit);
}
let location = response
.headers()
.get(http::header::LOCATION)
.ok_or(OutboundError::InvalidRedirect)?
.to_str()
.map_err(|_| OutboundError::InvalidRedirect)?;
let next = request.url.join(location)?;
if request.url.scheme() == "https" && next.scheme() == "http" {
return Err(OutboundError::HttpsDowngrade);
}
if request.url.origin()? != next.origin()? {
strip_cross_origin_credentials(&mut request.headers);
}
request.url = next;
redirects += 1;
}
}
fn client_for(
&self,
url: &CanonicalUrl,
endpoint: &crate::AllowedEndpoint,
) -> Result<reqwest::Client, OutboundError> {
let mut builder = reqwest::Client::builder()
.redirect(Policy::none())
.no_proxy();
if url.literal_address().is_none() {
builder = builder.resolve(
url.host_str(),
SocketAddr::new(endpoint.address(), endpoint.port()),
);
}
for root in &self.roots {
builder = builder.add_root_certificate(root.clone());
}
builder.build().map_err(|_| OutboundError::Transport)
}
}
impl<R> OutboundClientBuilder<R> {
#[must_use]
pub fn add_root_certificate(mut self, certificate: Certificate) -> Self {
self.client.roots.push(certificate);
self
}
#[must_use]
pub fn build(self) -> OutboundClient<R> {
self.client
}
}
impl OutboundRequest {
pub fn get(url: &str) -> Result<Self, OutboundError> {
Ok(Self {
method: Method::GET,
url: CanonicalUrl::parse(url)?,
headers: HeaderMap::new(),
})
}
pub fn head(url: &str) -> Result<Self, OutboundError> {
Ok(Self {
method: Method::HEAD,
url: CanonicalUrl::parse(url)?,
headers: HeaderMap::new(),
})
}
pub fn with_headers(mut self, headers: HeaderMap) -> Result<Self, OutboundError> {
if headers.contains_key(http::header::HOST)
|| headers.contains_key(http::header::CONNECTION)
|| headers.contains_key(http::header::PROXY_AUTHORIZATION)
{
return Err(OutboundError::InvalidHeaders);
}
self.headers = headers;
Ok(self)
}
}
impl OutboundResponse {
#[must_use]
pub fn final_url(&self) -> &CanonicalUrl {
&self.final_url
}
#[must_use]
pub fn response(&self) -> &reqwest::Response {
&self.response
}
#[must_use]
pub fn into_response(self) -> reqwest::Response {
self.response
}
}
fn strip_cross_origin_credentials(headers: &mut HeaderMap) {
for name in [
http::header::AUTHORIZATION,
http::header::COOKIE,
HeaderName::from_static("cookie2"),
] {
headers.remove(name);
}
}

View file

@ -0,0 +1,156 @@
use std::{fmt, net::IpAddr};
use url::{Host, Url};
use crate::OutboundError;
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub struct CanonicalUrl(Url);
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub struct Origin {
scheme: String,
host: String,
port: u16,
}
impl CanonicalUrl {
pub fn parse(input: &str) -> Result<Self, OutboundError> {
let mut parsed = Url::parse(input).map_err(|_| OutboundError::InvalidUrl)?;
if parsed.cannot_be_a_base()
|| !matches!(parsed.scheme(), "http" | "https")
|| !parsed.username().is_empty()
|| parsed.password().is_some()
|| parsed.fragment().is_some()
{
return Err(OutboundError::InvalidUrl);
}
let without_trailing_dot = {
let host = parsed.host().ok_or(OutboundError::InvalidUrl)?;
validate_numeric_spelling(input, &parsed, &host)?;
validate_dns_name(&host)?;
match host {
Host::Domain(domain) => domain.strip_suffix('.').map(str::to_owned),
_ => None,
}
};
if let Some(without_dot) = without_trailing_dot {
parsed
.set_host(Some(&without_dot))
.map_err(|_| OutboundError::InvalidUrl)?;
}
Ok(Self(parsed))
}
pub fn join(&self, location: &str) -> Result<Self, OutboundError> {
let joined = self
.0
.join(location)
.map_err(|_| OutboundError::InvalidRedirect)?;
Self::parse(joined.as_str()).map_err(|_| OutboundError::InvalidRedirect)
}
#[must_use]
pub fn as_url(&self) -> &Url {
&self.0
}
#[must_use]
pub fn scheme(&self) -> &str {
self.0.scheme()
}
#[must_use]
pub fn host_str(&self) -> &str {
self.0.host_str().expect("canonical URL always has a host")
}
#[must_use]
pub fn literal_address(&self) -> Option<IpAddr> {
match self.0.host()? {
Host::Ipv4(address) => Some(address.into()),
Host::Ipv6(address) => Some(address.into()),
Host::Domain(_) => None,
}
}
#[must_use]
pub fn port(&self) -> Option<u16> {
self.0.port_or_known_default()
}
pub fn origin(&self) -> Result<Origin, OutboundError> {
Ok(Origin {
scheme: self.scheme().to_owned(),
host: self.host_str().to_owned(),
port: self.port().ok_or(OutboundError::InvalidUrl)?,
})
}
}
impl fmt::Display for CanonicalUrl {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(formatter)
}
}
fn validate_numeric_spelling(
input: &str,
parsed: &Url,
host: &Host<&str>,
) -> Result<(), OutboundError> {
let authority = input
.split_once("://")
.map(|(_, tail)| tail.split(['/', '?', '#']).next().unwrap_or_default())
.ok_or(OutboundError::InvalidUrl)?;
if authority.contains('@') {
return Err(OutboundError::InvalidUrl);
}
let raw_host = if let Some(bracketed) = authority.strip_prefix('[') {
bracketed
.split_once(']')
.map(|(host, _)| host)
.ok_or(OutboundError::InvalidUrl)?
} else {
authority
.split_once(':')
.map_or(authority, |(host, _)| host)
};
match host {
Host::Ipv4(address) if raw_host != address.to_string() => Err(OutboundError::InvalidUrl),
Host::Ipv6(address) if raw_host != address.to_string() => Err(OutboundError::InvalidUrl),
Host::Domain(_) if resembles_numeric_host(raw_host) => Err(OutboundError::InvalidUrl),
_ if parsed.host_str().is_none() => Err(OutboundError::InvalidUrl),
_ => Ok(()),
}
}
fn resembles_numeric_host(host: &str) -> bool {
host.bytes()
.all(|byte| byte.is_ascii_digit() || byte == b'.')
|| host
.split('.')
.any(|label| label.starts_with("0x") || label.starts_with("0X"))
}
fn validate_dns_name(host: &Host<&str>) -> Result<(), OutboundError> {
let Host::Domain(domain) = host else {
return Ok(());
};
let domain = domain.strip_suffix('.').unwrap_or(domain);
if domain.is_empty()
|| domain.len() > 253
|| domain.split('.').any(|label| {
label.is_empty()
|| label.len() > 63
|| label.starts_with('-')
|| label.ends_with('-')
|| !label
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
})
{
return Err(OutboundError::InvalidUrl);
}
Ok(())
}

View file

@ -0,0 +1,205 @@
use std::{
collections::HashMap,
io,
net::IpAddr,
sync::{Arc, Mutex},
};
use ipnet::IpNet;
use remora_outbound::{
CanonicalUrl, OutboundError, OutboundPolicy, OutboundPolicyBuilder, Resolver,
};
#[derive(Clone, Default)]
struct ScriptedResolver {
state: Arc<Mutex<ResolverState>>,
}
#[derive(Default)]
struct ResolverState {
answers: HashMap<String, Vec<Vec<IpAddr>>>,
calls: HashMap<String, usize>,
}
impl ScriptedResolver {
fn with(host: &str, answers: Vec<Vec<IpAddr>>) -> Self {
let resolver = Self::default();
resolver
.state
.lock()
.expect("resolver state")
.answers
.insert(host.to_owned(), answers);
resolver
}
fn calls(&self, host: &str) -> usize {
self.state
.lock()
.expect("resolver state")
.calls
.get(host)
.copied()
.unwrap_or_default()
}
}
impl Resolver for ScriptedResolver {
async fn resolve(&self, host: &str, _port: u16) -> io::Result<Vec<IpAddr>> {
let mut state = self.state.lock().expect("resolver state");
let call = state.calls.entry(host.to_owned()).or_default();
let index = *call;
*call += 1;
let sequence = state.answers.get(host).cloned().unwrap_or_default();
Ok(sequence
.get(index)
.or_else(|| sequence.last())
.cloned()
.unwrap_or_default())
}
}
fn ip(input: &str) -> IpAddr {
input.parse().expect("valid test address")
}
async fn authorize_literal(policy: &OutboundPolicy, address: &str) -> Result<(), OutboundError> {
let brackets = if address.contains(':') {
("[", "]")
} else {
("", "")
};
let url = CanonicalUrl::parse(&format!("https://{}{address}{}/", brackets.0, brackets.1))?;
policy
.authorize(&url, &ScriptedResolver::default())
.await
.map(|_| ())
}
#[test]
fn canonical_url_rejects_ambiguous_or_sensitive_forms() {
let canonical = CanonicalUrl::parse("https://EXAMPLE.com.:443/a?q=1").expect("canonical URL");
assert_eq!(canonical.to_string(), "https://example.com/a?q=1");
for invalid in [
"ftp://example.com/object",
"https://user:[email protected]/object",
"https://example.com/object#fragment",
"https://%31%32%37.0.0.1/object",
"https://2130706433/object",
"https://0x7f000001/object",
"https://0177.0.0.1/object",
"https://127.1/object",
"https://[2001:0DB8::1]/object",
] {
assert_eq!(CanonicalUrl::parse(invalid), Err(OutboundError::InvalidUrl));
}
}
#[tokio::test]
async fn complete_answer_must_be_nonempty_and_fully_allowed() {
let policy = OutboundPolicy::https_only();
let mixed = ScriptedResolver::with(
"mixed.test",
vec![vec![ip("93.184.216.34"), ip("10.0.0.1")]],
);
let url = CanonicalUrl::parse("https://mixed.test/object").expect("URL");
assert_eq!(
policy.authorize(&url, &mixed).await,
Err(OutboundError::AddressDenied)
);
let empty = ScriptedResolver::with("empty.test", vec![vec![]]);
let url = CanonicalUrl::parse("https://empty.test/object").expect("URL");
assert_eq!(
policy.authorize(&url, &empty).await,
Err(OutboundError::EmptyResolution)
);
}
#[tokio::test]
async fn default_policy_denies_special_ipv4_and_ipv6_classes() {
let policy = OutboundPolicy::https_only();
for address in [
"0.0.0.0",
"10.0.0.1",
"100.64.0.1",
"127.0.0.1",
"169.254.1.1",
"172.16.0.1",
"192.168.0.1",
"192.0.2.1",
"198.18.0.1",
"224.0.0.1",
"240.0.0.1",
"::",
"::1",
"fc00::1",
"fe80::1",
"ff00::1",
"2001:db8::1",
"::ffff:127.0.0.1",
] {
assert_eq!(
authorize_literal(&policy, address).await,
Err(OutboundError::AddressDenied),
"{address}"
);
}
for address in ["93.184.216.34", "2606:4700:4700::1111"] {
authorize_literal(&policy, address)
.await
.expect("global address");
}
}
#[tokio::test]
async fn allowlists_never_override_metadata_denial() {
let policy = OutboundPolicyBuilder::new()
.allow_non_global("10.0.0.0/8".parse::<IpNet>().expect("network"))
.allow_non_global("169.254.0.0/16".parse::<IpNet>().expect("network"))
.allow_non_global("fd00::/8".parse::<IpNet>().expect("network"))
.build();
authorize_literal(&policy, "10.1.2.3")
.await
.expect("allowlisted private address");
for address in ["169.254.169.254", "fd00:ec2::254", "::ffff:169.254.169.254"] {
assert_eq!(
authorize_literal(&policy, address).await,
Err(OutboundError::AddressDenied)
);
}
}
#[tokio::test]
async fn scheme_port_policy_is_exact() {
let resolver = ScriptedResolver::with("example.test", vec![vec![ip("93.184.216.34")]]);
let policy = OutboundPolicy::https_only();
for denied in ["http://example.test/", "https://example.test:444/"] {
assert_eq!(
policy
.authorize(&CanonicalUrl::parse(denied).expect("URL"), &resolver)
.await,
Err(OutboundError::SchemePortDenied)
);
}
}
#[tokio::test]
async fn each_authorization_performs_one_fresh_resolution() {
let resolver = ScriptedResolver::with(
"rebind.test",
vec![vec![ip("93.184.216.34")], vec![ip("10.0.0.1")]],
);
let policy = OutboundPolicy::https_only();
let url = CanonicalUrl::parse("https://rebind.test/").expect("URL");
policy
.authorize(&url, &resolver)
.await
.expect("first authorization");
assert_eq!(
policy.authorize(&url, &resolver).await,
Err(OutboundError::AddressDenied)
);
assert_eq!(resolver.calls("rebind.test"), 2);
}

View file

@ -0,0 +1,430 @@
use std::{
collections::HashMap,
io,
net::IpAddr,
sync::{Arc, Mutex},
};
use http::{HeaderMap, HeaderValue};
use ipnet::IpNet;
use remora_outbound::{
OutboundClient, OutboundError, OutboundPolicyBuilder, OutboundRequest, Resolver,
};
use tokio::{
io::{AsyncRead, AsyncReadExt, AsyncWriteExt},
net::TcpListener,
task::JoinHandle,
};
#[derive(Clone)]
struct ScriptedResolver {
state: Arc<Mutex<ResolverState>>,
}
struct ResolverState {
answers: HashMap<String, Vec<Vec<IpAddr>>>,
calls: HashMap<String, usize>,
}
#[derive(Debug)]
struct ObservedRequest {
path: String,
host: Option<String>,
authorization: Option<String>,
cookie: Option<String>,
}
impl ScriptedResolver {
fn new(answers: impl IntoIterator<Item = (&'static str, Vec<Vec<IpAddr>>)>) -> Self {
Self {
state: Arc::new(Mutex::new(ResolverState {
answers: answers
.into_iter()
.map(|(host, answers)| (host.to_owned(), answers))
.collect(),
calls: HashMap::new(),
})),
}
}
fn calls(&self, host: &str) -> usize {
self.state
.lock()
.expect("resolver state")
.calls
.get(host)
.copied()
.unwrap_or_default()
}
}
impl Resolver for ScriptedResolver {
async fn resolve(&self, host: &str, _port: u16) -> io::Result<Vec<IpAddr>> {
let mut state = self.state.lock().expect("resolver state");
let call = state.calls.entry(host.to_owned()).or_default();
let index = *call;
*call += 1;
let sequence = state.answers.get(host).cloned().unwrap_or_default();
Ok(sequence
.get(index)
.or_else(|| sequence.last())
.cloned()
.unwrap_or_default())
}
}
fn ip(input: &str) -> IpAddr {
input.parse().expect("valid test address")
}
fn local_policy(port: u16, max_redirects: usize) -> remora_outbound::OutboundPolicy {
OutboundPolicyBuilder::new()
.allow_scheme_port("http", port)
.allow_non_global("127.0.0.0/8".parse::<IpNet>().expect("network"))
.max_redirects(max_redirects)
.build()
}
async fn serve_http(
connections: usize,
handler: impl Fn(&ObservedRequest) -> String + Send + Sync + 'static,
) -> (u16, Arc<Mutex<Vec<ObservedRequest>>>, JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
let port = listener.local_addr().expect("address").port();
let observed = Arc::new(Mutex::new(Vec::new()));
let task_observed = Arc::clone(&observed);
let handler = Arc::new(handler);
let task = tokio::spawn(async move {
for _ in 0..connections {
let (mut stream, _) = listener.accept().await.expect("accept");
let request = read_request(&mut stream).await;
let response = handler(&request);
task_observed.lock().expect("observations").push(request);
stream
.write_all(response.as_bytes())
.await
.expect("response");
}
});
(port, observed, task)
}
async fn read_request(stream: &mut (impl AsyncRead + Unpin)) -> ObservedRequest {
let mut bytes = Vec::new();
let mut chunk = [0_u8; 1024];
while !bytes.windows(4).any(|window| window == b"\r\n\r\n") {
let read = stream.read(&mut chunk).await.expect("request");
assert_ne!(read, 0, "request ended before headers");
bytes.extend_from_slice(&chunk[..read]);
}
let request = String::from_utf8(bytes).expect("ASCII request");
let mut lines = request.split("\r\n");
let path = lines
.next()
.and_then(|line| line.split_whitespace().nth(1))
.expect("request path")
.to_owned();
let headers: HashMap<String, String> = lines
.take_while(|line| !line.is_empty())
.filter_map(|line| line.split_once(':'))
.map(|(name, value)| (name.to_ascii_lowercase(), value.trim().to_owned()))
.collect();
ObservedRequest {
path,
host: headers.get("host").cloned(),
authorization: headers.get("authorization").cloned(),
cookie: headers.get("cookie").cloned(),
}
}
fn response(status: &str, headers: &[(&str, &str)], body: &str) -> String {
let fields = headers
.iter()
.map(|(name, value)| format!("{name}: {value}\r\n"))
.collect::<String>();
format!(
"HTTP/1.1 {status}\r\n{fields}Content-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
)
}
#[tokio::test]
async fn pins_address_and_preserves_http_authority() {
let (port, observed, server) = serve_http(1, |_| response("200 OK", &[], "ok")).await;
let resolver = ScriptedResolver::new([("source.test", vec![vec![ip("127.0.0.1")]])]);
let client = OutboundClient::builder(resolver.clone(), local_policy(port, 5)).build();
let result = client
.execute(
OutboundRequest::get(&format!("http://source.test:{port}/object")).expect("request"),
)
.await
.expect("response");
assert_eq!(result.response().status(), 200);
server.await.expect("server");
let observed = observed.lock().expect("observations");
assert_eq!(
observed[0].host.as_deref(),
Some(format!("source.test:{port}").as_str())
);
assert_eq!(observed[0].path, "/object");
assert_eq!(resolver.calls("source.test"), 1);
}
#[tokio::test]
async fn redirects_reauthorize_and_strip_cross_origin_credentials() {
let (port, observed, server) = serve_http(2, |request| {
if request.path == "/redirect" {
let port = request
.host
.as_deref()
.and_then(|host| host.rsplit_once(':'))
.map(|(_, port)| port)
.expect("authority port");
response(
"302 Found",
&[("Location", &format!("http://other.test:{port}/ok"))],
"",
)
} else {
response("200 OK", &[], "ok")
}
})
.await;
let resolver = ScriptedResolver::new([
("source.test", vec![vec![ip("127.0.0.1")]]),
("other.test", vec![vec![ip("127.0.0.1")]]),
]);
let client = OutboundClient::builder(resolver.clone(), local_policy(port, 5)).build();
let mut headers = HeaderMap::new();
headers.insert(
"authorization",
HeaderValue::from_static("Bearer synthetic"),
);
headers.insert("cookie", HeaderValue::from_static("session=synthetic"));
let request = OutboundRequest::get(&format!("http://source.test:{port}/redirect"))
.expect("request")
.with_headers(headers)
.expect("headers");
let result = client.execute(request).await.expect("response");
assert_eq!(result.final_url().host_str(), "other.test");
server.await.expect("server");
let observed = observed.lock().expect("observations");
assert_eq!(
observed[0].authorization.as_deref(),
Some("Bearer synthetic")
);
assert_eq!(observed[0].cookie.as_deref(), Some("session=synthetic"));
assert_eq!(observed[1].authorization, None);
assert_eq!(observed[1].cookie, None);
assert_eq!(resolver.calls("source.test"), 1);
assert_eq!(resolver.calls("other.test"), 1);
}
#[tokio::test]
async fn denied_redirect_is_rejected_before_dial() {
let (port, _, server) = serve_http(1, |request| {
let port = request
.host
.as_deref()
.and_then(|host| host.rsplit_once(':'))
.map(|(_, port)| port)
.expect("authority port");
response(
"302 Found",
&[(
"Location",
&format!("http://metadata.test:{port}/latest/meta-data"),
)],
"",
)
})
.await;
let resolver = ScriptedResolver::new([
("source.test", vec![vec![ip("127.0.0.1")]]),
("metadata.test", vec![vec![ip("169.254.169.254")]]),
]);
let client = OutboundClient::builder(resolver, local_policy(port, 5)).build();
let result = client
.execute(OutboundRequest::get(&format!("http://source.test:{port}/")).expect("request"))
.await;
assert_eq!(
result.expect_err("denied redirect"),
OutboundError::AddressDenied
);
server.await.expect("server");
}
#[tokio::test]
async fn redirect_count_is_bounded() {
let (port, _, server) =
serve_http(3, |_| response("302 Found", &[("Location", "/loop")], "")).await;
let resolver = ScriptedResolver::new([("source.test", vec![vec![ip("127.0.0.1")]])]);
let client = OutboundClient::builder(resolver.clone(), local_policy(port, 2)).build();
let result = client
.execute(OutboundRequest::get(&format!("http://source.test:{port}/loop")).expect("request"))
.await;
assert_eq!(
result.expect_err("redirect bound"),
OutboundError::RedirectLimit
);
server.await.expect("server");
assert_eq!(resolver.calls("source.test"), 3);
}
#[tokio::test]
async fn a_new_request_cannot_reuse_a_pool_after_dns_changes() {
let (port, _, server) = serve_http(1, |_| response("200 OK", &[], "ok")).await;
let resolver = ScriptedResolver::new([(
"rebind.test",
vec![vec![ip("127.0.0.1")], vec![ip("169.254.169.254")]],
)]);
let client = OutboundClient::builder(resolver.clone(), local_policy(port, 5)).build();
let url = format!("http://rebind.test:{port}/ok");
client
.execute(OutboundRequest::get(&url).expect("first request"))
.await
.expect("first response");
assert_eq!(
client
.execute(OutboundRequest::get(&url).expect("second request"))
.await
.expect_err("rebound address"),
OutboundError::AddressDenied
);
server.await.expect("server");
assert_eq!(resolver.calls("rebind.test"), 2);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn ignores_ambient_proxy_configuration() {
let (port, _, server) = serve_http(1, |_| response("200 OK", &[], "ok")).await;
let status = std::process::Command::new(std::env::current_exe().expect("test executable"))
.args(["--exact", "ambient_proxy_child", "--nocapture"])
.env("REMORA_PROXY_CHILD_PORT", port.to_string())
.env("HTTP_PROXY", "http://169.254.169.254:8080")
.status()
.expect("proxy child");
assert!(status.success());
server.await.expect("server");
}
#[test]
fn ambient_proxy_child() {
let Ok(port) = std::env::var("REMORA_PROXY_CHILD_PORT") else {
return;
};
let port = port.parse::<u16>().expect("child port");
tokio::runtime::Runtime::new()
.expect("runtime")
.block_on(async move {
let resolver = ScriptedResolver::new([("source.test", vec![vec![ip("127.0.0.1")]])]);
let client = OutboundClient::builder(resolver, local_policy(port, 5)).build();
client
.execute(
OutboundRequest::get(&format!("http://source.test:{port}/")).expect("request"),
)
.await
.expect("direct response");
});
}
#[tokio::test]
async fn tls_verification_uses_canonical_hostname_not_pinned_address() {
let certified =
rcgen::generate_simple_self_signed(vec!["source.test".to_owned()]).expect("certificate");
let certificate_der = certified.cert.der().clone();
let key_der = certified.signing_key.serialize_der();
let tls = rustls::ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(
vec![certificate_der.clone()],
rustls::pki_types::PrivateKeyDer::Pkcs8(key_der.into()),
)
.expect("TLS config");
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
let port = listener.local_addr().expect("address").port();
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.expect("accept");
let mut stream = tokio_rustls::TlsAcceptor::from(Arc::new(tls))
.accept(stream)
.await
.expect("TLS handshake");
let request = read_request(&mut stream).await;
assert_eq!(
request.host.as_deref(),
Some(format!("source.test:{port}").as_str())
);
stream
.write_all(response("200 OK", &[], "ok").as_bytes())
.await
.expect("response");
});
let policy = OutboundPolicyBuilder::new()
.allow_scheme_port("https", port)
.allow_non_global("127.0.0.0/8".parse::<IpNet>().expect("network"))
.build();
let resolver = ScriptedResolver::new([("source.test", vec![vec![ip("127.0.0.1")]])]);
let root = reqwest::Certificate::from_der(certificate_der.as_ref()).expect("root");
let client = OutboundClient::builder(resolver, policy)
.add_root_certificate(root)
.build();
client
.execute(OutboundRequest::get(&format!("https://source.test:{port}/")).expect("request"))
.await
.expect("TLS response");
server.await.expect("server");
}
#[tokio::test]
async fn https_redirect_cannot_downgrade_to_http() {
let certified =
rcgen::generate_simple_self_signed(vec!["source.test".to_owned()]).expect("certificate");
let certificate_der = certified.cert.der().clone();
let key_der = certified.signing_key.serialize_der();
let tls = rustls::ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(
vec![certificate_der.clone()],
rustls::pki_types::PrivateKeyDer::Pkcs8(key_der.into()),
)
.expect("TLS config");
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
let port = listener.local_addr().expect("address").port();
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.expect("accept");
let mut stream = tokio_rustls::TlsAcceptor::from(Arc::new(tls))
.accept(stream)
.await
.expect("TLS handshake");
read_request(&mut stream).await;
stream
.write_all(
response(
"302 Found",
&[("Location", "http://source.test/object")],
"",
)
.as_bytes(),
)
.await
.expect("response");
});
let policy = OutboundPolicyBuilder::new()
.allow_scheme_port("https", port)
.allow_scheme_port("http", 80)
.allow_non_global("127.0.0.0/8".parse::<IpNet>().expect("network"))
.build();
let resolver = ScriptedResolver::new([("source.test", vec![vec![ip("127.0.0.1")]])]);
let root = reqwest::Certificate::from_der(certificate_der.as_ref()).expect("root");
let client = OutboundClient::builder(resolver, policy)
.add_root_certificate(root)
.build();
let result = client
.execute(OutboundRequest::get(&format!("https://source.test:{port}/")).expect("request"))
.await;
assert_eq!(
result.expect_err("HTTPS downgrade"),
OutboundError::HttpsDowngrade
);
server.await.expect("server");
}