crates/monetize-site-buy/tests/a17_client_ip.rs
//! ★ **The client's address is the socket's unless a trusted proxy vouches** (A17 F-B-03,
//! 2026-10-23): the relay's router believes `X-Forwarded-For` only from loopback (a reverse
//! proxy on the same box) and from the proxies the site names, and then only its right-most
//! hop that no trusted proxy wrote — Gunnar's console's rule (`security::client_ip_from`).
//! Before, the first hop was believed from anyone, so a visitor wrote a new address on every
//! request and escaped every budget.
use std::collections::BTreeMap;
use std::net::SocketAddr;
use std::sync::{Arc, Mutex};
use axum::extract::ConnectInfo;
use axum::http::HeaderMap;
use monetize_site_buy::{Account, BuySite, Buyer, Door, Refusal, Relay, ShopLink, Who};
/// A site that writes down the address every budget was asked for, and refuses it (so the
/// shop is never dialled).
#[derive(Clone, Default)]
struct Site {
seen: Arc<Mutex<Vec<String>>>,
trusted: Vec<std::net::IpAddr>,
}
impl BuySite for Site {
fn origin(&self) -> String {
"https://holger.mock.invalid".into()
}
fn product_word(&self) -> String {
"Holger".into()
}
fn trusted_proxies(&self) -> &[std::net::IpAddr] {
&self.trusted
}
fn allowed(&self, _door: &str, ip: &str, _key: &str) -> bool {
self.seen.lock().unwrap().push(ip.to_owned());
false
}
async fn who(&self, _: &HeaderMap) -> Result<Who, Refusal> {
Ok(Who::Visitor)
}
async fn ticket(
&self,
_: &HeaderMap,
_: &Buyer,
_: &str,
_: &BTreeMap<String, u64>,
) -> Result<Vec<u8>, Refusal> {
Ok(Vec::new())
}
async fn held_caps(&self, _: &HeaderMap, _: &Buyer) -> Result<BTreeMap<String, u64>, Refusal> {
Ok(BTreeMap::new())
}
async fn signup_account(&self, _: &str) -> Result<Account, Refusal> {
Ok(Account::NoPasskey)
}
}
/// The address the name check's budget was asked for, for a request from `peer` carrying `xff`.
async fn seen_for<S: BuySite + Clone>(
site: S,
seen: &Arc<Mutex<Vec<String>>>,
peer: Option<&str>,
xff: Option<&str>,
) -> String {
use tower::ServiceExt;
let link = ShopLink::connect_lazy(
&Door {
url: "http://127.0.0.1:9".into(),
..Door::default()
},
"site-bearer-for-tests",
"holger",
)
.unwrap();
let r = Arc::new(Relay::new(site, link));
let mut req = axum::http::Request::builder().uri("/auth/signup/name?u=x14");
if let Some(x) = xff {
req = req.header("x-forwarded-for", x);
}
let mut req = req.body(axum::body::Body::empty()).unwrap();
if let Some(p) = peer {
req.extensions_mut()
.insert(ConnectInfo(p.parse::<SocketAddr>().unwrap()));
}
let res = monetize_site_buy::router(r).oneshot(req).await.unwrap();
assert_eq!(res.status(), axum::http::StatusCode::TOO_MANY_REQUESTS);
seen.lock().unwrap().pop().expect("the budget was asked")
}
#[tokio::test]
async fn a_forged_header_from_an_untrusted_peer_counts_as_the_peer() {
let site = Site::default();
let seen = site.seen.clone();
for forged in ["203.0.113.9", "198.51.100.1", "10.9.9.9, 127.0.0.1"] {
assert_eq!(
seen_for(
site.clone(),
&seen,
Some("198.51.100.4:40000"),
Some(forged)
)
.await,
"198.51.100.4",
"forged {forged}"
);
}
assert_eq!(
seen_for(site.clone(), &seen, Some("198.51.100.4:40000"), None).await,
"198.51.100.4"
);
}
#[tokio::test]
async fn behind_a_proxy_on_loopback_the_right_most_untrusted_hop_counts() {
let site = Site::default();
let seen = site.seen.clone();
// The visitor typed the left hop; the proxy on the same box appended what it saw.
assert_eq!(
seen_for(
site.clone(),
&seen,
Some("127.0.0.1:50000"),
Some("198.51.100.66, 203.0.113.9")
)
.await,
"203.0.113.9"
);
assert_eq!(
seen_for(
site.clone(),
&seen,
Some("[::1]:50000"),
Some("203.0.113.9, 127.0.0.1")
)
.await,
"203.0.113.9"
);
// A loopback peer that forwards nothing is itself the client.
assert_eq!(
seen_for(site.clone(), &seen, Some("127.0.0.1:50000"), None).await,
"127.0.0.1"
);
// No socket to judge the claim by: one shared bucket, never the header.
assert_eq!(
seen_for(site.clone(), &seen, None, Some("203.0.113.9")).await,
"unknown"
);
}
#[tokio::test]
async fn a_proxy_the_site_names_is_believed_and_its_own_hop_skipped() {
let site = Site {
trusted: vec!["10.0.0.2".parse().unwrap(), "10.0.0.3".parse().unwrap()],
..Site::default()
};
let seen = site.seen.clone();
assert_eq!(
seen_for(
site.clone(),
&seen,
Some("10.0.0.2:443"),
Some("198.51.100.66, 203.0.113.9, 10.0.0.3")
)
.await,
"203.0.113.9"
);
// Any other peer is still only itself.
assert_eq!(
seen_for(
site.clone(),
&seen,
Some("10.0.0.4:443"),
Some("203.0.113.9")
)
.await,
"10.0.0.4"
);
}