From 707613c92bbc26546eb6da23e36b54fe2c991a1d Mon Sep 17 00:00:00 2001 From: jwinterm Date: Tue, 23 Jun 2026 18:25:10 -0400 Subject: [PATCH] feat: router middleware (CORS, body limit, timeout, compression, tracing) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layer global middleware onto build_router (applies to every route, shared by main + the test harness): - CORS: configurable allowed origins via CORS_ALLOWED_ORIGINS; empty -> allow any origin, which is safe here because auth is a Bearer token (no cookies, no ambient credentials). Credentials are never allowed. - Body limit: 1 MiB global backstop (handlers already validate tighter per field); /wallet/lws/submit_tx overrides to ~2 MiB+ for raw Monero tx hex. - Timeout: 60s request backstop (above the chain clients' ~30s timeouts, so their errors surface first; bounds any slow handler). Uses the http-aware tower_http TimeoutLayer (-> 408), not tower::timeout (fallible, rejected by axum's Infallible Router). - gzip compression + request tracing (outermost). Adds cors_allowed_origins config + the tower-http "timeout" feature. Per-IP rate limiting is deliberately separate (needs ConnectInfo) — next commit. All gates green; integration tests pass through the layered router (CORS/timeout/ compression/body-limit are oneshot-safe). openapi unchanged. --- Cargo.toml | 2 +- src/api/wallet/xmr_wow.rs | 14 ++++++++++-- src/config.rs | 6 +++++ src/lib.rs | 46 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 65 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 643e346..0c0036b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,7 +27,7 @@ tokio = { version = "1.35", features = ["full"] } axum = { version = "0.7", features = ["ws", "macros"] } axum-extra = { version = "0.9", features = ["typed-header"] } tower = { version = "0.4", features = ["limit", "timeout", "load-shed", "util"] } -tower-http = { version = "0.5", features = ["cors", "trace", "compression-gzip"] } +tower-http = { version = "0.5", features = ["cors", "trace", "compression-gzip", "timeout"] } tower_governor = "0.4" governor = "0.6" diff --git a/src/api/wallet/xmr_wow.rs b/src/api/wallet/xmr_wow.rs index 970082f..11f4474 100644 --- a/src/api/wallet/xmr_wow.rs +++ b/src/api/wallet/xmr_wow.rs @@ -16,7 +16,12 @@ use std::sync::Arc; -use axum::{extract::State, http::HeaderMap, routing::post, Json, Router}; +use axum::{ + extract::{DefaultBodyLimit, State}, + http::HeaderMap, + routing::post, + Json, Router, +}; use serde::{Deserialize, Serialize}; use tracing::instrument; @@ -539,7 +544,12 @@ pub fn routes() -> Router> { .route("/wallet/lws/history", post(history)) .route("/wallet/lws/unspent_outs", post(unspent_outs)) .route("/wallet/lws/random_outs", post(random_outs)) - .route("/wallet/lws/submit_tx", post(submit_tx)) + // submit_tx carries a raw tx hex (up to MAX_CN_TX_HEX_LEN); raise its + // body cap above the global limit, with headroom for the JSON envelope. + .route( + "/wallet/lws/submit_tx", + post(submit_tx).layer(DefaultBodyLimit::max(MAX_CN_TX_HEX_LEN + 64 * 1024)), + ) .route("/wallet/lws/height", post(height)) .route("/wallet/lws/confirmations", post(confirmations)) } diff --git a/src/config.rs b/src/config.rs index 6d5ba07..f909ccd 100644 --- a/src/config.rs +++ b/src/config.rs @@ -117,6 +117,10 @@ pub struct Config { /// Networks whose `X-Forwarded-For` is trusted. Empty (default) means the /// real TCP peer is always used for rate-limiting and audit IPs. pub trusted_proxies: Vec, + /// Browser origins allowed by CORS (e.g. the web wallet). Empty (default) + /// allows any origin — safe here because auth is a Bearer token, not cookies, + /// so no ambient credentials ride a cross-origin request. + pub cors_allowed_origins: Vec, pub features: FeatureFlags, pub chains: ChainConfig, @@ -296,6 +300,7 @@ impl Config { ip_salt: env_or("IP_SALT", ""), }, trusted_proxies: parse_networks("TRUSTED_PROXIES")?, + cors_allowed_origins: env_list("CORS_ALLOWED_ORIGINS"), features: FeatureFlags { chains: ChainFlags { @@ -533,6 +538,7 @@ mod tests { ip_salt: "s".repeat(16), }, trusted_proxies: vec![], + cors_allowed_origins: vec![], features: FeatureFlags { chains: ChainFlags { btc: false, diff --git a/src/lib.rs b/src/lib.rs index 4b45905..7189517 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -17,15 +17,28 @@ pub mod models; use std::collections::HashMap; use std::sync::Arc; +use std::time::Duration; +use axum::extract::DefaultBodyLimit; +use axum::http::{header, HeaderValue, Method}; use axum::{routing::get, Router}; use tokio::sync::RwLock; +use tower_http::compression::CompressionLayer; +use tower_http::cors::{Any, CorsLayer}; +use tower_http::timeout::TimeoutLayer; +use tower_http::trace::TraceLayer; use crate::config::Config; use crate::core::session::{SessionManager, WebChallenge}; use crate::infra::chains::ChainClients; use crate::infra::db::Database; +/// Global request-body cap (backstop; handlers validate tighter per field). +const MAX_BODY_BYTES: usize = 1024 * 1024; +/// Global request deadline (backstop above the chain clients' own ~30s timeouts, +/// so their errors surface first for chain calls; bounds any slow handler). +const REQUEST_TIMEOUT: Duration = Duration::from_secs(60); + /// Shared application state injected into handlers via `State>`. #[derive(Clone)] pub struct AppState { @@ -44,6 +57,8 @@ pub struct AppState { /// the exact same wiring: `/health` and the NIP-05 directory at the root, the /// authenticated wallet/identity API nested under `/api/v1`. pub fn build_router(state: Arc) -> Router { + let cors = cors_layer(&state.config); + let api_v1 = api::auth::routes() .merge(api::website::routes()) .merge(api::users::routes()) @@ -54,4 +69,35 @@ pub fn build_router(state: Arc) -> Router { .merge(api::nip05::routes()) .nest("/api/v1", api_v1) .with_state(state) + // Layers apply outside-in (last added = outermost). Body cap + timeout + // sit closest to the handlers; CORS + compression wrap them; tracing is + // outermost so it observes every request (incl. rejected ones). + // Per-IP rate limiting is layered separately (it needs ConnectInfo). + .layer(DefaultBodyLimit::max(MAX_BODY_BYTES)) + .layer(TimeoutLayer::new(REQUEST_TIMEOUT)) + .layer(cors) + .layer(CompressionLayer::new()) + .layer(TraceLayer::new_for_http()) +} + +/// Build the CORS layer. With no configured origins, allow any origin — safe +/// because authentication is a Bearer token (no cookies), so a cross-origin +/// request carries no ambient credentials. Credentials are never allowed. +fn cors_layer(config: &Config) -> CorsLayer { + let methods = [Method::GET, Method::POST, Method::OPTIONS]; + let headers = [header::AUTHORIZATION, header::CONTENT_TYPE]; + let base = CorsLayer::new() + .allow_methods(methods) + .allow_headers(headers); + + if config.cors_allowed_origins.is_empty() { + base.allow_origin(Any) + } else { + let origins: Vec = config + .cors_allowed_origins + .iter() + .filter_map(|o| o.parse().ok()) + .collect(); + base.allow_origin(origins) + } }