feat: router middleware (CORS, body limit, timeout, compression, tracing)

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.
This commit is contained in:
jwinterm
2026-06-23 18:25:10 -04:00
parent 586f681287
commit 707613c92b
4 changed files with 65 additions and 3 deletions
+12 -2
View File
@@ -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<Arc<AppState>> {
.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))
}
+6
View File
@@ -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<IpNetwork>,
/// 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<String>,
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,
+46
View File
@@ -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<Arc<AppState>>`.
#[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<AppState>) -> 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<AppState>) -> 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<HeaderValue> = config
.cors_allowed_origins
.iter()
.filter_map(|o| o.parse().ok())
.collect();
base.allow_origin(origins)
}
}