mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 07:21:32 +02:00
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:
+36
-2
@@ -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
@@ -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
@@ -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}"));
|
||||
}
|
||||
|
||||
@@ -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)"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user