Actix Web

Client IPs

Key limits by client IP, directly or behind load balancers, proxies and CDNs, without letting clients spoof their address.

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 peerX-Forwarded-ForKeyWhy
203.0.113.7anything203.0.113.7The peer is not trusted, so the header is ignored.
10.0.0.5198.51.100.4198.51.100.4The first untrusted hop.
10.0.0.51.2.3.4, 198.51.100.4198.51.100.41.2.3.4 was written by the client and is never parsed.
10.0.0.5198.51.100.4, 10.0.0.9198.51.100.4The trusted hop 10.0.0.9 is skipped.
10.0.0.5absent10.0.0.5No header: the peer itself is the client.
10.0.0.510.0.0.910.0.0.9Every hop is trusted: the leftmost one is used.
10.0.0.5198.51.100.4:5123198.51.100.4Ports are accepted and dropped.
10.0.0.5garbagekey errorA 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
    );
}