| 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