Client IPs
Limiting by IP needs the client's real address. Connected directly, that is the TCP peer. Behind a load balancer, every request arrives from the load balancer, and the client's address is in a header the proxy wrote. Headers can also be written by the client, so actix-trypema reads them only when the TCP peer is a proxy you trust.
Clients connect directly: PeerIp
PeerIp keys by the TCP peer address and never reads headers, so it cannot be spoofed.
use actix_trypema::{Local, TrypemaLimiter, extract::PeerIp};
use trypema::{RateLimit, WindowSize};
let limiter = TrypemaLimiter::builder(Local::new(WindowSize::seconds_or_panic(60))?)
.namespace("ip")
.extractor(PeerIp::default())
.rate(RateLimit::per_minute_or_panic(60.0))
.build()?;
IPv6 clients
A single IPv6 client usually controls a whole /64 network and can rotate through its
addresses, so IPv6 clients are grouped by their /64 by default: 2001:db8:1:2::7 and
2001:db8:1:2::8 share the key 2001:db8:1:2::/64. Change the grouping when your clients get
larger or smaller allocations:
use actix_trypema::extract::PeerIp;
let per_56 = PeerIp::default().ipv6_prefix_len(56)?; // group by /56
let per_address = PeerIp::default().ipv6_prefix_len(128)?; // one bucket per address
IPv4-mapped (::ffff:203.0.113.7) and NAT64 (64:ff9b::cb00:7107) addresses are keyed as the
IPv4 address they carry, so a dual-stack listener cannot split one client into two buckets.
Behind a load balancer: RealIp::xff
RealIp::xff reads X-Forwarded-For, but only when the TCP peer is in your TrustedProxies:
use actix_trypema::{Local, TrypemaLimiter, extract::{RealIp, TrustedProxies}};
use trypema::{RateLimit, WindowSize};
// The load balancers in front of the app, and nothing else.
let trusted = TrustedProxies::new(["10.0.0.0/16"])?;
let limiter = TrypemaLimiter::builder(Local::new(WindowSize::seconds_or_panic(60))?)
.namespace("ip")
.extractor(RealIp::xff(trusted))
.rate(RateLimit::per_minute_or_panic(60.0))
.build()?;
How the client is found
Proxies append the address they received a connection from, so the right end of the header is the part your own proxies wrote. actix-trypema walks it from the right, skips addresses of trusted proxies, and stops at the first address that is not trusted. That address is the client; anything to its left was written by the client and is never read.
With TrustedProxies::new(["10.0.0.0/16"]):
| TCP peer | X-Forwarded-For | Key | Why |
|---|---|---|---|
203.0.113.7 | anything | 203.0.113.7 | The peer is not trusted, so the header is ignored. |
10.0.0.5 | 198.51.100.4 | 198.51.100.4 | The first untrusted hop. |
10.0.0.5 | 1.2.3.4, 198.51.100.4 | 198.51.100.4 | 1.2.3.4 was written by the client and is never parsed. |
10.0.0.5 | 198.51.100.4, 10.0.0.9 | 198.51.100.4 | The trusted hop 10.0.0.9 is skipped. |
10.0.0.5 | absent | 10.0.0.5 | No header: the peer itself is the client. |
10.0.0.5 | 10.0.0.9 | 10.0.0.9 | Every hop is trusted: the leftmost one is used. |
10.0.0.5 | 198.51.100.4:5123 | 198.51.100.4 | Ports are accepted and dropped. |
10.0.0.5 | garbage | key error | A malformed trusted chain never falls back silently. |
Several X-Forwarded-For header lines are read as one list, in order. Hops may carry a port
(198.51.100.4:5123, [2001:db8::1]:443). A walk longer than 32 trusted hops is a key error.
Choosing trusted proxies
TrustedProxies takes CIDR blocks and single addresses, IPv4 or IPv6:
use actix_trypema::extract::TrustedProxies;
let one_load_balancer = TrustedProxies::new(["192.0.2.10"])?;
let vpc_and_ipv6 = TrustedProxies::new(["10.0.0.0/16", "fd00:1::/64"])?;
// Rejected: trusting every address would let any client choose its own key.
assert!(TrustedProxies::new(["0.0.0.0/0"]).is_err());
List only the proxies in front of the application. A client whose own address falls inside a
trusted range can write any X-Forwarded-For it likes and pick its key. That is why there is no
"all private networks" preset: inside a VPC, a Kubernetes cluster or an office network, the
clients live in those ranges too.
Common setups:
- Cloud load balancer in a VPC (AWS ALB, GCP, Azure): trust the load balancer's subnet, not the whole VPC.
- Kubernetes ingress: trust the ingress controller's pod or node addresses, and set the
ingress to append
X-Forwarded-For. - CDN in front of a load balancer: trust both the CDN's published ranges and your load balancer. The walk skips both and lands on the visitor.
RFC 7239 Forwarded: RealIp::forwarded
Proxies that write the standard Forwarded header are read with RealIp::forwarded. It uses
each element's for= parameter and follows the same right-to-left rules:
use actix_trypema::extract::{RealIp, TrustedProxies};
// Forwarded: for=198.51.100.4;proto=https, for="[2001:db8::1]:4711"
let extractor = RealIp::forwarded(TrustedProxies::new(["10.0.0.0/16"])?);
Obfuscated identifiers (for=_hidden, for=unknown) cannot be keyed. If the walk reaches one,
the request is a key error.
CDN and proxy headers: RealIp::header
Many CDNs and proxies send the client in their own header. RealIp::header reads any
comma-separated header with the same trust rules:
use actix_trypema::extract::{RealIp, TrustedProxies};
// Cloudflare: trust Cloudflare's published IP ranges (two shown here; list them all).
let cloudflare = RealIp::header(
"cf-connecting-ip",
TrustedProxies::new(["173.245.48.0/20", "2400:cb00::/32"])?,
)?;
// nginx with `proxy_set_header X-Real-IP $remote_addr;`
let nginx = RealIp::header("x-real-ip", TrustedProxies::new(["10.0.0.2"])?)?;
// Other CDNs work the same way: their header name, and their published ranges in place of
// this placeholder.
let other_cdn = RealIp::header("true-client-ip", TrustedProxies::new(["192.0.2.0/24"])?)?;
Header names must be lowercase. forwarded is rejected here, because its format is different;
use RealIp::forwarded for it.
RealIp groups IPv6 clients by /64 like PeerIp, and accepts the same
.ipv6_prefix_len(..).
When the address cannot be read
A missing peer address (rare outside tests) or a malformed trusted chain is a key error. By
default the request gets 400 Bad Request. Choose another policy with on_key_error; see
Key errors.
Never limit some addresses
allow_keys forwards matching clients without counting them. Entries are compared with the
extracted key, so IPv6 entries use the masked form:
use actix_trypema::{Local, TrypemaLimiter, extract::PeerIp};
use trypema::{RateLimit, WindowSize};
let limiter = TrypemaLimiter::builder(Local::new(WindowSize::seconds_or_panic(60))?)
.namespace("ip")
.extractor(PeerIp::default())
.rate(RateLimit::per_minute_or_panic(60.0))
.allow_keys(["203.0.113.10", "2001:db8:1:2::/64"]) // office IPv4 and IPv6 network
.build()?;
Testing client IP resolution
actix's test helpers can set both the peer and the headers, which makes the trust rules easy to check in your own tests:
use std::net::SocketAddr;
use actix_trypema::{Local, TrypemaLimiter, extract::{RealIp, TrustedProxies}};
use actix_web::{App, http::StatusCode, test, web};
use trypema::{RateLimit, WindowSize};
#[actix_web::test]
async fn forwarded_clients_get_their_own_limit() {
let limiter = TrypemaLimiter::builder(Local::new(WindowSize::seconds_or_panic(60)).unwrap())
.namespace("ip")
.extractor(RealIp::xff(TrustedProxies::new_or_panic(["10.0.0.0/16"])))
.rate(RateLimit::per_minute_or_panic(1.0))
.build()
.unwrap();
let app = test::init_service(
App::new().wrap(limiter).route("/", web::get().to(|| async { "ok" })),
)
.await;
let from = |client: &str| {
test::TestRequest::get()
.peer_addr("10.0.0.5:4000".parse::<SocketAddr>().unwrap())
.insert_header(("x-forwarded-for", client.to_string()))
.to_request()
};
// Two clients behind the same load balancer: each gets its own one request per minute.
assert_eq!(test::call_service(&app, from("198.51.100.4")).await.status(), StatusCode::OK);
assert_eq!(test::call_service(&app, from("198.51.100.5")).await.status(), StatusCode::OK);
assert_eq!(
test::call_service(&app, from("198.51.100.4")).await.status(),
StatusCode::TOO_MANY_REQUESTS
);
}

