1use crate::config::{Environment, SecurityConfig};
4use axum::{Router, http};
5use std::time::Duration;
6use tower_http::cors::CorsLayer;
7
8pub fn apply_security_baseline_with_machine_endpoints(
12 app: Router,
13 security: SecurityConfig,
14 environment: Environment,
15 policy: super::MachineEndpointPolicy,
16) -> Result<Router, SecurityBaselineError> {
17 let app = if environment.requires_secure_defaults() {
18 app
19 } else {
20 app.layer(axum::middleware::from_fn(
21 super::machine::authenticate_machine_request,
22 ))
23 };
24 Ok(apply_security_baseline(app, security, environment)?.layer(axum::Extension(policy)))
25}
26
27#[derive(Debug, thiserror::Error, PartialEq, Eq)]
29#[non_exhaustive]
30pub enum SecurityBaselineError {
31 #[error("invalid CORS origin header value `{0}`")]
33 InvalidCorsOrigin(String),
34 #[error("invalid security baseline configuration: {0}")]
36 InvalidConfiguration(String),
37}
38
39pub fn apply_security_baseline(
48 mut app: Router,
49 security: SecurityConfig,
50 environment: Environment,
51) -> Result<Router, SecurityBaselineError> {
52 security
53 .validate()
54 .map_err(|error| SecurityBaselineError::InvalidConfiguration(error.to_string()))?;
55
56 if environment.requires_secure_defaults() {
57 if security.enable_pii_masking {
58 app = app.layer(axum::middleware::from_fn(
59 crate::security::pii_masking_middleware,
60 ));
61 }
62 app = app
63 .layer(axum::middleware::from_fn(crate::security::csrf_middleware))
64 .layer(axum::middleware::from_fn(crate::security::waf_middleware));
65 }
66
67 if !security.cors_allow_origins.is_empty() {
68 let origins = security
69 .cors_allow_origins
70 .iter()
71 .map(|origin| {
72 origin
73 .parse::<http::HeaderValue>()
74 .map_err(|_| SecurityBaselineError::InvalidCorsOrigin(origin.clone()))
75 })
76 .collect::<Result<Vec<_>, _>>()?;
77 app = app.layer(
78 CorsLayer::new()
79 .allow_origin(origins)
80 .allow_methods([
81 http::Method::GET,
82 http::Method::HEAD,
83 http::Method::POST,
84 http::Method::PUT,
85 http::Method::PATCH,
86 http::Method::DELETE,
87 ])
88 .allow_headers([
89 http::header::ACCEPT,
90 http::header::AUTHORIZATION,
91 http::header::CONTENT_TYPE,
92 http::HeaderName::from_static("x-csrf-token"),
93 ])
94 .allow_credentials(security.cors_allow_credentials)
95 .max_age(Duration::from_secs(600)),
96 );
97 }
98
99 if environment.requires_secure_defaults() {
100 app = app.layer(axum::middleware::from_fn(
101 crate::security::headers_middleware,
102 ));
103 }
104
105 Ok(app
106 .layer(axum::Extension(security))
107 .layer(axum::Extension(environment)))
108}
109
110#[cfg(test)]
111#[allow(
112 clippy::expect_used,
113 clippy::unwrap_used,
114 clippy::field_reassign_with_default
115)]
116mod tests {
117 use super::*;
118 use crate::security::{CspNonce, CsrfToken};
119 use axum::{
120 body::{Body, to_bytes},
121 extract::Extension,
122 http::{Request, StatusCode, header},
123 routing::{get, post},
124 };
125 use tower::ServiceExt;
126
127 fn production_app() -> Router {
128 let mut security = SecurityConfig::default();
129 security.csrf_same_site = "Strict".to_string();
130 security.cors_allow_origins = vec!["https://academy.example".to_string()];
131 security.cors_allow_credentials = true;
132 let app = Router::new()
133 .route(
134 "/",
135 get(
136 |Extension(nonce): Extension<CspNonce>,
137 Extension(csrf): Extension<CsrfToken>,
138 Extension(config): Extension<SecurityConfig>| async move {
139 format!(
140 "{}|{}|{}",
141 nonce.as_str(),
142 csrf.as_str(),
143 config.csrf_same_site
144 )
145 },
146 ),
147 )
148 .route("/write", post(|| async { StatusCode::NO_CONTENT }));
149 apply_security_baseline(app, security, Environment::Production)
150 .expect("production security baseline")
151 }
152
153 fn browser_request(method: http::Method, path: &str) -> http::request::Builder {
154 Request::builder()
155 .method(method)
156 .uri(path)
157 .header(header::USER_AGENT, "Mozilla/5.0")
158 .header(header::ORIGIN, "https://academy.example")
159 }
160
161 #[tokio::test]
162 async fn production_baseline_composes_nonce_cors_csrf_and_headers() {
163 let app = production_app();
166 let response = app
167 .clone()
168 .oneshot(
169 browser_request(http::Method::GET, "/")
170 .body(Body::empty())
171 .unwrap(),
172 )
173 .await
174 .unwrap();
175 assert_eq!(response.status(), StatusCode::OK);
176 assert_eq!(
177 response.headers().get(header::ACCESS_CONTROL_ALLOW_ORIGIN),
178 Some(&http::HeaderValue::from_static("https://academy.example")),
179 );
180 assert_eq!(
181 response
182 .headers()
183 .get(header::ACCESS_CONTROL_ALLOW_CREDENTIALS),
184 Some(&http::HeaderValue::from_static("true")),
185 );
186 assert_eq!(
187 response.headers().get(header::X_FRAME_OPTIONS),
188 Some(&http::HeaderValue::from_static("DENY")),
189 );
190 let csp = response
191 .headers()
192 .get(header::CONTENT_SECURITY_POLICY)
193 .expect("CSP header")
194 .to_str()
195 .expect("CSP text")
196 .to_string();
197 let cookie = response
198 .headers()
199 .get(header::SET_COOKIE)
200 .expect("CSRF cookie")
201 .to_str()
202 .expect("CSRF cookie text")
203 .to_string();
204 let body = to_bytes(response.into_body(), 1024).await.unwrap();
205 let body = std::str::from_utf8(&body).expect("baseline response text");
206 let mut fields = body.split('|');
207 let nonce = fields.next().expect("nonce field");
208 let csrf = fields.next().expect("CSRF field");
209 assert_eq!(fields.next(), Some("Strict"));
210 assert!(csp.contains(&format!("'nonce-{nonce}'")));
211 assert!(cookie.starts_with(&format!("rullst_csrf={csrf};")));
212 assert!(cookie.contains("SameSite=Strict"));
213 assert!(cookie.contains("; Secure"));
214
215 let denied = app
216 .clone()
217 .oneshot(
218 browser_request(http::Method::POST, "/write")
219 .body(Body::empty())
220 .unwrap(),
221 )
222 .await
223 .unwrap();
224 assert_eq!(denied.status(), StatusCode::FORBIDDEN);
225 assert!(
226 denied
227 .headers()
228 .contains_key(header::CONTENT_SECURITY_POLICY)
229 );
230
231 let accepted = app
232 .oneshot(
233 browser_request(http::Method::POST, "/write")
234 .header(header::COOKIE, format!("rullst_csrf={csrf}"))
235 .header("x-csrf-token", csrf)
236 .body(Body::empty())
237 .unwrap(),
238 )
239 .await
240 .unwrap();
241 assert_eq!(accepted.status(), StatusCode::NO_CONTENT);
242 }
243
244 #[tokio::test]
245 async fn cors_preflight_is_exact_and_unlisted_origins_receive_no_grant() {
246 let app = production_app();
247 let preflight = app
248 .clone()
249 .oneshot(
250 browser_request(http::Method::OPTIONS, "/write")
251 .header(header::ACCESS_CONTROL_REQUEST_METHOD, "POST")
252 .header(header::ACCESS_CONTROL_REQUEST_HEADERS, "x-csrf-token")
253 .body(Body::empty())
254 .unwrap(),
255 )
256 .await
257 .unwrap();
258 assert_eq!(preflight.status(), StatusCode::OK);
259 assert_eq!(
260 preflight.headers().get(header::ACCESS_CONTROL_ALLOW_ORIGIN),
261 Some(&http::HeaderValue::from_static("https://academy.example")),
262 );
263
264 let unlisted = app
265 .oneshot(
266 Request::builder()
267 .uri("/")
268 .header(header::USER_AGENT, "Mozilla/5.0")
269 .header(header::ORIGIN, "https://evil.example")
270 .body(Body::empty())
271 .unwrap(),
272 )
273 .await
274 .unwrap();
275 assert_eq!(unlisted.status(), StatusCode::OK);
276 assert!(
277 !unlisted
278 .headers()
279 .contains_key(header::ACCESS_CONTROL_ALLOW_ORIGIN)
280 );
281 }
282}