Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions backend/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion backend/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ tokio = { version = "1.0", features = ["full"] }
redis = { version = "0.27", features = ["tokio-comp", "connection-manager"] }
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"] }
Expand All @@ -29,5 +29,7 @@ jsonwebtoken = "9.0"
base64 = "0.21"
stellar-strkey = "0.0.8"
ed25519-dalek = { version = "2.1", features = ["pkcs8", "rand_core"] }

dashmap = "6"
prometheus = { version = "0.13", features = ["process"] }
once_cell = "1.19"
36 changes: 31 additions & 5 deletions backend/src/api.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
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::header::HeaderName,
http::StatusCode,
http::{header::HeaderName, HeaderValue},
middleware::from_fn,
response::IntoResponse,
routing::{get, post},
Expand All @@ -11,7 +16,7 @@ use chrono::{DateTime, Utc};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tower_http::cors::{Any, CorsLayer};
use tower_http::cors::CorsLayer;
use tracing::error;
use uuid::Uuid;

Expand Down Expand Up @@ -110,10 +115,23 @@ struct ApiError {
}

pub fn create_router(state: Arc<AppState>) -> 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::<HeaderValue>()
.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()
Expand All @@ -137,6 +155,14 @@ pub fn create_router(state: Arc<AppState>) -> 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())
.route("/metrics", get(metrics_handler))
.layer(from_fn(latency_middleware))
.layer(cors)
Expand Down
1 change: 1 addition & 0 deletions backend/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ pub mod db;
pub mod inactivity_watchdog;
pub mod kyc_webhook;
pub mod metrics;
pub mod middleware;
pub mod stellar_anchor;
pub mod telemetry;
pub mod ws;
Expand Down
131 changes: 131 additions & 0 deletions backend/src/middleware.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/// 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<DashMap<IpAddr, RateLimitState>>);

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<Body>,
next: Next,
store: RateLimitStore,
config: Arc<RateLimitConfig>,
) -> Response<Body> {
let ip = req
.extensions()
.get::<ConnectInfo<std::net::SocketAddr>>()
.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<HeaderValue> {
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<HeaderValue> {
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<HeaderValue> {
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<HeaderValue>
{
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<HeaderValue> {
tower_http::set_header::SetResponseHeaderLayer::if_not_present(
axum::http::header::REFERRER_POLICY,
HeaderValue::from_static("strict-origin-when-cross-origin"),
)
}
125 changes: 125 additions & 0 deletions backend/tests/middleware_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
use axum::{
body::Body,
http::{Request, StatusCode},
routing::get,
Router,
};
use inheritx_backend::middleware::{rate_limit_middleware, RateLimitConfig, RateLimitStore};
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));
}
Loading