| 1 | //! Request middleware: session resolution, CSRF, and security headers. | |
| 2 | ||
| 3 | use std::sync::Arc; | |
| 4 | ||
| 5 | use axum::extract::{Request, State}; | |
| 6 | use axum::http::{header, HeaderValue, Method, StatusCode}; | |
| 7 | use axum::middleware::Next; | |
| 8 | use axum::response::{IntoResponse, Response}; | |
| 9 | use axum_extra::extract::cookie::{Cookie, SameSite}; | |
| 10 | use df_auth::{csrf, session}; | |
| 11 | use rand::RngCore; | |
| 12 | ||
| 13 | use crate::state::{AppState, CsrfToken, CurrentUser, Nonce}; | |
| 14 | ||
| 15 | /// Resolve the session cookie into a user, once per request. | |
| 16 | /// | |
| 17 | /// Handlers never read the cookie themselves — spec §6: "Resolve permissions | |
| 18 | /// once per request in middleware … and pass it down." | |
| 19 | pub async fn session_layer( | |
| 20 | State(state): State<AppState>, | |
| 21 | mut req: Request, | |
| 22 | next: Next, | |
| 23 | ) -> Response { | |
| 24 | let jar = axum_extra::extract::cookie::CookieJar::from_headers(req.headers()); | |
| 25 | ||
| 26 | let mut current = CurrentUser(None); | |
| 27 | ||
| 28 | if let Some(raw) = jar.get(session::COOKIE_NAME).map(|c| c.value().to_owned()) { | |
| 29 | // The cookie is the session's token, not its id. `load` rejects | |
| 30 | // anything not shaped like one without touching the database. | |
| 31 | match session::load(&state.db, &raw).await { | |
| 32 | Ok(Some((sess, user))) => { | |
| 33 | // Slide the expiry only past the halfway point, to avoid a | |
| 34 | // database write on every page view. | |
| 35 | if session::should_slide(sess.expires_at, state.config.session_ttl_days) { | |
| 36 | if let Err(e) = | |
| 37 | session::slide(&state.db, sess.id, state.config.session_ttl_days).await | |
| 38 | { | |
| 39 | tracing::warn!("sliding session failed: {e}"); | |
| 40 | } | |
| 41 | } | |
| 42 | current = CurrentUser(Some(Arc::new(user))); | |
| 43 | } | |
| 44 | Ok(None) => { /* expired or unknown: treated as signed out */ } | |
| 45 | Err(e) => tracing::error!("loading session failed: {e}"), | |
| 46 | } | |
| 47 | } | |
| 48 | ||
| 49 | req.extensions_mut().insert(current); | |
| 50 | next.run(req).await | |
| 51 | } | |
| 52 | ||
| 53 | /// Issue a CSP nonce and a CSRF token, enforce CSRF on unsafe methods, and set | |
| 54 | /// the security headers from spec §9. | |
| 55 | pub async fn security_layer( | |
| 56 | State(state): State<AppState>, | |
| 57 | mut req: Request, | |
| 58 | next: Next, | |
| 59 | ) -> Response { | |
| 60 | let secret = state.config.session_secret.clone(); | |
| 61 | ||
| 62 | let jar = axum_extra::extract::cookie::CookieJar::from_headers(req.headers()); | |
| 63 | let existing = jar.get(csrf::COOKIE_NAME).map(|c| c.value().to_owned()); | |
| 64 | ||
| 65 | // Reuse a valid token so a page open in two tabs does not invalidate itself. | |
| 66 | let token = match &existing { | |
| 67 | Some(t) if csrf::is_valid(&secret, t) => t.clone(), | |
| 68 | _ => csrf::issue(&secret), | |
| 69 | }; | |
| 70 | let issued_new = existing.as_deref() != Some(token.as_str()); | |
| 71 | ||
| 72 | // Enforce on every state-changing method, except the Git RPC endpoints. | |
| 73 | // | |
| 74 | // Git clients cannot carry a CSRF token, and these endpoints are exempt | |
| 75 | // *safely* because they never authenticate from the session cookie — | |
| 76 | // `git_http::auth` reads only the `Authorization` header. CSRF protects | |
| 77 | // cookie-authenticated state changes; a cross-origin form cannot set an | |
| 78 | // Authorization header, so there is nothing here for it to ride on. | |
| 79 | // | |
| 80 | // This exemption is only sound while that remains true. If a Git endpoint | |
| 81 | // ever starts honouring the session cookie, it must be re-included here. | |
| 82 | let git_rpc = is_git_rpc(req.uri().path()); | |
| 83 | ||
| 84 | if !git_rpc && !matches!(req.method(), &Method::GET | &Method::HEAD | &Method::OPTIONS) { | |
| 85 | let submitted = req | |
| 86 | .headers() | |
| 87 | .get(csrf::HEADER_NAME) | |
| 88 | .and_then(|v| v.to_str().ok()) | |
| 89 | .map(str::to_owned); | |
| 90 | ||
| 91 | // Forms submit the token in a hidden field. Reading it requires | |
| 92 | // buffering the body, so we do that only when the header is absent — | |
| 93 | // which is the no-JavaScript path. | |
| 94 | let (submitted, req_rebuilt) = match submitted { | |
| 95 | Some(s) => (Some(s), req), | |
| 96 | None => extract_csrf_from_form(req).await, | |
| 97 | }; | |
| 98 | req = req_rebuilt; | |
| 99 | ||
| 100 | if !csrf::verify(&secret, existing.as_deref(), submitted.as_deref()) { | |
| 101 | tracing::warn!( | |
| 102 | method = %req.method(), | |
| 103 | path = %req.uri().path(), | |
| 104 | "CSRF validation failed" | |
| 105 | ); | |
| 106 | return (StatusCode::FORBIDDEN, "CSRF validation failed").into_response(); | |
| 107 | } | |
| 108 | } | |
| 109 | ||
| 110 | let nonce = { | |
| 111 | let mut b = [0u8; 16]; | |
| 112 | rand::thread_rng().fill_bytes(&mut b); | |
| 113 | hex::encode(b) | |
| 114 | }; | |
| 115 | ||
| 116 | req.extensions_mut().insert(CsrfToken(token.clone())); | |
| 117 | req.extensions_mut().insert(Nonce(nonce.clone())); | |
| 118 | ||
| 119 | let mut res = next.run(req).await; | |
| 120 | let headers = res.headers_mut(); | |
| 121 | ||
| 122 | // Strict CSP with no `unsafe-inline` (spec §9). htmx works fine with nonces. | |
| 123 | headers.insert( | |
| 124 | header::CONTENT_SECURITY_POLICY, | |
| 125 | HeaderValue::from_str(&format!( | |
| 126 | "default-src 'self'; \ | |
| 127 | script-src 'self' 'nonce-{nonce}'; \ | |
| 128 | style-src 'self'; \ | |
| 129 | img-src 'self' data:; \ | |
| 130 | font-src 'self'; \ | |
| 131 | connect-src 'self'; \ | |
| 132 | form-action 'self'; \ | |
| 133 | frame-ancestors 'none'; \ | |
| 134 | base-uri 'none'; \ | |
| 135 | object-src 'none'" | |
| 136 | )) | |
| 137 | .unwrap_or_else(|_| HeaderValue::from_static("default-src 'self'")), | |
| 138 | ); | |
| 139 | headers.insert( | |
| 140 | header::X_CONTENT_TYPE_OPTIONS, | |
| 141 | HeaderValue::from_static("nosniff"), | |
| 142 | ); | |
| 143 | headers.insert( | |
| 144 | header::REFERRER_POLICY, | |
| 145 | HeaderValue::from_static("same-origin"), | |
| 146 | ); | |
| 147 | // Redundant with `frame-ancestors 'none'` above, but kept for clients that | |
| 148 | // predate CSP framing directives. | |
| 149 | headers.insert( | |
| 150 | header::X_FRAME_OPTIONS, | |
| 151 | HeaderValue::from_static("DENY"), | |
| 152 | ); | |
| 153 | ||
| 154 | if issued_new { | |
| 155 | // Not HttpOnly: the double-submit pattern requires the page to read it | |
| 156 | // in order to echo it back. Its secrecy is not what makes it work — the | |
| 157 | // HMAC and the same-origin requirement are. | |
| 158 | let cookie = Cookie::build((csrf::COOKIE_NAME, token)) | |
| 159 | .path("/") | |
| 160 | .secure(state.config.secure_cookies()) | |
| 161 | .http_only(false) | |
| 162 | .same_site(SameSite::Lax) | |
| 163 | .build(); | |
| 164 | if let Ok(v) = HeaderValue::from_str(&cookie.to_string()) { | |
| 165 | res.headers_mut().append(header::SET_COOKIE, v); | |
| 166 | } | |
| 167 | } | |
| 168 | ||
| 169 | res | |
| 170 | } | |
| 171 | ||
| 172 | /// Pull `_csrf` out of a urlencoded body, returning the request with its body | |
| 173 | /// intact so the handler can still read it. | |
| 174 | async fn extract_csrf_from_form(req: Request) -> (Option<String>, Request) { | |
| 175 | let is_form = req | |
| 176 | .headers() | |
| 177 | .get(header::CONTENT_TYPE) | |
| 178 | .and_then(|v| v.to_str().ok()) | |
| 179 | .is_some_and(|v| v.starts_with("application/x-www-form-urlencoded")); | |
| 180 | ||
| 181 | if !is_form { | |
| 182 | return (None, req); | |
| 183 | } | |
| 184 | ||
| 185 | let (parts, body) = req.into_parts(); | |
| 186 | // Bounded: a form body large enough to matter is not a form. | |
| 187 | let bytes = match axum::body::to_bytes(body, 64 * 1024).await { | |
| 188 | Ok(b) => b, | |
| 189 | Err(_) => { | |
| 190 | return (None, Request::from_parts(parts, axum::body::Body::empty())); | |
| 191 | } | |
| 192 | }; | |
| 193 | ||
| 194 | let token = form_urlencoded::parse(&bytes) | |
| 195 | .find(|(k, _)| k == csrf::FORM_FIELD) | |
| 196 | .map(|(_, v)| v.into_owned()); | |
| 197 | ||
| 198 | ( | |
| 199 | token, | |
| 200 | Request::from_parts(parts, axum::body::Body::from(bytes)), | |
| 201 | ) | |
| 202 | } | |
| 203 | ||
| 204 | /// Whether a path is one of the Git smart-HTTP RPC endpoints. | |
| 205 | /// | |
| 206 | /// Matched on the final segment only, so a repository named | |
| 207 | /// `git-receive-pack` cannot smuggle an exemption for its own pages. | |
| 208 | fn is_git_rpc(path: &str) -> bool { | |
| 209 | let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); | |
| 210 | // Exactly `/{owner}/{repo}/{rpc}` — the shape the routes declare. Matching | |
| 211 | // on the last segment alone would also exempt `/git-receive-pack`, which is | |
| 212 | // an owner page, not an RPC endpoint. | |
| 213 | segments.len() == 3 | |
| 214 | && matches!(segments[2], "git-upload-pack" | "git-receive-pack") | |
| 215 | } | |
| 216 | ||
| 217 | #[cfg(test)] | |
| 218 | mod tests { | |
| 219 | use super::is_git_rpc; | |
| 220 | ||
| 221 | #[test] | |
| 222 | fn git_rpc_endpoints_are_recognised() { | |
| 223 | assert!(is_git_rpc("/owner/repo.git/git-receive-pack")); | |
| 224 | assert!(is_git_rpc("/owner/repo.git/git-upload-pack")); | |
| 225 | assert!(is_git_rpc("/o/r/git-receive-pack")); | |
| 226 | } | |
| 227 | ||
| 228 | #[test] | |
| 229 | fn ordinary_paths_are_not_exempt() { | |
| 230 | for p in [ | |
| 231 | "/", | |
| 232 | "/repos", | |
| 233 | "/owner/repo", | |
| 234 | "/owner/repo/settings", | |
| 235 | "/logout", | |
| 236 | "/auth/handle", | |
| 237 | ] { | |
| 238 | assert!(!is_git_rpc(p), "{p} must not be CSRF-exempt"); | |
| 239 | } | |
| 240 | } | |
| 241 | ||
| 242 | #[test] | |
| 243 | fn a_repository_named_like_the_rpc_cannot_exempt_its_own_pages() { | |
| 244 | // /owner/git-receive-pack is a repository page and must stay protected; | |
| 245 | // only the trailing RPC segment counts. | |
| 246 | assert!(!is_git_rpc("/owner/git-receive-pack/settings")); | |
| 247 | assert!(!is_git_rpc("/git-receive-pack"), "top-level owner page"); | |
| 248 | } | |
| 249 | ||
| 250 | #[test] | |
| 251 | fn csp_forbids_unsafe_inline() { | |
| 252 | // Guard against someone loosening the policy to make an inline script | |
| 253 | // work; the nonce is the supported mechanism. | |
| 254 | let policy = "default-src 'self'; script-src 'self' 'nonce-abc'; style-src 'self'"; | |
| 255 | assert!(!policy.contains("unsafe-inline")); | |
| 256 | assert!(!policy.contains("unsafe-eval")); | |
| 257 | } | |
| 258 | } |
258 lines · Rust