use crate::config::{Environment, SecurityConfig};
use axum::{Router, http};
use std::time::Duration;
use tower_http::cors::CorsLayer;
pub fn apply_security_baseline_with_machine_endpoints(
app: Router,
security: SecurityConfig,
environment: Environment,
policy: super::MachineEndpointPolicy,
) -> Result<Router, SecurityBaselineError> {
let app = if environment.requires_secure_defaults() {
app
} else {
app.layer(axum::middleware::from_fn(
super::machine::authenticate_machine_request,
))
};
Ok(apply_security_baseline(app, security, environment)?.layer(axum::Extension(policy)))
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
#[non_exhaustive]
pub enum SecurityBaselineError {
#[error("invalid CORS origin header value `{0}`")]
InvalidCorsOrigin(String),
#[error("invalid security baseline configuration: {0}")]
InvalidConfiguration(String),
}
pub fn apply_security_baseline(
mut app: Router,
security: SecurityConfig,
environment: Environment,
) -> Result<Router, SecurityBaselineError> {
security
.validate()
.map_err(|error| SecurityBaselineError::InvalidConfiguration(error.to_string()))?;
if environment.requires_secure_defaults() {
if security.enable_pii_masking {
app = app.layer(axum::middleware::from_fn(
crate::security::pii_masking_middleware,
));
}
app = app
.layer(axum::middleware::from_fn(crate::security::csrf_middleware))
.layer(axum::middleware::from_fn(crate::security::waf_middleware));
}
if !security.cors_allow_origins.is_empty() {
let origins = security
.cors_allow_origins
.iter()
.map(|origin| {
origin
.parse::<http::HeaderValue>()
.map_err(|_| SecurityBaselineError::InvalidCorsOrigin(origin.clone()))
})
.collect::<Result<Vec<_>, _>>()?;
app = app.layer(
CorsLayer::new()
.allow_origin(origins)
.allow_methods([
http::Method::GET,
http::Method::HEAD,
http::Method::POST,
http::Method::PUT,
http::Method::PATCH,
http::Method::DELETE,
])
.allow_headers([
http::header::ACCEPT,
http::header::AUTHORIZATION,
http::header::CONTENT_TYPE,
http::HeaderName::from_static("x-csrf-token"),
])
.allow_credentials(security.cors_allow_credentials)
.max_age(Duration::from_secs(600)),
);
}
if environment.requires_secure_defaults() {
app = app.layer(axum::middleware::from_fn(
crate::security::headers_middleware,
));
}
Ok(app
.layer(axum::Extension(security))
.layer(axum::Extension(environment)))
}
#[cfg(test)]
#[allow(
clippy::expect_used,
clippy::unwrap_used,
clippy::field_reassign_with_default
)]
mod tests {
use super::*;
use crate::security::{CspNonce, CsrfToken};
use axum::{
body::{Body, to_bytes},
extract::Extension,
http::{Request, StatusCode, header},
routing::{get, post},
};
use tower::ServiceExt;
fn production_app() -> Router {
let mut security = SecurityConfig::default();
security.csrf_same_site = "Strict".to_string();
security.cors_allow_origins = vec!["https://academy.example".to_string()];
security.cors_allow_credentials = true;
let app = Router::new()
.route(
"/",
get(
|Extension(nonce): Extension<CspNonce>,
Extension(csrf): Extension<CsrfToken>,
Extension(config): Extension<SecurityConfig>| async move {
format!(
"{}|{}|{}",
nonce.as_str(),
csrf.as_str(),
config.csrf_same_site
)
},
),
)
.route("/write", post(|| async { StatusCode::NO_CONTENT }));
apply_security_baseline(app, security, Environment::Production)
.expect("production security baseline")
}
fn browser_request(method: http::Method, path: &str) -> http::request::Builder {
Request::builder()
.method(method)
.uri(path)
.header(header::USER_AGENT, "Mozilla/5.0")
.header(header::ORIGIN, "https://academy.example")
}
#[tokio::test]
async fn production_baseline_composes_nonce_cors_csrf_and_headers() {
let app = production_app();
let response = app
.clone()
.oneshot(
browser_request(http::Method::GET, "/")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response.headers().get(header::ACCESS_CONTROL_ALLOW_ORIGIN),
Some(&http::HeaderValue::from_static("https://academy.example")),
);
assert_eq!(
response
.headers()
.get(header::ACCESS_CONTROL_ALLOW_CREDENTIALS),
Some(&http::HeaderValue::from_static("true")),
);
assert_eq!(
response.headers().get(header::X_FRAME_OPTIONS),
Some(&http::HeaderValue::from_static("DENY")),
);
let csp = response
.headers()
.get(header::CONTENT_SECURITY_POLICY)
.expect("CSP header")
.to_str()
.expect("CSP text")
.to_string();
let cookie = response
.headers()
.get(header::SET_COOKIE)
.expect("CSRF cookie")
.to_str()
.expect("CSRF cookie text")
.to_string();
let body = to_bytes(response.into_body(), 1024).await.unwrap();
let body = std::str::from_utf8(&body).expect("baseline response text");
let mut fields = body.split('|');
let nonce = fields.next().expect("nonce field");
let csrf = fields.next().expect("CSRF field");
assert_eq!(fields.next(), Some("Strict"));
assert!(csp.contains(&format!("'nonce-{nonce}'")));
assert!(cookie.starts_with(&format!("rullst_csrf={csrf};")));
assert!(cookie.contains("SameSite=Strict"));
assert!(cookie.contains("; Secure"));
let denied = app
.clone()
.oneshot(
browser_request(http::Method::POST, "/write")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(denied.status(), StatusCode::FORBIDDEN);
assert!(
denied
.headers()
.contains_key(header::CONTENT_SECURITY_POLICY)
);
let accepted = app
.oneshot(
browser_request(http::Method::POST, "/write")
.header(header::COOKIE, format!("rullst_csrf={csrf}"))
.header("x-csrf-token", csrf)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(accepted.status(), StatusCode::NO_CONTENT);
}
#[tokio::test]
async fn cors_preflight_is_exact_and_unlisted_origins_receive_no_grant() {
let app = production_app();
let preflight = app
.clone()
.oneshot(
browser_request(http::Method::OPTIONS, "/write")
.header(header::ACCESS_CONTROL_REQUEST_METHOD, "POST")
.header(header::ACCESS_CONTROL_REQUEST_HEADERS, "x-csrf-token")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(preflight.status(), StatusCode::OK);
assert_eq!(
preflight.headers().get(header::ACCESS_CONTROL_ALLOW_ORIGIN),
Some(&http::HeaderValue::from_static("https://academy.example")),
);
let unlisted = app
.oneshot(
Request::builder()
.uri("/")
.header(header::USER_AGENT, "Mozilla/5.0")
.header(header::ORIGIN, "https://evil.example")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(unlisted.status(), StatusCode::OK);
assert!(
!unlisted
.headers()
.contains_key(header::ACCESS_CONTROL_ALLOW_ORIGIN)
);
}
}