Skip to main content

rullst_core/security/
baseline.rs

1//! Canonical composition of the runtime-owned browser security baseline.
2
3use crate::config::{Environment, SecurityConfig};
4use axum::{Router, http};
5use std::time::Duration;
6use tower_http::cors::CorsLayer;
7
8/// Applies the full baseline and an exact authenticated machine-route policy.
9/// Authentication also runs in development; WAF and browser security retain the
10/// normal environment policy. The legacy signed-webhook path list is unchanged.
11pub 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/// Failure to construct the runtime security baseline from application configuration.
28#[derive(Debug, thiserror::Error, PartialEq, Eq)]
29#[non_exhaustive]
30pub enum SecurityBaselineError {
31    /// A configured CORS origin cannot be represented as an HTTP header value.
32    #[error("invalid CORS origin header value `{0}`")]
33    InvalidCorsOrigin(String),
34    /// A cross-field security configuration invariant failed.
35    #[error("invalid security baseline configuration: {0}")]
36    InvalidConfiguration(String),
37}
38
39/// Applies the canonical runtime-owned browser security baseline.
40///
41/// In staging/production the outer-to-inner order is secure headers → CORS →
42/// WAF/RASP → CSRF → optional response PII masking → handler. Configuration and
43/// environment extensions are installed outside those layers so every
44/// middleware observes the exact application configuration rather than a
45/// process-global fallback. Session, authentication, tenancy and authorization
46/// remain application-owned layers.
47pub 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        // TM-NEXUS-05: a cookie-authenticated write is denied before the handler
164        // unless the exact double-submit token accompanies the request.
165        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}