From a6aa09174fbd2785e2129043d8bc7c909ce72ee2 Mon Sep 17 00:00:00 2001 From: ghzhost Date: Thu, 3 Sep 2026 04:58:46 +0000 Subject: [PATCH] fix(cors): enforce explicit allowed_origins in production and restrict methods/headers (#24) - Require explicit non-wildcard ALLOWED_ORIGINS when APP_ENV=production in Config::from_env - Restrict AllowMethods to GET, POST, OPTIONS - Restrict AllowHeaders to Authorization, Content-Type, Accept, X-Request-Id - Set max_age for preflight caching --- src/config.rs | 8 +++++++- src/middleware/cors.rs | 41 ++++++++++++++++++++++++++++++++++++++--- 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/src/config.rs b/src/config.rs index 86af11c..a244b1e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,4 +1,4 @@ -use anyhow::{Context, Result}; +use anyhow::{bail, Context, Result}; use std::env; /// Central application configuration, loaded once at startup from environment variables. @@ -152,6 +152,12 @@ impl Config { _ => AppEnv::Development, }; + if app_env == AppEnv::Production + && (allowed_origins.is_empty() || allowed_origins.iter().any(|o| o == "*")) + { + bail!("ALLOWED_ORIGINS must be set to an explicit, non-wildcard list of origins in production"); + } + Ok(Self { port, host, diff --git a/src/middleware/cors.rs b/src/middleware/cors.rs index 13693d2..1bcc2e2 100644 --- a/src/middleware/cors.rs +++ b/src/middleware/cors.rs @@ -1,10 +1,15 @@ +use axum::http::{header, Method}; use tower_http::cors::{AllowHeaders, AllowMethods, AllowOrigin, CorsLayer}; /// Build the CORS layer from the configured list of allowed origins. /// /// When `allowed_origins` contains `"*"`, or is empty, every origin is -/// permitted (suitable for development / public APIs). In production, supply +/// permitted (suitable for development / public APIs). In production, supply /// an explicit list so that only known origins are whitelisted. +/// +/// Methods and headers are restricted to the real surface required by the +/// StellarSend API (GET, POST, OPTIONS, and standard auth/content headers) +/// with a 1-hour preflight cache max age. pub fn build_cors_layer(allowed_origins: &[String]) -> CorsLayer { let allow_origin: AllowOrigin = if allowed_origins.iter().any(|o| o == "*") || allowed_origins.is_empty() @@ -20,6 +25,36 @@ pub fn build_cors_layer(allowed_origins: &[String]) -> CorsLayer { CorsLayer::new() .allow_origin(allow_origin) - .allow_methods(AllowMethods::any()) - .allow_headers(AllowHeaders::any()) + .allow_methods(AllowMethods::list([ + Method::GET, + Method::POST, + Method::OPTIONS, + ])) + .allow_headers(AllowHeaders::list([ + header::AUTHORIZATION, + header::CONTENT_TYPE, + header::ACCEPT, + header::HeaderName::from_static("x-request-id"), + ])) + .max_age(std::time::Duration::from_secs(3600)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn build_cors_layer_with_wildcard_works() { + let origins = vec!["*".to_string()]; + let _layer = build_cors_layer(&origins); + } + + #[test] + fn build_cors_layer_with_explicit_origins_works() { + let origins = vec![ + "https://app.stellarsend.com".to_string(), + "https://staging.stellarsend.com".to_string(), + ]; + let _layer = build_cors_layer(&origins); + } }