Enforce outbound policy at each HTTP connection
This commit is contained in:
parent
be651d559c
commit
12c59ee70f
5 changed files with 1397 additions and 4 deletions
35
crates/remora-outbound/src/lib.rs
Normal file
35
crates/remora-outbound/src/lib.rs
Normal 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,
|
||||
}
|
||||
185
crates/remora-outbound/src/policy.rs
Normal file
185
crates/remora-outbound/src/policy.rs
Normal 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)
|
||||
}
|
||||
25
crates/remora-outbound/src/resolver.rs
Normal file
25
crates/remora-outbound/src/resolver.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
182
crates/remora-outbound/src/transport.rs
Normal file
182
crates/remora-outbound/src/transport.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
156
crates/remora-outbound/src/url.rs
Normal file
156
crates/remora-outbound/src/url.rs
Normal 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(())
|
||||
}
|
||||
Loading…
Reference in a new issue