use ullun::prelude::*;
use ullun::proxy::TrustedProxies;
use ullun::rate_limit::RateLimiter;
use ullun::testing::TestClient;
fn app(proxies: Option<TrustedProxies>) -> App {
let mut app = App::new().get("/ip", |req: Request| async move {
Ok(Response::text(format!(
"{} {}",
req.client_ip().map(|i| i.to_string()).unwrap_or_default(),
req.is_secure()
)))
});
if let Some(p) = proxies {
app = app.trusted_proxies(p);
}
app
}
fn lb() -> TrustedProxies {
TrustedProxies::new().trust("10.0.0.0/8").unwrap()
}
async fn ip(app: App, peer: [u8; 4], headers: &[(&str, &str)]) -> String {
let c = TestClient::new(app);
let mut call = c.get("/ip").remote_addr((peer, 4000));
for (k, v) in headers {
call = call.header(k, v);
}
call.send().await.text()
}
#[tokio::test]
async fn without_trusted_proxies_headers_are_ignored() {
let out = ip(
app(None),
[10, 0, 0, 1],
&[
("x-forwarded-for", "1.2.3.4"),
("x-forwarded-proto", "https"),
],
)
.await;
assert_eq!(out, "10.0.0.1 false");
}
#[tokio::test]
async fn only_trusted_peers_may_set_the_client() {
let out = ip(
app(Some(lb())),
[203, 0, 113, 5],
&[
("x-forwarded-for", "1.2.3.4"),
("x-forwarded-proto", "https"),
],
)
.await;
assert_eq!(out, "203.0.113.5 false");
let out = ip(
app(Some(lb())),
[10, 0, 0, 1],
&[
("x-forwarded-for", "198.51.100.7"),
("x-forwarded-proto", "https"),
],
)
.await;
assert_eq!(out, "198.51.100.7 true");
}
#[tokio::test]
async fn spoofed_leftmost_entries_are_skipped() {
let out = ip(
app(Some(lb())),
[10, 0, 0, 1],
&[("x-forwarded-for", "6.6.6.6, 198.51.100.7")],
)
.await;
assert_eq!(out, "198.51.100.7 false");
let out = ip(
app(Some(lb())),
[10, 0, 0, 1],
&[(
"x-forwarded-for",
"6.6.6.6, 198.51.100.7, 10.0.0.9, 10.0.0.8",
)],
)
.await;
assert_eq!(out, "198.51.100.7 false");
}
#[tokio::test]
async fn all_trusted_chain_and_garbage_fall_back_safely() {
let out = ip(
app(Some(lb())),
[10, 0, 0, 1],
&[("x-forwarded-for", "10.0.0.5, 10.0.0.6")],
)
.await;
assert_eq!(out, "10.0.0.5 false");
let out = ip(
app(Some(lb())),
[10, 0, 0, 1],
&[("x-forwarded-for", "9.9.9.9, unknown, 10.0.0.6")],
)
.await;
assert_eq!(out, "10.0.0.6 false");
let out = ip(app(Some(lb())), [10, 0, 0, 1], &[]).await;
assert_eq!(out, "10.0.0.1 false");
}
#[tokio::test]
async fn forwarded_header_is_understood_and_wins() {
let out = ip(
app(Some(lb())),
[10, 0, 0, 1],
&[
(
"forwarded",
"for=6.6.6.6;proto=http, for=\"[2001:db8::7]:4711\";proto=https",
),
("x-forwarded-for", "1.1.1.1"),
],
)
.await;
assert_eq!(out, "2001:db8::7 true");
let out = ip(
app(Some(lb())),
[10, 0, 0, 1],
&[("forwarded", "for=198.51.100.7;proto=http")],
)
.await;
assert_eq!(out, "198.51.100.7 false");
}
#[tokio::test]
async fn only_the_proxy_nearest_to_us_decides_the_scheme() {
let out = ip(
app(Some(lb())),
[10, 0, 0, 1],
&[
("x-forwarded-for", "198.51.100.7"),
("x-forwarded-proto", "https, http"),
],
)
.await;
assert_eq!(out, "198.51.100.7 false");
}
#[tokio::test]
async fn rate_limit_buckets_follow_the_real_client() {
let app = App::new()
.trusted_proxies(lb())
.middleware(RateLimiter::new(1, std::time::Duration::from_secs(60)))
.get("/", |_req: Request| async { Ok(Response::text("ok")) });
let c = TestClient::new(app);
let call = |client: &str| {
c.get("/")
.remote_addr(([10, 0, 0, 1], 4000))
.header("x-forwarded-for", &format!("6.6.6.6, {client}"))
.send()
};
assert_eq!(call("198.51.100.1").await.status(), 200);
assert_eq!(call("198.51.100.1").await.status(), 429);
assert_eq!(call("198.51.100.2").await.status(), 200);
}
#[tokio::test]
async fn invalid_networks_are_reported() {
assert!(TrustedProxies::new().trust("10.0.0.0/40").is_err());
assert!(TrustedProxies::new().trust("not an ip").is_err());
}
#[tokio::test]
async fn allowed_hosts_rejects_unknown_and_malformed_hosts() {
use ullun::secure::AllowedHosts;
let app = App::new()
.middleware(AllowedHosts::new(["example.com", "*.example.com"]).except_paths(["/livez"]))
.get("/", |_req: Request| async { Ok(Response::text("ok")) })
.get("/livez", |_req: Request| async { Ok(Response::text("up")) });
let c = TestClient::new(app);
let status = |host: &'static str, path: &'static str| {
let c = c.clone();
async move { c.get(path).header("host", host).send().await.status() }
};
for ok in [
"example.com",
"EXAMPLE.com:8443",
"www.example.com",
"a.b.example.com",
] {
assert_eq!(status(ok, "/").await, 200, "{ok}");
}
for bad in [
"evil.com",
"example.com.evil.com",
"evilexample.com",
"xexample.com",
".example.com",
"example.com/x",
"user@example.com",
"10.1.2.3",
"",
] {
assert_eq!(status(bad, "/").await, 421, "{bad:?}");
}
assert_eq!(status("10.1.2.3", "/livez").await, 200);
assert_eq!(status("10.1.2.3", "/").await, 421);
let res = c.get("/").header("host", "evil.com").send().await;
assert!(res.text().contains("Unknown host"));
}
#[tokio::test]
async fn allowed_hosts_wildcard_does_not_match_the_bare_domain() {
use ullun::secure::AllowedHosts;
let app = App::new()
.middleware(AllowedHosts::new(["*.example.com"]))
.get("/", |_req: Request| async { Ok(Response::text("ok")) });
let c = TestClient::new(app);
assert_eq!(
c.get("/")
.header("host", "example.com")
.send()
.await
.status(),
421
);
assert_eq!(
c.get("/")
.header("host", "api.example.com")
.send()
.await
.status(),
200
);
}