Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/monetize

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"
    );
}