Jump to…
snowfix: 500 error because of database is patchedyuzpxzopsouq1mo
Matt W1//! Request middleware: session resolution, CSRF, and security headers.
Matt W2
Matt W3use std::sync::Arc;
Matt W4
Matt W5use axum::extract::{Request, State};
Matt W6use axum::http::{header, HeaderValue, Method, StatusCode};
Matt W7use axum::middleware::Next;
Matt W8use axum::response::{IntoResponse, Response};
Matt W9use axum_extra::extract::cookie::{Cookie, SameSite};
Matt W10use df_auth::{csrf, session};
Matt W11use rand::RngCore;
Matt W12
Matt W13use crate::state::{AppState, CsrfToken, CurrentUser, Nonce};
Matt W14
Matt W15/// Resolve the session cookie into a user, once per request.
Matt W16///
Matt W17/// Handlers never read the cookie themselves — spec §6: "Resolve permissions
Matt W18/// once per request in middleware … and pass it down."
Matt W19pub async fn session_layer(
Matt W20 State(state): State<AppState>,
Matt W21 mut req: Request,
Matt W22 next: Next,
Matt W23) -> Response {
Matt W24 let jar = axum_extra::extract::cookie::CookieJar::from_headers(req.headers());
Matt W25
Matt W26 let mut current = CurrentUser(None);
Matt W27
Matt W28 if let Some(raw) = jar.get(session::COOKIE_NAME).map(|c| c.value().to_owned()) {
Matt W29 // The cookie is the session's token, not its id. `load` rejects
Matt W30 // anything not shaped like one without touching the database.
Matt W31 match session::load(&state.db, &raw).await {
Matt W32 Ok(Some((sess, user))) => {
Matt W33 // Slide the expiry only past the halfway point, to avoid a
Matt W34 // database write on every page view.
Matt W35 if session::should_slide(sess.expires_at, state.config.session_ttl_days) {
Matt W36 if let Err(e) =
Matt W37 session::slide(&state.db, sess.id, state.config.session_ttl_days).await
Matt W38 {
Matt W39 tracing::warn!("sliding session failed: {e}");
Matt W40 }
Matt W41 }
Matt W42 current = CurrentUser(Some(Arc::new(user)));
Matt W43 }
Matt W44 Ok(None) => { /* expired or unknown: treated as signed out */ }
Matt W45 Err(e) => tracing::error!("loading session failed: {e}"),
Matt W46 }
Matt W47 }
Matt W48
Matt W49 req.extensions_mut().insert(current);
Matt W50 next.run(req).await
Matt W51}
Matt W52
Matt W53/// Issue a CSP nonce and a CSRF token, enforce CSRF on unsafe methods, and set
Matt W54/// the security headers from spec §9.
Matt W55pub async fn security_layer(
Matt W56 State(state): State<AppState>,
Matt W57 mut req: Request,
Matt W58 next: Next,
Matt W59) -> Response {
Matt W60 let secret = state.config.session_secret.clone();
Matt W61
Matt W62 let jar = axum_extra::extract::cookie::CookieJar::from_headers(req.headers());
Matt W63 let existing = jar.get(csrf::COOKIE_NAME).map(|c| c.value().to_owned());
Matt W64
Matt W65 // Reuse a valid token so a page open in two tabs does not invalidate itself.
Matt W66 let token = match &existing {
Matt W67 Some(t) if csrf::is_valid(&secret, t) => t.clone(),
Matt W68 _ => csrf::issue(&secret),
Matt W69 };
Matt W70 let issued_new = existing.as_deref() != Some(token.as_str());
Matt W71
Matt W72 // Enforce on every state-changing method, except the Git RPC endpoints.
Matt W73 //
Matt W74 // Git clients cannot carry a CSRF token, and these endpoints are exempt
Matt W75 // *safely* because they never authenticate from the session cookie —
Matt W76 // `git_http::auth` reads only the `Authorization` header. CSRF protects
Matt W77 // cookie-authenticated state changes; a cross-origin form cannot set an
Matt W78 // Authorization header, so there is nothing here for it to ride on.
Matt W79 //
Matt W80 // This exemption is only sound while that remains true. If a Git endpoint
Matt W81 // ever starts honouring the session cookie, it must be re-included here.
Matt W82 let git_rpc = is_git_rpc(req.uri().path());
Matt W83
Matt W84 if !git_rpc && !matches!(req.method(), &Method::GET | &Method::HEAD | &Method::OPTIONS) {
Matt W85 let submitted = req
Matt W86 .headers()
Matt W87 .get(csrf::HEADER_NAME)
Matt W88 .and_then(|v| v.to_str().ok())
Matt W89 .map(str::to_owned);
Matt W90
Matt W91 // Forms submit the token in a hidden field. Reading it requires
Matt W92 // buffering the body, so we do that only when the header is absent —
Matt W93 // which is the no-JavaScript path.
Matt W94 let (submitted, req_rebuilt) = match submitted {
Matt W95 Some(s) => (Some(s), req),
Matt W96 None => extract_csrf_from_form(req).await,
Matt W97 };
Matt W98 req = req_rebuilt;
Matt W99
Matt W100 if !csrf::verify(&secret, existing.as_deref(), submitted.as_deref()) {
Matt W101 tracing::warn!(
Matt W102 method = %req.method(),
Matt W103 path = %req.uri().path(),
Matt W104 "CSRF validation failed"
Matt W105 );
Matt W106 return (StatusCode::FORBIDDEN, "CSRF validation failed").into_response();
Matt W107 }
Matt W108 }
Matt W109
Matt W110 let nonce = {
Matt W111 let mut b = [0u8; 16];
Matt W112 rand::thread_rng().fill_bytes(&mut b);
Matt W113 hex::encode(b)
Matt W114 };
Matt W115
Matt W116 req.extensions_mut().insert(CsrfToken(token.clone()));
Matt W117 req.extensions_mut().insert(Nonce(nonce.clone()));
Matt W118
Matt W119 let mut res = next.run(req).await;
Matt W120 let headers = res.headers_mut();
Matt W121
Matt W122 // Strict CSP with no `unsafe-inline` (spec §9). htmx works fine with nonces.
Matt W123 headers.insert(
Matt W124 header::CONTENT_SECURITY_POLICY,
Matt W125 HeaderValue::from_str(&format!(
Matt W126 "default-src 'self'; \
Matt W127 script-src 'self' 'nonce-{nonce}'; \
Matt W128 style-src 'self'; \
Matt W129 img-src 'self' data:; \
Matt W130 font-src 'self'; \
Matt W131 connect-src 'self'; \
Matt W132 form-action 'self'; \
Matt W133 frame-ancestors 'none'; \
Matt W134 base-uri 'none'; \
Matt W135 object-src 'none'"
Matt W136 ))
Matt W137 .unwrap_or_else(|_| HeaderValue::from_static("default-src 'self'")),
Matt W138 );
Matt W139 headers.insert(
Matt W140 header::X_CONTENT_TYPE_OPTIONS,
Matt W141 HeaderValue::from_static("nosniff"),
Matt W142 );
Matt W143 headers.insert(
Matt W144 header::REFERRER_POLICY,
Matt W145 HeaderValue::from_static("same-origin"),
Matt W146 );
Matt W147 // Redundant with `frame-ancestors 'none'` above, but kept for clients that
Matt W148 // predate CSP framing directives.
Matt W149 headers.insert(
Matt W150 header::X_FRAME_OPTIONS,
Matt W151 HeaderValue::from_static("DENY"),
Matt W152 );
Matt W153
Matt W154 if issued_new {
Matt W155 // Not HttpOnly: the double-submit pattern requires the page to read it
Matt W156 // in order to echo it back. Its secrecy is not what makes it work — the
Matt W157 // HMAC and the same-origin requirement are.
Matt W158 let cookie = Cookie::build((csrf::COOKIE_NAME, token))
Matt W159 .path("/")
Matt W160 .secure(state.config.secure_cookies())
Matt W161 .http_only(false)
Matt W162 .same_site(SameSite::Lax)
Matt W163 .build();
Matt W164 if let Ok(v) = HeaderValue::from_str(&cookie.to_string()) {
Matt W165 res.headers_mut().append(header::SET_COOKIE, v);
Matt W166 }
Matt W167 }
Matt W168
Matt W169 res
Matt W170}
Matt W171
Matt W172/// Pull `_csrf` out of a urlencoded body, returning the request with its body
Matt W173/// intact so the handler can still read it.
Matt W174async fn extract_csrf_from_form(req: Request) -> (Option<String>, Request) {
Matt W175 let is_form = req
Matt W176 .headers()
Matt W177 .get(header::CONTENT_TYPE)
Matt W178 .and_then(|v| v.to_str().ok())
Matt W179 .is_some_and(|v| v.starts_with("application/x-www-form-urlencoded"));
Matt W180
Matt W181 if !is_form {
Matt W182 return (None, req);
Matt W183 }
Matt W184
Matt W185 let (parts, body) = req.into_parts();
Matt W186 // Bounded: a form body large enough to matter is not a form.
Matt W187 let bytes = match axum::body::to_bytes(body, 64 * 1024).await {
Matt W188 Ok(b) => b,
Matt W189 Err(_) => {
Matt W190 return (None, Request::from_parts(parts, axum::body::Body::empty()));
Matt W191 }
Matt W192 };
Matt W193
Matt W194 let token = form_urlencoded::parse(&bytes)
Matt W195 .find(|(k, _)| k == csrf::FORM_FIELD)
Matt W196 .map(|(_, v)| v.into_owned());
Matt W197
Matt W198 (
Matt W199 token,
Matt W200 Request::from_parts(parts, axum::body::Body::from(bytes)),
Matt W201 )
Matt W202}
Matt W203
Matt W204/// Whether a path is one of the Git smart-HTTP RPC endpoints.
Matt W205///
Matt W206/// Matched on the final segment only, so a repository named
Matt W207/// `git-receive-pack` cannot smuggle an exemption for its own pages.
Matt W208fn is_git_rpc(path: &str) -> bool {
Matt W209 let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
Matt W210 // Exactly `/{owner}/{repo}/{rpc}` — the shape the routes declare. Matching
Matt W211 // on the last segment alone would also exempt `/git-receive-pack`, which is
Matt W212 // an owner page, not an RPC endpoint.
Matt W213 segments.len() == 3
Matt W214 && matches!(segments[2], "git-upload-pack" | "git-receive-pack")
Matt W215}
Matt W216
Matt W217#[cfg(test)]
Matt W218mod tests {
Matt W219 use super::is_git_rpc;
Matt W220
Matt W221 #[test]
Matt W222 fn git_rpc_endpoints_are_recognised() {
Matt W223 assert!(is_git_rpc("/owner/repo.git/git-receive-pack"));
Matt W224 assert!(is_git_rpc("/owner/repo.git/git-upload-pack"));
Matt W225 assert!(is_git_rpc("/o/r/git-receive-pack"));
Matt W226 }
Matt W227
Matt W228 #[test]
Matt W229 fn ordinary_paths_are_not_exempt() {
Matt W230 for p in [
Matt W231 "/",
Matt W232 "/repos",
Matt W233 "/owner/repo",
Matt W234 "/owner/repo/settings",
Matt W235 "/logout",
Matt W236 "/auth/handle",
Matt W237 ] {
Matt W238 assert!(!is_git_rpc(p), "{p} must not be CSRF-exempt");
Matt W239 }
Matt W240 }
Matt W241
Matt W242 #[test]
Matt W243 fn a_repository_named_like_the_rpc_cannot_exempt_its_own_pages() {
Matt W244 // /owner/git-receive-pack is a repository page and must stay protected;
Matt W245 // only the trailing RPC segment counts.
Matt W246 assert!(!is_git_rpc("/owner/git-receive-pack/settings"));
Matt W247 assert!(!is_git_rpc("/git-receive-pack"), "top-level owner page");
Matt W248 }
Matt W249
Matt W250 #[test]
Matt W251 fn csp_forbids_unsafe_inline() {
Matt W252 // Guard against someone loosening the policy to make an inline script
Matt W253 // work; the nonce is the supported mechanism.
Matt W254 let policy = "default-src 'self'; script-src 'self' 'nonce-abc'; style-src 'self'";
Matt W255 assert!(!policy.contains("unsafe-inline"));
Matt W256 assert!(!policy.contains("unsafe-eval"));
Matt W257 }
Matt W258}

258 lines · Rust