Enforce outbound policy at each HTTP connection
This commit is contained in:
parent
be651d559c
commit
12c59ee70f
1 changed files with 1397 additions and 4 deletions
205
crates/remora-outbound/tests/policy.rs
Normal file
205
crates/remora-outbound/tests/policy.rs
Normal 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);
|
||||
}
|
||||
Loading…
Reference in a new issue