mod common;
use std::future::Future;
use std::io;
use std::net::SocketAddr;
use std::pin::Pin;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, Instant};
use common::{Reply, Table, serve};
use ssrf_guard::header::{AUTHORIZATION, CONTENT_TYPE, HeaderName, HeaderValue};
use ssrf_guard::{Error, Fetcher, Method, Policy, PolicyBuilder, Request, Url};
type Lookup<'a> = Pin<Box<dyn Future<Output = io::Result<Vec<SocketAddr>>> + Send + 'a>>;
fn loopback() -> PolicyBuilder {
Policy::builder().allow("127.0.0.1".parse().unwrap())
}
fn post(url: &str) -> Request {
Request::new(Method::POST, Url::parse(url).unwrap())
.header(
HeaderName::from_static("x-api-key"),
HeaderValue::from_static("k-123"),
)
.header(AUTHORIZATION, HeaderValue::from_static("Bearer t-456"))
.header(CONTENT_TYPE, HeaderValue::from_static("application/json"))
.body(r#"{"password":"hunter2"}"#)
}
#[tokio::test]
async fn loopback_is_refused_by_default() {
let server = serve(|_| Reply::ok("hi")).await;
let err = Fetcher::default().get(&server.url("/")).await.unwrap_err();
assert!(
matches!(err, Error::Denied { addr, .. } if addr == server.addr.ip()),
"{err}"
);
assert!(server.seen().is_empty(), "nothing should have connected");
}
#[tokio::test]
async fn fetches_when_the_policy_allows_it() {
let server = serve(|_| Reply::ok("hello")).await;
let resp = Fetcher::new(loopback().build())
.get(&server.url("/x"))
.await
.unwrap();
assert_eq!(resp.status(), 200);
assert_eq!(resp.remote_addr(), Some(server.addr));
assert_eq!(resp.text().await.unwrap(), "hello");
}
#[tokio::test]
async fn same_origin_redirects_keep_headers_and_body() {
let server = serve(|req| match req.path.as_str() {
"/start" => Reply::redirect(307, "/next"),
_ => Reply::ok("done"),
})
.await;
let fetcher = Fetcher::new(loopback().build()).user_agent(HeaderValue::from_static("test/1"));
let resp = fetcher.send(post(&server.url("/start"))).await.unwrap();
assert_eq!(resp.url().path(), "/next");
let seen = server.seen();
assert_eq!(seen.len(), 2);
let last = &seen[1];
assert_eq!(last.method, "POST", "307 repeats the method");
assert_eq!(last.header("x-api-key"), Some("k-123"));
assert_eq!(last.header("authorization"), Some("Bearer t-456"));
assert_eq!(last.body, br#"{"password":"hunter2"}"#);
}
#[tokio::test]
async fn cross_origin_redirects_drop_headers_and_body() {
let other = serve(|_| Reply::ok("landed")).await;
let to = other.url("/land");
let first = serve(move |_| Reply::redirect(307, &to)).await;
let fetcher = Fetcher::new(loopback().build()).user_agent(HeaderValue::from_static("test/1"));
let resp = fetcher.send(post(&first.url("/start"))).await.unwrap();
assert_eq!(resp.url().port(), Some(other.port()));
assert_eq!(first.seen()[0].header("x-api-key"), Some("k-123"));
let landed = &other.seen()[0];
assert_eq!(landed.method, "POST");
assert_eq!(landed.header("x-api-key"), None);
assert_eq!(landed.header("authorization"), None);
assert_eq!(landed.header("content-type"), None);
assert!(landed.body.is_empty(), "the body stays on the first origin");
assert_eq!(landed.header("user-agent"), Some("test/1"));
}
#[tokio::test]
async fn a_hop_through_another_host_name_stays_untrusted() {
let server = serve(|req| {
let host = req.header("host").unwrap_or_default();
let port = host.rsplit(':').next().unwrap_or_default();
match (host.split(':').next(), req.path.as_str()) {
(Some("a.test"), "/") => Reply::redirect(307, &format!("http://b.test:{port}/")),
(Some("b.test"), _) => Reply::redirect(307, &format!("http://a.test:{port}/back")),
_ => Reply::ok("back on a"),
}
})
.await;
let port = server.port();
let table = Table::default()
.with("a.test", &[&["127.0.0.1"]])
.with("b.test", &[&["127.0.0.1"]]);
let fetcher = Fetcher::new(loopback().build()).with_resolver(table);
let resp = fetcher
.send(post(&format!("http://a.test:{port}/")))
.await
.unwrap();
assert_eq!(resp.text().await.unwrap(), "back on a");
let seen = server.seen();
assert_eq!(seen.len(), 3);
assert_eq!(seen[0].header("x-api-key"), Some("k-123"));
assert_eq!(seen[1].header("x-api-key"), None);
assert_eq!(seen[2].header("x-api-key"), None);
assert!(seen[2].body.is_empty());
}
#[tokio::test]
async fn see_other_turns_a_post_into_a_get_without_its_body() {
let server = serve(|req| match req.path.as_str() {
"/form" => Reply::redirect(303, "/result"),
_ => Reply::ok(""),
})
.await;
Fetcher::new(loopback().build())
.send(post(&server.url("/form")))
.await
.unwrap();
let result = &server.seen()[1];
assert_eq!(result.method, "GET");
assert!(result.body.is_empty());
assert_eq!(result.header("content-type"), None);
assert_eq!(
result.header("x-api-key"),
Some("k-123"),
"still the same origin"
);
}
#[tokio::test]
async fn a_redirect_to_a_refused_address_is_not_followed() {
for location in [
"http://127.0.0.2/",
"http://[::1]/",
"http://169.254.169.254/latest/meta-data/",
"http://[::ffff:10.1.2.3]/",
"http://0.0.0.0/",
] {
let to = location.to_string();
let server = serve(move |_| Reply::redirect(302, &to)).await;
let err = Fetcher::new(loopback().build())
.get(&server.url("/"))
.await
.unwrap_err();
assert!(matches!(err, Error::Denied { .. }), "{location}: {err}");
}
}
#[tokio::test]
async fn one_refused_address_refuses_the_whole_name() {
let server = serve(|_| Reply::ok("")).await;
let table = Table::default().with("mixed.test", &[&["127.0.0.1", "10.1.2.3"]]);
let fetcher = Fetcher::new(loopback().build()).with_resolver(table);
let err = fetcher
.get(&format!("http://mixed.test:{}/", server.port()))
.await
.unwrap_err();
assert!(
matches!(err, Error::Denied { addr, .. } if addr.to_string() == "10.1.2.3"),
"{err}"
);
assert!(server.seen().is_empty());
}
#[tokio::test]
async fn a_name_that_rebinds_cannot_move_the_connection() {
let server = serve(|req| match req.path.as_str() {
"/" => Reply::redirect(302, "/again"),
_ => Reply::ok(""),
})
.await;
let table = Table::default().with("rebind.test", &[&["127.0.0.1"], &["10.1.2.3"]]);
let fetcher = Fetcher::new(loopback().build()).with_resolver(table.clone());
let err = fetcher
.get(&format!("http://rebind.test:{}/", server.port()))
.await
.unwrap_err();
let seen = server.seen();
assert_eq!(seen.len(), 1);
assert_eq!(seen[0].path, "/");
assert!(
matches!(err, Error::Denied { addr, .. } if addr.to_string() == "10.1.2.3"),
"{err}"
);
assert_eq!(table.lookups(), ["rebind.test", "rebind.test"]);
}
#[tokio::test]
async fn the_redirect_limit_holds() {
let server = serve(|req| {
let n: usize = req.path.trim_start_matches('/').parse().unwrap_or(0);
Reply::redirect(302, &format!("/{}", n + 1))
})
.await;
let err = Fetcher::new(loopback().build())
.get(&server.url("/0"))
.await
.unwrap_err();
assert!(matches!(err, Error::TooManyRedirects(3)), "{err}");
assert_eq!(
server.seen().len(),
4,
"the first request and three redirects"
);
let none = Fetcher::new(loopback().max_redirects(0).build());
let resp = none.get(&server.url("/0")).await.unwrap();
assert_eq!(resp.status(), 302);
assert_eq!(resp.headers()["location"], "/1");
}
#[tokio::test]
async fn a_redirect_without_location_is_the_answer() {
let server = serve(|_| Reply {
status: 302,
headers: vec![],
body: vec![],
delay: Duration::ZERO,
})
.await;
let resp = Fetcher::new(loopback().build())
.get(&server.url("/"))
.await
.unwrap();
assert_eq!(resp.status(), 302);
}
#[tokio::test]
async fn only_redirect_statuses_are_followed() {
for status in [300, 304, 305] {
let server = serve(move |req| match req.path.as_str() {
"/" => Reply::redirect(status, "/elsewhere"),
_ => Reply::ok("followed"),
})
.await;
let resp = Fetcher::new(loopback().build())
.get(&server.url("/"))
.await
.unwrap();
assert_eq!(resp.status(), status);
assert_eq!(server.seen().len(), 1, "{status} was followed");
}
}
#[tokio::test]
async fn a_location_with_raw_utf8_is_followed() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let paths = Arc::new(std::sync::Mutex::new(Vec::new()));
let seen = paths.clone();
tokio::spawn(async move {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
while let Ok((mut sock, _)) = listener.accept().await {
let mut buf = [0u8; 1024];
let n = sock.read(&mut buf).await.unwrap_or(0);
let head = String::from_utf8_lossy(&buf[..n]).into_owned();
let path = head.split(' ').nth(1).unwrap_or_default().to_string();
let reply: &[u8] = if path == "/" {
"HTTP/1.1 302 Found\r\nLocation: /caf\u{e9}\r\nContent-Length: 0\r\n\r\n".as_bytes()
} else {
b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n"
};
seen.lock().unwrap().push(path);
let _ = sock.write_all(reply).await;
}
});
let resp = Fetcher::new(loopback().build())
.get(&format!("http://{addr}/"))
.await
.unwrap();
assert_eq!(resp.status(), 200);
assert_eq!(resp.url().path(), "/caf%C3%A9");
assert_eq!(paths.lock().unwrap().as_slice(), ["/", "/caf%C3%A9"]);
}
#[tokio::test]
async fn a_location_that_isnt_utf8_is_refused() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
while let Ok((mut sock, _)) = listener.accept().await {
let mut buf = [0u8; 1024];
let _ = sock.read(&mut buf).await;
let reply = b"HTTP/1.1 302 Found\r\nLocation: /caf\xe9\r\nContent-Length: 0\r\n\r\n";
let _ = sock.write_all(reply).await;
}
});
let err = Fetcher::new(loopback().build())
.get(&format!("http://{addr}/"))
.await
.unwrap_err();
assert!(matches!(err, Error::BadRedirect(_)), "{err}");
}
#[tokio::test]
async fn errors_dont_carry_the_url() {
let port = {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
listener.local_addr().unwrap().port()
};
let fetcher = Fetcher::new(loopback().build());
let err = fetcher
.get(&format!("http://127.0.0.1:{port}/cb?token=q-012"))
.await
.unwrap_err();
assert!(matches!(err, Error::Http(_)), "{err}");
let mut text = format!("{err:?}");
let mut source: Option<&dyn std::error::Error> = Some(&err);
while let Some(e) = source {
text.push_str(&e.to_string());
source = e.source();
}
assert!(!text.contains("q-012"), "{text}");
for url in [
"https://user:pw-345@example.com/",
"gopher://example.com/?token=q-012",
"https//pw-345@example.com",
] {
let err = fetcher.get(url).await.unwrap_err();
assert!(matches!(err, Error::InvalidUrl(_)), "{url}: {err}");
let shown = err.to_string();
assert!(
!shown.contains("pw-345") && !shown.contains("q-012"),
"{shown}"
);
}
}
#[tokio::test]
async fn connection_headers_cannot_be_set() {
let server = serve(|_| Reply::ok("")).await;
let url = Url::parse(&server.url("/")).unwrap();
for name in ["host", "content-length", "transfer-encoding", "connection"] {
let req = Request::new(Method::POST, url.clone())
.header(
HeaderName::from_static(name),
HeaderValue::from_static("internal.example"),
)
.body("hello");
let err = Fetcher::new(loopback().build())
.send(req)
.await
.unwrap_err();
assert!(
matches!(&err, Error::RefusedHeader(n) if n == name),
"{err}"
);
}
assert!(server.seen().is_empty());
}
#[tokio::test]
async fn a_resolver_cannot_choose_the_port() {
struct WrongPort;
impl ssrf_guard::Resolve for WrongPort {
fn resolve<'a>(&'a self, _host: &'a str, _port: u16) -> Lookup<'a> {
Box::pin(async { Ok(vec!["127.0.0.1:22".parse().unwrap()]) })
}
}
let fetcher = Fetcher::new(loopback().build()).with_resolver(WrongPort);
let addrs = fetcher.resolve("any.test", 8080).await.unwrap();
assert_eq!(addrs, ["127.0.0.1:8080".parse().unwrap()]);
}
#[tokio::test]
async fn unfollowable_redirects_are_errors() {
for location in [
"ftp://example.com/",
"http://user:pw@127.0.0.1/",
"http://[::1",
] {
let to = location.to_string();
let server = serve(move |_| Reply::redirect(302, &to)).await;
let err = Fetcher::new(loopback().build())
.get(&server.url("/"))
.await
.unwrap_err();
assert!(matches!(err, Error::BadRedirect(_)), "{location}: {err}");
}
}
#[tokio::test]
async fn one_timeout_covers_every_hop() {
let server = serve(|req| {
let n: usize = req.path.trim_start_matches('/').parse().unwrap_or(0);
let reply = if n < 3 {
Reply::redirect(302, &format!("/{}", n + 1))
} else {
Reply::ok("")
};
reply.after(Duration::from_millis(300))
})
.await;
let fetcher = Fetcher::new(loopback().timeout(Duration::from_millis(800)).build());
let started = Instant::now();
let err = fetcher.get(&server.url("/0")).await.unwrap_err();
assert!(matches!(err, Error::Timeout(_)), "{err}");
assert!(
started.elapsed() < Duration::from_millis(1500),
"took {:?}",
started.elapsed()
);
assert!(server.seen().len() < 4);
}
#[tokio::test]
async fn the_timeout_covers_reading_the_body() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let (mut sock, _) = listener.accept().await.unwrap();
let mut buf = [0u8; 1024];
let _ = sock.read(&mut buf).await;
sock.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 1000\r\n\r\npartial")
.await
.unwrap();
tokio::time::sleep(Duration::from_secs(10)).await;
});
let fetcher = Fetcher::new(loopback().timeout(Duration::from_millis(400)).build());
let resp = fetcher.get(&format!("http://{addr}/")).await.unwrap();
let started = Instant::now();
let err = resp.bytes().await.unwrap_err();
assert!(matches!(err, Error::Timeout(_)), "{err}");
assert!(started.elapsed() < Duration::from_secs(2));
}
#[tokio::test]
async fn the_body_limit_holds() {
let server = serve(|_| Reply::ok(&"x".repeat(100))).await;
let fetcher = Fetcher::new(loopback().max_body(99).build());
let err = fetcher
.get(&server.url("/"))
.await
.unwrap()
.bytes()
.await
.unwrap_err();
assert!(matches!(err, Error::BodyTooLarge(99)), "{err}");
let fetcher = Fetcher::new(loopback().max_body(100).build());
assert_eq!(
fetcher
.get(&server.url("/"))
.await
.unwrap()
.bytes()
.await
.unwrap()
.len(),
100
);
}
#[tokio::test]
async fn a_long_body_can_be_cut_short() {
let server = serve(|_| Reply::ok(&"x".repeat(100))).await;
let fetcher = Fetcher::new(loopback().max_body(40).build());
let resp = fetcher.get(&server.url("/")).await.unwrap();
let (body, cut) = resp.bytes_truncated().await.unwrap();
assert_eq!((body.len(), cut), (40, true));
let fetcher = Fetcher::new(loopback().max_body(100).build());
let resp = fetcher.get(&server.url("/")).await.unwrap();
let (body, cut) = resp.bytes_truncated().await.unwrap();
assert_eq!((body.len(), cut), (100, false));
}
#[tokio::test]
async fn chunks_stop_at_the_body_limit() {
let server = serve(|_| Reply::ok(&"x".repeat(100))).await;
let mut resp = Fetcher::new(loopback().max_body(99).build())
.get(&server.url("/"))
.await
.unwrap();
let mut read = 0;
let err = loop {
match resp.chunk().await {
Ok(Some(c)) => read += c.len(),
Ok(None) => panic!("read all {read} bytes past the limit"),
Err(e) => break e,
}
};
assert!(matches!(err, Error::BodyTooLarge(99)), "{err}");
assert!(read <= 99);
}
#[tokio::test]
async fn a_denied_block_wins_over_an_allowed_one() {
let server = serve(|_| Reply::ok("")).await;
let fetcher = Fetcher::new(loopback().deny("127.0.0.0/8".parse().unwrap()).build());
assert!(matches!(
fetcher.get(&server.url("/")).await,
Err(Error::Denied { .. })
));
}
#[tokio::test]
async fn urls_that_are_not_http_are_refused() {
let fetcher = Fetcher::default();
for url in [
"file:///etc/passwd",
"gopher://example.com/",
"https://user:pw@example.com/",
"not a url",
] {
assert!(
matches!(fetcher.get(url).await, Err(Error::InvalidUrl(_))),
"{url}"
);
}
}
#[tokio::test]
async fn connect_checks_the_address_first() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let accepted = Arc::new(AtomicUsize::new(0));
let count = accepted.clone();
tokio::spawn(async move {
while listener.accept().await.is_ok() {
count.fetch_add(1, Ordering::SeqCst);
}
});
let err = Fetcher::default()
.connect("127.0.0.1", addr.port())
.await
.unwrap_err();
assert!(matches!(err, Error::Denied { .. }), "{err}");
let stream = Fetcher::new(loopback().build())
.connect("127.0.0.1", addr.port())
.await
.unwrap();
assert_eq!(stream.peer_addr().unwrap(), addr);
let table = Table::default().with("db.test", &[&["127.0.0.1"]]);
let fetcher = Fetcher::new(loopback().build()).with_resolver(table);
fetcher.connect("db.test", addr.port()).await.unwrap();
tokio::time::sleep(Duration::from_millis(50)).await;
assert_eq!(accepted.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn an_address_that_never_answers_leaves_time_for_the_next() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move { while listener.accept().await.is_ok() {} });
let table = Table::default().with("slow.test", &[&["192.0.2.1", "127.0.0.1"]]);
let policy = loopback()
.allow("192.0.2.1".parse().unwrap())
.timeout(Duration::from_secs(3))
.build();
let started = Instant::now();
let stream = Fetcher::new(policy)
.with_resolver(table)
.connect("slow.test", addr.port())
.await
.unwrap();
assert_eq!(stream.peer_addr().unwrap(), addr);
assert!(started.elapsed() < Duration::from_secs(3));
}
#[tokio::test]
async fn connections_leave_from_the_source_address() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move { while listener.accept().await.is_ok() {} });
let policy = loopback().source("127.0.0.1".parse().unwrap()).build();
let stream = Fetcher::new(policy)
.connect("127.0.0.1", addr.port())
.await
.unwrap();
assert_eq!(stream.local_addr().unwrap().ip().to_string(), "127.0.0.1");
let v6_only = loopback().source("::1".parse().unwrap()).build();
let err = Fetcher::new(v6_only)
.connect("127.0.0.1", addr.port())
.await
.unwrap_err();
assert!(matches!(err, Error::NoSource { .. }), "{err}");
}
#[tokio::test]
async fn fetches_leave_from_the_source_address() {
let server = serve(|_| Reply::ok("")).await;
let fetcher = Fetcher::new(loopback().source("127.0.0.1".parse().unwrap()).build());
assert_eq!(fetcher.get(&server.url("/")).await.unwrap().status(), 200);
let v6_only = Fetcher::new(loopback().source("::1".parse().unwrap()).build());
assert!(matches!(
v6_only.get(&server.url("/")).await,
Err(Error::NoSource { .. })
));
}
#[tokio::test]
#[ignore = "needs the internet"]
async fn a_real_https_fetch() {
let resp = Fetcher::default()
.get("https://example.com/")
.await
.unwrap();
assert_eq!(resp.status(), 200);
assert!(resp.text().await.unwrap().contains("Example Domain"));
}
#[tokio::test]
#[ignore = "needs the internet"]
async fn a_public_name_for_the_metadata_service() {
let err = Fetcher::default()
.get("http://169.254.169.254.nip.io/latest/meta-data/")
.await
.unwrap_err();
assert!(
matches!(err, Error::Denied { addr, .. } if addr.to_string() == "169.254.169.254"),
"{err}"
);
}
#[tokio::test]
#[ignore = "needs the internet"]
async fn a_bad_certificate_fails() {
let err = Fetcher::default()
.get("https://expired.badssl.com/")
.await
.unwrap_err();
assert!(matches!(err, Error::Http(_)), "{err}");
}