Jump to…
snowfix: 500 error because of database is patchedyuzpxzopsouq1mo
1//! Request middleware: session resolution, CSRF, and security headers.
2
3use std::sync::Arc;
4
5use axum::extract::{Request, State};
6use axum::http::{header, HeaderValue, Method, StatusCode};
7use axum::middleware::Next;
8use axum::response::{IntoResponse, Response};
9use axum_extra::extract::cookie::{Cookie, SameSite};
10use df_auth::{csrf, session};
11use rand::RngCore;
12
13use 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."
19pub 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.
55pub 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.
174async 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.
208fn 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)]
218mod 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