mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-08-24 12:24:01 +02:00
272 lines
10 KiB
Rust
272 lines
10 KiB
Rust
//! Where a send may go.
|
|
//!
|
|
//! A hosted sender is, by construction, a machine that makes HTTP requests on
|
|
//! behalf of strangers. Left alone that is an open relay into whatever network
|
|
//! it sits on: cloud metadata endpoints, internal admin panels, the database
|
|
//! next door. So every destination is checked twice — once on the URL before a
|
|
//! hop is attempted (literal IPs, host allow/deny lists) and once on the
|
|
//! addresses a hostname actually resolves to, right before the connection is
|
|
//! made. The second check is the one that matters for a hostname pointing at
|
|
//! an internal address, and it runs on every redirect hop because the engine
|
|
//! resolves every hop.
|
|
|
|
use async_trait::async_trait;
|
|
use log::warn;
|
|
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
|
use std::sync::Arc;
|
|
use tokio::sync::mpsc;
|
|
use url::Url;
|
|
use yaak_http::dns::AddressFilter;
|
|
use yaak_http::sender::{HttpResponse, HttpResponseEvent, HttpSender};
|
|
use yaak_http::types::SendableHttpRequest;
|
|
|
|
/// The destination policy, shared by every send: public addresses only, unless the operator
|
|
/// has said otherwise. A hosted server's "private network" is the cloud's, not the user's, so
|
|
/// the default is public-only; a self-hosted instance on a LAN can be told that its private
|
|
/// network *is* the user's, which is what `--allow-private-networks` means.
|
|
#[derive(Clone, Default)]
|
|
pub struct DestinationPolicy {
|
|
allow_private: bool,
|
|
}
|
|
|
|
impl DestinationPolicy {
|
|
pub fn new(allow_private: bool) -> Self {
|
|
Self { allow_private }
|
|
}
|
|
|
|
/// Check a URL before a hop is attempted: scheme and literal IPs. A hostname that passes
|
|
/// here still has its resolved addresses checked by [`Self::address_filter`].
|
|
pub fn check_url(&self, raw: &str) -> Result<(), String> {
|
|
let url = Url::parse(raw).map_err(|e| format!("Invalid URL {raw:?}: {e}"))?;
|
|
match url.scheme() {
|
|
"http" | "https" => {}
|
|
other => return Err(format!("Refusing to send over {other:?}; only http and https")),
|
|
}
|
|
let host = url.host_str().ok_or_else(|| format!("URL {raw:?} has no host"))?;
|
|
let host = host.trim_matches(|c| c == '[' || c == ']');
|
|
|
|
// A literal IP never reaches the resolver, so it is checked here. Hostnames are checked
|
|
// where their addresses become known.
|
|
if let Ok(ip) = host.parse::<IpAddr>() {
|
|
self.check_ip(ip)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// The veto the engine's resolver applies to every address a hostname resolves to.
|
|
pub fn address_filter(&self) -> AddressFilter {
|
|
let policy = self.clone();
|
|
Arc::new(move |ip| policy.check_ip(ip))
|
|
}
|
|
|
|
pub fn check_ip(&self, ip: IpAddr) -> Result<(), String> {
|
|
if self.allow_private {
|
|
return Ok(());
|
|
}
|
|
match non_public_reason(ip) {
|
|
Some(reason) => Err(format!(
|
|
"Refusing to connect to {ip}: {reason}. This server only sends to public addresses"
|
|
)),
|
|
None => Ok(()),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Why an address is not a public internet address, or `None` if it is one.
|
|
///
|
|
/// Every range here is one a hosted relay must never be talked into reaching: the machine
|
|
/// itself, the network it sits on, and the link-local range where cloud metadata services
|
|
/// (169.254.169.254) live. IPv4 addresses carried inside fixed-layout IPv6 forms — IPv4-mapped,
|
|
/// the well-known NAT64 prefix, 6to4 — are unwrapped and judged as IPv4, since that is where
|
|
/// the packets end up; the NAT64 local-use range is refused outright. This is the stable-Rust
|
|
/// stand-in for `IpAddr::is_global`, which is still behind `#![feature(ip)]`; a network-specific
|
|
/// NAT64 prefix is not knowable here.
|
|
pub fn non_public_reason(ip: IpAddr) -> Option<&'static str> {
|
|
match ip {
|
|
IpAddr::V4(v4) => non_public_v4(v4),
|
|
IpAddr::V6(v6) => {
|
|
if let Some(v4) = v6.to_ipv4_mapped() {
|
|
return non_public_v4(v4);
|
|
}
|
|
if let Some(v4) = embedded_v4(&v6) {
|
|
return non_public_v4(v4);
|
|
}
|
|
if v6.is_loopback() {
|
|
Some("loopback")
|
|
} else if v6.is_unspecified() {
|
|
Some("unspecified")
|
|
} else if v6.is_unique_local() {
|
|
Some("unique local (fc00::/7)")
|
|
} else if v6.is_unicast_link_local() {
|
|
Some("link-local (fe80::/10)")
|
|
} else if v6.is_multicast() {
|
|
Some("multicast")
|
|
} else if v6.segments()[..3] == [0x64, 0xff9b, 1] {
|
|
Some("NAT64 local-use (64:ff9b:1::/48)")
|
|
} else if v6.segments()[..4] == [0x100, 0, 0, 0] {
|
|
Some("discard-only (100::/64)")
|
|
} else if (v6.segments()[0] & 0xffc0) == 0xfec0 {
|
|
Some("site-local (fec0::/10)")
|
|
} else if v6.segments()[0] == 0x2001 && v6.segments()[1] == 0x0db8 {
|
|
Some("documentation (2001:db8::/32)")
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn non_public_v4(v4: Ipv4Addr) -> Option<&'static str> {
|
|
let o = v4.octets();
|
|
if v4.is_loopback() {
|
|
Some("loopback (127.0.0.0/8)")
|
|
} else if v4.is_private() {
|
|
Some("private (10/8, 172.16/12, 192.168/16)")
|
|
} else if v4.is_link_local() {
|
|
Some("link-local (169.254.0.0/16, where cloud metadata lives)")
|
|
} else if v4.is_unspecified() || o[0] == 0 {
|
|
Some("this network (0.0.0.0/8)")
|
|
} else if o[0] == 100 && (o[1] & 0xc0) == 64 {
|
|
Some("carrier-grade NAT (100.64.0.0/10)")
|
|
} else if v4.is_broadcast() {
|
|
Some("broadcast")
|
|
} else if v4.is_multicast() {
|
|
Some("multicast (224.0.0.0/4)")
|
|
} else if o[0] >= 240 {
|
|
Some("reserved (240.0.0.0/4)")
|
|
} else if v4.is_documentation() {
|
|
Some("documentation")
|
|
} else if o[0] == 192 && o[1] == 0 && o[2] == 0 {
|
|
Some("IETF protocol assignments (192.0.0.0/24)")
|
|
} else if o[0] == 198 && (o[1] & 0xfe) == 18 {
|
|
Some("benchmarking (198.18.0.0/15)")
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
/// The IPv4 address an IPv6 address stands for, when it is one of the fixed-layout translation
|
|
/// forms: the NAT64 well-known prefix (64:ff9b::/96) or 6to4 (2002::/16, IPv4 in the next 32
|
|
/// bits). The NAT64 local-use range (64:ff9b:1::/48) is a pool operators carve their own
|
|
/// prefix from, at a length only they know, so it is refused wholesale in [`non_public_reason`]
|
|
/// rather than decoded — the same call `std`'s (still unstable) `Ipv6Addr::is_global` makes.
|
|
fn embedded_v4(v6: &Ipv6Addr) -> Option<Ipv4Addr> {
|
|
let s = v6.segments();
|
|
let o = v6.octets();
|
|
if s[0] == 0x64 && s[1] == 0xff9b && s[2..6].iter().all(|x| *x == 0) {
|
|
return Some(Ipv4Addr::new(o[12], o[13], o[14], o[15]));
|
|
}
|
|
if s[0] == 0x2002 {
|
|
return Some(Ipv4Addr::new(o[2], o[3], o[4], o[5]));
|
|
}
|
|
None
|
|
}
|
|
|
|
/// An [`HttpSender`] that checks each hop's URL against the policy before delegating.
|
|
///
|
|
/// The engine's redirect loop calls the sender once per hop with the hop's URL, so wrapping
|
|
/// the sender is what makes `Location:` headers subject to the same rules as the first URL —
|
|
/// including a redirect to a literal internal IP, which the resolver would never see.
|
|
pub struct GuardedSender<S> {
|
|
inner: S,
|
|
policy: DestinationPolicy,
|
|
}
|
|
|
|
impl<S: HttpSender> GuardedSender<S> {
|
|
pub fn new(inner: S, policy: DestinationPolicy) -> Self {
|
|
Self { inner, policy }
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl<S: HttpSender> HttpSender for GuardedSender<S> {
|
|
async fn send(
|
|
&self,
|
|
request: SendableHttpRequest,
|
|
event_tx: mpsc::Sender<HttpResponseEvent>,
|
|
) -> yaak_http::error::Result<HttpResponse> {
|
|
if let Err(reason) = self.policy.check_url(&request.url) {
|
|
warn!("Refused {} {}: {reason}", request.method, request.url);
|
|
return Err(yaak_http::error::Error::RequestError(reason));
|
|
}
|
|
self.inner.send(request, event_tx).await
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn ip(s: &str) -> IpAddr {
|
|
s.parse().unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn refuses_the_ranges_a_relay_must_never_reach() {
|
|
for addr in [
|
|
"127.0.0.1",
|
|
"127.9.9.9",
|
|
"10.0.0.1",
|
|
"172.16.0.1",
|
|
"172.31.255.255",
|
|
"192.168.1.1",
|
|
"169.254.169.254",
|
|
"169.254.0.1",
|
|
"0.0.0.0",
|
|
"100.64.0.1",
|
|
"255.255.255.255",
|
|
"224.0.0.1",
|
|
"240.0.0.1",
|
|
"::1",
|
|
"::",
|
|
"fc00::1",
|
|
"fd12::1",
|
|
"fe80::1",
|
|
"::ffff:127.0.0.1",
|
|
"::ffff:169.254.169.254",
|
|
"64:ff9b::7f00:1",
|
|
"ff02::1",
|
|
] {
|
|
assert!(non_public_reason(ip(addr)).is_some(), "{addr} should be refused");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn allows_public_addresses() {
|
|
for addr in [
|
|
"1.1.1.1",
|
|
"8.8.8.8",
|
|
"93.184.216.34",
|
|
"172.32.0.1",
|
|
"2606:4700:4700::1111",
|
|
] {
|
|
assert!(non_public_reason(ip(addr)).is_none(), "{addr} should be allowed");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn literal_private_addresses_in_urls_are_refused() {
|
|
let policy = DestinationPolicy::new(false);
|
|
assert!(policy.check_url("http://127.0.0.1/").is_err());
|
|
assert!(policy.check_url("http://[::1]/").is_err());
|
|
assert!(policy.check_url("http://169.254.169.254/latest/meta-data").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn allow_private_networks_opens_the_local_ranges_but_not_other_schemes() {
|
|
let policy = DestinationPolicy::new(true);
|
|
assert!(policy.check_url("http://127.0.0.1/").is_ok());
|
|
assert!(policy.check_ip(ip("10.0.0.1")).is_ok());
|
|
assert!(policy.check_ip(ip("169.254.169.254")).is_ok());
|
|
assert!(policy.check_url("file:///etc/passwd").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn only_http_schemes() {
|
|
let policy = DestinationPolicy::new(false);
|
|
assert!(policy.check_url("ftp://example.com/").is_err());
|
|
assert!(policy.check_url("file:///etc/passwd").is_err());
|
|
assert!(policy.check_url("https://example.com/").is_ok());
|
|
}
|
|
}
|