From f9fa2de20ca4f32b8c21cf5130c5e5a2fed73076 Mon Sep 17 00:00:00 2001 From: Karanjadavi Date: Sat, 27 Jun 2026 01:08:29 +0300 Subject: [PATCH 1/3] feat: implement rate limiting and security middleware (#819) - Add RateLimitStore and RateLimitConfig with per-IP tracking via DashMap - Implement rate_limit_middleware returning 429 on limit exceeded - Add security header layers: HSTS, CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy - Replace loose CORS (Any) with strict origin/method/header config - Wire all middleware layers into create_router in api.rs - Add 5 tests covering: within-limit, 429 trigger, window reset, heavy traffic, per-IP independence --- backend/Cargo.lock | 15 ++++ backend/Cargo.toml | 4 +- backend/src/api.rs | 31 ++++++- backend/src/lib.rs | 1 + backend/src/middleware.rs | 130 ++++++++++++++++++++++++++++++ backend/tests/middleware_tests.rs | 121 +++++++++++++++++++++++++++ 6 files changed, 297 insertions(+), 5 deletions(-) create mode 100644 backend/src/middleware.rs create mode 100644 backend/tests/middleware_tests.rs diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 187f9e58f..92ace7912 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -404,6 +404,20 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "data-encoding" version = "2.11.0" @@ -994,6 +1008,7 @@ dependencies = [ "axum", "base64 0.21.7", "chrono", + "dashmap", "dotenvy", "ed25519-dalek", "hex", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 9e8158061..e70c0be08 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -7,7 +7,7 @@ edition = "2021" tokio = { version = "1.0", features = ["full"] } axum = { version = "0.8", features = ["json", "multipart", "macros", "ws"] } tower = { version = "0.5", features = ["util"] } -tower-http = { version = "0.6", features = ["cors", "trace", "request-id", "util"] } +tower-http = { version = "0.6", features = ["cors", "trace", "request-id", "util", "set-header"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" chrono = { version = "0.4", features = ["serde"] } @@ -28,3 +28,5 @@ jsonwebtoken = "9.0" base64 = "0.21" stellar-strkey = "0.0.8" ed25519-dalek = { version = "2.1", features = ["pkcs8", "rand_core"] } + +dashmap = "6" diff --git a/backend/src/api.rs b/backend/src/api.rs index 271765326..65b41b740 100644 --- a/backend/src/api.rs +++ b/backend/src/api.rs @@ -9,7 +9,12 @@ use axum::{ use rust_decimal::Decimal; use serde::{Deserialize, Serialize}; use std::sync::Arc; -use tower_http::cors::{Any, CorsLayer}; +use tower_http::cors::CorsLayer; +use axum::http::{Method, HeaderValue}; +use crate::middleware::{ + rate_limit_middleware, RateLimitConfig, RateLimitStore, + hsts_layer, csp_layer, x_frame_options_layer, x_content_type_options_layer, referrer_policy_layer, +}; use crate::auth::signature_auth_middleware; use crate::kyc_webhook::kyc_webhook_handler; @@ -80,10 +85,19 @@ pub struct AnchorQuery { } pub fn create_router(state: Arc) -> Router { + // Strict CORS: only allow specific origins, methods, and headers let cors = CorsLayer::new() - .allow_origin(Any) - .allow_methods(Any) - .allow_headers(Any); + .allow_origin("https://inheritx.vercel.app".parse::().unwrap()) + .allow_methods([Method::GET, Method::POST, Method::OPTIONS]) + .allow_headers([ + axum::http::header::CONTENT_TYPE, + axum::http::header::AUTHORIZATION, + ]) + .max_age(std::time::Duration::from_secs(3600)); + + // Rate limiter: 100 requests per IP per 60 seconds + let store = RateLimitStore::new(); + let config = Arc::new(RateLimitConfig::default()); // User routes requiring signature verification let user_routes = Router::new() @@ -102,6 +116,14 @@ pub fn create_router(state: Arc) -> Router { Router::new() .merge(user_routes) .merge(public_routes) + .layer(axum::middleware::from_fn(move |req, next| { + rate_limit_middleware(req, next, store.clone(), config.clone()) + })) + .layer(referrer_policy_layer()) + .layer(x_content_type_options_layer()) + .layer(x_frame_options_layer()) + .layer(csp_layer()) + .layer(hsts_layer()) .layer(cors) .with_state(state) } @@ -702,3 +724,4 @@ async fn get_anchor_payouts( let empty_list: Vec = Vec::new(); (StatusCode::OK, Json(empty_list)) } + diff --git a/backend/src/lib.rs b/backend/src/lib.rs index a2c0fca19..c3cac8c21 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -4,6 +4,7 @@ pub mod config; pub mod db; pub mod inactivity_watchdog; pub mod kyc_webhook; +pub mod middleware; pub mod stellar_anchor; pub mod telemetry; pub mod ws; diff --git a/backend/src/middleware.rs b/backend/src/middleware.rs new file mode 100644 index 000000000..0911a1c5d --- /dev/null +++ b/backend/src/middleware.rs @@ -0,0 +1,130 @@ +/// Rate limiting and security-header middleware for InheritX. +use std::{ + net::IpAddr, + sync::Arc, + time::{Duration, Instant}, +}; + +use axum::{ + body::Body, + extract::ConnectInfo, + http::{HeaderValue, Request, Response, StatusCode}, + middleware::Next, + response::IntoResponse, +}; +use dashmap::DashMap; + +/// Configuration knobs for the rate limiter. +/// Defaults: 100 requests per 60-second window. +#[derive(Debug, Clone)] +pub struct RateLimitConfig { + pub max_requests: u64, + pub window: Duration, +} + +impl Default for RateLimitConfig { + fn default() -> Self { + Self { + max_requests: 100, + window: Duration::from_secs(60), + } + } +} + +#[derive(Debug)] +struct RateLimitState { + count: u64, + window_start: Instant, +} + +/// Thread-safe store of per-IP rate-limit state. +#[derive(Clone, Default)] +pub struct RateLimitStore(Arc>); + +impl RateLimitStore { + pub fn new() -> Self { + Self(Arc::new(DashMap::new())) + } + + /// Returns true when the request is within the allowed rate. + /// Returns false when the caller should respond with 429. + pub fn check_and_increment(&self, ip: IpAddr, cfg: &RateLimitConfig) -> bool { + let now = Instant::now(); + let mut entry = self.0.entry(ip).or_insert_with(|| RateLimitState { + count: 0, + window_start: now, + }); + + if now.duration_since(entry.window_start) >= cfg.window { + entry.count = 0; + entry.window_start = now; + } + + entry.count += 1; + entry.count <= cfg.max_requests + } +} + +/// Axum middleware function for rate limiting. +pub async fn rate_limit_middleware( + req: Request, + next: Next, + store: RateLimitStore, + config: Arc, +) -> Response { + let ip = req + .extensions() + .get::>() + .map(|ci| ci.0.ip()) + .unwrap_or(IpAddr::from([127, 0, 0, 1])); + + if !store.check_and_increment(ip, &config) { + return ( + StatusCode::TOO_MANY_REQUESTS, + "Too Many Requests - rate limit exceeded. Please slow down.", + ) + .into_response(); + } + + next.run(req).await +} + +/// HSTS layer: max-age=1 year, includeSubDomains, preload. +pub fn hsts_layer() -> tower_http::set_header::SetResponseHeaderLayer { + tower_http::set_header::SetResponseHeaderLayer::if_not_present( + axum::http::header::STRICT_TRANSPORT_SECURITY, + HeaderValue::from_static("max-age=31536000; includeSubDomains; preload"), + ) +} + +/// Content-Security-Policy layer. +pub fn csp_layer() -> tower_http::set_header::SetResponseHeaderLayer { + tower_http::set_header::SetResponseHeaderLayer::if_not_present( + axum::http::header::CONTENT_SECURITY_POLICY, + HeaderValue::from_static("default-src 'self'; frame-ancestors 'none'"), + ) +} + +/// X-Frame-Options: DENY layer. +pub fn x_frame_options_layer() -> tower_http::set_header::SetResponseHeaderLayer { + tower_http::set_header::SetResponseHeaderLayer::if_not_present( + axum::http::header::X_FRAME_OPTIONS, + HeaderValue::from_static("DENY"), + ) +} + +/// X-Content-Type-Options: nosniff layer. +pub fn x_content_type_options_layer() -> tower_http::set_header::SetResponseHeaderLayer { + tower_http::set_header::SetResponseHeaderLayer::if_not_present( + axum::http::header::X_CONTENT_TYPE_OPTIONS, + HeaderValue::from_static("nosniff"), + ) +} + +/// Referrer-Policy layer. +pub fn referrer_policy_layer() -> tower_http::set_header::SetResponseHeaderLayer { + tower_http::set_header::SetResponseHeaderLayer::if_not_present( + axum::http::header::REFERRER_POLICY, + HeaderValue::from_static("strict-origin-when-cross-origin"), + ) +} diff --git a/backend/tests/middleware_tests.rs b/backend/tests/middleware_tests.rs new file mode 100644 index 000000000..449dfc915 --- /dev/null +++ b/backend/tests/middleware_tests.rs @@ -0,0 +1,121 @@ +use axum::{ + body::Body, + http::{Request, StatusCode}, + routing::get, + Router, +}; +use inheritx_backend::middleware::{RateLimitConfig, RateLimitStore, rate_limit_middleware}; +use std::sync::Arc; +use std::time::Duration; +use tower::ServiceExt; + +fn build_rate_limited_app(max_requests: u64, window_secs: u64) -> Router { + let store = RateLimitStore::new(); + let config = Arc::new(RateLimitConfig { + max_requests, + window: Duration::from_secs(window_secs), + }); + + Router::new() + .route("/test", get(|| async { "ok" })) + .layer(axum::middleware::from_fn(move |req, next| { + rate_limit_middleware(req, next, store.clone(), config.clone()) + })) +} + +#[tokio::test] +async fn test_requests_within_limit_succeed() { + let app = build_rate_limited_app(5, 60); + + for _ in 0..5 { + let response = app + .clone() + .oneshot(Request::builder().uri("/test").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + } +} + +#[tokio::test] +async fn test_request_exceeding_limit_returns_429() { + let app = build_rate_limited_app(3, 60); + + for _ in 0..3 { + app.clone() + .oneshot(Request::builder().uri("/test").body(Body::empty()).unwrap()) + .await + .unwrap(); + } + + // 4th request should be rate limited + let response = app + .clone() + .oneshot(Request::builder().uri("/test").body(Body::empty()).unwrap()) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS); +} + +#[tokio::test] +async fn test_rate_limit_window_resets() { + let store = RateLimitStore::new(); + let config = RateLimitConfig { + max_requests: 2, + window: Duration::from_millis(100), + }; + + let ip: std::net::IpAddr = "127.0.0.1".parse().unwrap(); + + // Use up the limit + assert!(store.check_and_increment(ip, &config)); + assert!(store.check_and_increment(ip, &config)); + // 3rd should fail + assert!(!store.check_and_increment(ip, &config)); + + // Wait for window to expire + tokio::time::sleep(Duration::from_millis(150)).await; + + // Should be allowed again after window reset + assert!(store.check_and_increment(ip, &config)); +} + +#[tokio::test] +async fn test_heavy_mock_traffic_triggers_rate_limit() { + let app = build_rate_limited_app(10, 60); + let mut limited_count = 0; + + for _ in 0..30 { + let response = app + .clone() + .oneshot(Request::builder().uri("/test").body(Body::empty()).unwrap()) + .await + .unwrap(); + if response.status() == StatusCode::TOO_MANY_REQUESTS { + limited_count += 1; + } + } + + // At least 20 requests should have been rate limited + assert!(limited_count >= 20, "Expected at least 20 limited, got {}", limited_count); +} + +#[tokio::test] +async fn test_different_ips_have_independent_limits() { + let store = RateLimitStore::new(); + let config = RateLimitConfig { + max_requests: 1, + window: Duration::from_secs(60), + }; + + let ip1: std::net::IpAddr = "192.168.1.1".parse().unwrap(); + let ip2: std::net::IpAddr = "192.168.1.2".parse().unwrap(); + + // IP1 uses its limit + assert!(store.check_and_increment(ip1, &config)); + assert!(!store.check_and_increment(ip1, &config)); + + // IP2 should still be allowed independently + assert!(store.check_and_increment(ip2, &config)); +} From 02e8891a8493109d7f3f441983b1c384c3a5e449 Mon Sep 17 00:00:00 2001 From: Karanjadavi Date: Tue, 30 Jun 2026 11:44:56 +0300 Subject: [PATCH 2/3] fix: apply cargo fmt formatting --- backend/src/api.rs | 17 ++++++++++------- backend/src/lib.rs | 2 +- backend/src/middleware.rs | 5 +++-- backend/tests/middleware_tests.rs | 10 +++++++--- 4 files changed, 21 insertions(+), 13 deletions(-) diff --git a/backend/src/api.rs b/backend/src/api.rs index c192523aa..4f2adb636 100644 --- a/backend/src/api.rs +++ b/backend/src/api.rs @@ -1,3 +1,8 @@ +use crate::middleware::{ + csp_layer, hsts_layer, rate_limit_middleware, referrer_policy_layer, + x_content_type_options_layer, x_frame_options_layer, RateLimitConfig, RateLimitStore, +}; +use axum::http::{HeaderValue, Method}; use axum::{ extract::{Query, State}, http::StatusCode, @@ -12,11 +17,6 @@ use rust_decimal::Decimal; use serde::{Deserialize, Serialize}; use std::sync::Arc; use tower_http::cors::CorsLayer; -use axum::http::{Method, HeaderValue}; -use crate::middleware::{ - rate_limit_middleware, RateLimitConfig, RateLimitStore, - hsts_layer, csp_layer, x_frame_options_layer, x_content_type_options_layer, referrer_policy_layer, -}; use tower_http::cors::{Any, CorsLayer}; use tracing::error; use uuid::Uuid; @@ -118,7 +118,11 @@ struct ApiError { pub fn create_router(state: Arc) -> Router { // Strict CORS: only allow specific origins, methods, and headers let cors = CorsLayer::new() - .allow_origin("https://inheritx.vercel.app".parse::().unwrap()) + .allow_origin( + "https://inheritx.vercel.app" + .parse::() + .unwrap(), + ) .allow_methods([Method::GET, Method::POST, Method::OPTIONS]) .allow_headers([ axum::http::header::CONTENT_TYPE, @@ -1372,4 +1376,3 @@ async fn get_kyc_requirements() -> impl IntoResponse { (StatusCode::OK, Json(response)) } - diff --git a/backend/src/lib.rs b/backend/src/lib.rs index a8ea0bfb1..fe94c1740 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -5,8 +5,8 @@ pub mod config; pub mod db; pub mod inactivity_watchdog; pub mod kyc_webhook; -pub mod middleware; pub mod metrics; +pub mod middleware; pub mod stellar_anchor; pub mod telemetry; pub mod ws; diff --git a/backend/src/middleware.rs b/backend/src/middleware.rs index 0911a1c5d..a11568370 100644 --- a/backend/src/middleware.rs +++ b/backend/src/middleware.rs @@ -1,4 +1,4 @@ -/// Rate limiting and security-header middleware for InheritX. +/// Rate limiting and security-header middleware for InheritX. use std::{ net::IpAddr, sync::Arc, @@ -114,7 +114,8 @@ pub fn x_frame_options_layer() -> tower_http::set_header::SetResponseHeaderLayer } /// X-Content-Type-Options: nosniff layer. -pub fn x_content_type_options_layer() -> tower_http::set_header::SetResponseHeaderLayer { +pub fn x_content_type_options_layer() -> tower_http::set_header::SetResponseHeaderLayer +{ tower_http::set_header::SetResponseHeaderLayer::if_not_present( axum::http::header::X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff"), diff --git a/backend/tests/middleware_tests.rs b/backend/tests/middleware_tests.rs index 449dfc915..c4fcb794c 100644 --- a/backend/tests/middleware_tests.rs +++ b/backend/tests/middleware_tests.rs @@ -1,10 +1,10 @@ -use axum::{ +use axum::{ body::Body, http::{Request, StatusCode}, routing::get, Router, }; -use inheritx_backend::middleware::{RateLimitConfig, RateLimitStore, rate_limit_middleware}; +use inheritx_backend::middleware::{rate_limit_middleware, RateLimitConfig, RateLimitStore}; use std::sync::Arc; use std::time::Duration; use tower::ServiceExt; @@ -98,7 +98,11 @@ async fn test_heavy_mock_traffic_triggers_rate_limit() { } // At least 20 requests should have been rate limited - assert!(limited_count >= 20, "Expected at least 20 limited, got {}", limited_count); + assert!( + limited_count >= 20, + "Expected at least 20 limited, got {}", + limited_count + ); } #[tokio::test] From c44e72a6395f7801ac0c1235733d2621076b1aff Mon Sep 17 00:00:00 2001 From: Karanjadavi Date: Tue, 30 Jun 2026 19:14:30 +0300 Subject: [PATCH 3/3] fix: resolve duplicate and unused imports in api.rs --- backend/src/api.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/backend/src/api.rs b/backend/src/api.rs index 4f2adb636..554e6ea9e 100644 --- a/backend/src/api.rs +++ b/backend/src/api.rs @@ -5,8 +5,8 @@ use crate::middleware::{ use axum::http::{HeaderValue, Method}; use axum::{ extract::{Query, State}, + http::header::HeaderName, http::StatusCode, - http::{header::HeaderName, HeaderValue}, middleware::from_fn, response::IntoResponse, routing::{get, post}, @@ -17,7 +17,6 @@ use rust_decimal::Decimal; use serde::{Deserialize, Serialize}; use std::sync::Arc; use tower_http::cors::CorsLayer; -use tower_http::cors::{Any, CorsLayer}; use tracing::error; use uuid::Uuid;