feat: per-IP rate limiting (tower_governor tiers) + ConnectInfo wiring

Add tiered per-IP rate limiting to the router, completing the assembly:
- STRICT tier (~2 req/s, burst 20) on the unauthenticated, abuse-prone
  surface: /api/v1/auth/* and /api/v1/website/*. This closes the
  website-challenge growth vector (the prior rate-limit LOW): an unauthenticated
  flood of challenge requests is now throttled per IP.
- NORMAL tier (~10 req/s, burst 60) on the authenticated wallet/identity API.
- PeerIpKeyExtractor keys on the peer IP; `main` now serves with
  into_make_service_with_connect_info::<SocketAddr> so the IP is available, and
  the test harness injects a loopback ConnectInfo (each test builds a fresh
  router with fresh limiter state).

Over-limit returns 429. The limiter sits inside the global layers (CORS/timeout/
body/compression/trace) and in front of the handlers.

L1 test proves it throttles: a 40-request burst to a strict-tier route returns
429 (the governor runs before the handler, so even unauthenticated requests
count). All 33 integration tests + 114 lib tests pass through the fully-layered
router; openapi unchanged.
This commit is contained in:
jwinterm
2026-06-23 19:02:03 -04:00
parent 707613c92b
commit c558e9d7bd
4 changed files with 81 additions and 4 deletions
+36 -2
View File
@@ -23,6 +23,7 @@ use axum::extract::DefaultBodyLimit;
use axum::http::{header, HeaderValue, Method};
use axum::{routing::get, Router};
use tokio::sync::RwLock;
use tower_governor::{governor::GovernorConfigBuilder, GovernorLayer};
use tower_http::compression::CompressionLayer;
use tower_http::cors::{Any, CorsLayer};
use tower_http::timeout::TimeoutLayer;
@@ -39,6 +40,15 @@ const MAX_BODY_BYTES: usize = 1024 * 1024;
/// so their errors surface first for chain calls; bounds any slow handler).
const REQUEST_TIMEOUT: Duration = Duration::from_secs(60);
// Per-IP rate-limit tiers (one token replenished per period; `burst` = bucket).
// STRICT throttles the unauthenticated, abuse-prone surface (auth + website);
// NORMAL covers the authenticated wallet/identity API. Sane defaults; operator-
// tunable config can follow.
const STRICT_PERIOD_MS: u64 = 500; // ~2 req/s sustained
const STRICT_BURST: u32 = 20;
const NORMAL_PERIOD_MS: u64 = 100; // ~10 req/s sustained
const NORMAL_BURST: u32 = 60;
/// Shared application state injected into handlers via `State<Arc<AppState>>`.
#[derive(Clone)]
pub struct AppState {
@@ -59,10 +69,34 @@ pub struct AppState {
pub fn build_router(state: Arc<AppState>) -> Router {
let cors = cors_layer(&state.config);
// Per-IP rate limiting (PeerIpKeyExtractor reads ConnectInfo — `main` serves
// with `into_make_service_with_connect_info`; the test harness injects it).
// Tiered: stricter on the unauthenticated auth/website surface (closes the
// website-challenge growth vector), looser on the wallet/identity API.
let mut strict_cfg = GovernorConfigBuilder::default();
strict_cfg
.per_millisecond(STRICT_PERIOD_MS)
.burst_size(STRICT_BURST);
let strict = GovernorLayer {
config: Arc::new(strict_cfg.finish().expect("valid strict governor config")),
};
let mut normal_cfg = GovernorConfigBuilder::default();
normal_cfg
.per_millisecond(NORMAL_PERIOD_MS)
.burst_size(NORMAL_BURST);
let normal = GovernorLayer {
config: Arc::new(normal_cfg.finish().expect("valid normal governor config")),
};
let api_v1 = api::auth::routes()
.merge(api::website::routes())
.merge(api::users::routes())
.merge(api::wallet::routes());
.layer(strict)
.merge(
api::users::routes()
.merge(api::wallet::routes())
.layer(normal),
);
Router::new()
.route("/health", get(api::health::health))
+6 -1
View File
@@ -80,6 +80,11 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let listener = tokio::net::TcpListener::bind(&addr).await?;
tracing::info!("smirk-backend-core listening on http://{addr}");
axum::serve(listener, app).await?;
// ConnectInfo carries the peer IP that the per-IP rate limiter keys on.
axum::serve(
listener,
app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
)
.await?;
Ok(())
}
+12 -1
View File
@@ -112,7 +112,18 @@ impl TestApp {
token: Option<&str>,
body: Option<Value>,
) -> (StatusCode, Value) {
let mut builder = Request::builder().method(method).uri(uri);
// The per-IP rate limiter keys on ConnectInfo; in production `main`
// supplies it via into_make_service_with_connect_info. oneshot requests
// don't, so inject a loopback peer (each test builds a fresh router with
// fresh limiter state, so this never trips across tests).
let mut builder =
Request::builder()
.method(method)
.uri(uri)
.extension(axum::extract::ConnectInfo(std::net::SocketAddr::from((
[127, 0, 0, 1],
0,
))));
if let Some(t) = token {
builder = builder.header("authorization", format!("Bearer {t}"));
}
+27
View File
@@ -0,0 +1,27 @@
//! L1 integration: the per-IP rate limiter actually throttles a burst.
//!
//! The strict tier wraps the unauthenticated auth/website surface. The governor
//! runs before the handler, so an unauthenticated burst to a strict-tier route
//! still counts — past the burst size it must return 429. (All requests share the
//! injected loopback peer, so they hit one bucket.)
mod common;
use axum::http::StatusCode;
#[tokio::test]
async fn strict_tier_throttles_a_burst() {
let app = require_app!();
let mut saw_429 = false;
for _ in 0..40 {
let (status, _) = app.request("GET", "/api/v1/auth/me", None, None).await;
if status == StatusCode::TOO_MANY_REQUESTS {
saw_429 = true;
break;
}
}
assert!(
saw_429,
"a rapid burst to a strict-tier endpoint should be rate-limited (429)"
);
}