samjeston / openhub Public
Sam Jeston Working vertical 9aa3c45 · History
292 lines· 8.9 KB ·Rust
Raw
1//! Per-request context: the signed-in user, flash messages, CSRF protection
2//! and error page rendering.
3
4use std::sync::Arc;
5
6use askama::Template;
7use axum::extract::{FromRequestParts, Request, State};
8use axum::http::header::{self, HeaderValue};
9use axum::http::request::Parts;
10use axum::http::{Method, StatusCode};
11use axum::middleware::Next;
12use axum::response::{Html, IntoResponse, Response};
13use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
14use base64::Engine;
15use base64::engine::general_purpose::URL_SAFE_NO_PAD;
16use rand::RngCore;
17use sha2::{Digest, Sha256};
18
19use super::error::{AppError, ErrorPage};
20use crate::db::{self, User};
21use crate::state::AppState;
22
23pub const SESSION_COOKIE: &str = "openhub_session";
24const FLASH_COOKIE: &str = "openhub_flash";
25pub const SESSION_TTL_SECS: i64 = 30 * 24 * 3600;
26
27#[derive(Debug, Clone)]
28pub struct Flash {
29 pub kind: String,
30 pub message: String,
31}
32
33/// Available to every handler and template.
34#[derive(Debug, Clone)]
35pub struct Ctx {
36 pub user: Option<Arc<User>>,
37 pub flash: Option<Flash>,
38 pub path: String,
39 pub session_hash: Option<String>,
40}
41
42impl Ctx {
43 pub fn require_user(&self) -> Result<&User, AppError> {
44 self.user
45 .as_deref()
46 .ok_or_else(|| AppError::LoginRequired(self.path.clone()))
47 }
48}
49
50impl<S: Send + Sync> FromRequestParts<S> for Ctx {
51 type Rejection = AppError;
52
53 async fn from_request_parts(parts: &mut Parts, _: &S) -> Result<Self, Self::Rejection> {
54 parts
55 .extensions
56 .get::<Ctx>()
57 .cloned()
58 .ok_or_else(|| AppError::Internal(anyhow::anyhow!("request context missing")))
59 }
60}
61
62pub fn hash_token(token: &str) -> String {
63 hex::encode(Sha256::digest(token.as_bytes()))
64}
65
66pub fn new_token() -> String {
67 let mut bytes = [0u8; 32];
68 rand::rngs::OsRng.fill_bytes(&mut bytes);
69 URL_SAFE_NO_PAD.encode(bytes)
70}
71
72pub fn session_cookie(token: String, secure: bool) -> Cookie<'static> {
73 Cookie::build((SESSION_COOKIE, token))
74 .path("/")
75 .http_only(true)
76 .secure(secure)
77 .same_site(SameSite::Lax)
78 .max_age(time_duration(SESSION_TTL_SECS))
79 .build()
80}
81
82pub fn clear_session_cookie() -> Cookie<'static> {
83 Cookie::build((SESSION_COOKIE, ""))
84 .path("/")
85 .max_age(time_duration(0))
86 .build()
87}
88
89fn time_duration(secs: i64) -> cookie::time::Duration {
90 cookie::time::Duration::seconds(secs)
91}
92
93pub fn flash_cookie(kind: &str, message: &str) -> Cookie<'static> {
94 let value = URL_SAFE_NO_PAD.encode(format!("{kind}\n{message}"));
95 Cookie::build((FLASH_COOKIE, value))
96 .path("/")
97 .http_only(true)
98 .same_site(SameSite::Lax)
99 .build()
100}
101
102fn decode_flash(value: &str) -> Option<Flash> {
103 let bytes = URL_SAFE_NO_PAD.decode(value).ok()?;
104 let text = String::from_utf8(bytes).ok()?;
105 let (kind, message) = text.split_once('\n')?;
106 let kind = match kind {
107 "success" | "error" | "info" => kind.to_string(),
108 _ => return None,
109 };
110 Some(Flash {
111 kind,
112 message: message.to_string(),
113 })
114}
115
116fn is_git_request(path: &str) -> bool {
117 path.ends_with("/info/refs")
118 || path.ends_with("/git-upload-pack")
119 || path.ends_with("/git-receive-pack")
120}
121
122/// Rejects cross-site form posts. SameSite=Lax cookies already prevent
123/// authenticated cross-site requests in modern browsers; this is a second
124/// line of defence based on the Origin header. Requests are accepted when the
125/// origin matches either the configured public URL or the Host header (reverse
126/// proxies do not always preserve the latter).
127fn cross_origin(req: &Request, public_url: &str) -> bool {
128 let Some(origin) = req
129 .headers()
130 .get(header::ORIGIN)
131 .and_then(|v| v.to_str().ok())
132 else {
133 return false;
134 };
135 if origin.eq_ignore_ascii_case(public_url) {
136 return false;
137 }
138 let host = req
139 .headers()
140 .get(header::HOST)
141 .and_then(|v| v.to_str().ok())
142 .unwrap_or("");
143 let origin_host = origin.split_once("://").map(|(_, h)| h).unwrap_or(origin);
144 origin == "null" || !origin_host.eq_ignore_ascii_case(host)
145}
146
147/// `https://git.example.com/sub` → `https://git.example.com`
148fn origin_of(url: &str) -> &str {
149 let after_scheme = url.find("://").map(|i| i + 3).unwrap_or(0);
150 match url[after_scheme..].find('/') {
151 Some(i) => &url[..after_scheme + i],
152 None => url,
153 }
154}
155
156#[derive(Template)]
157#[template(path = "error.html")]
158struct ErrorTemplate {
159 ctx: Ctx,
160 title: &'static str,
161 message: String,
162 status: u16,
163}
164
165pub async fn middleware(State(state): State<AppState>, mut req: Request, next: Next) -> Response {
166 let path = req.uri().path().to_string();
167 if path.starts_with("/static/") || path == "/favicon.svg" {
168 return next.run(req).await;
169 }
170 let git = is_git_request(&path);
171 if !git
172 && !matches!(*req.method(), Method::GET | Method::HEAD | Method::OPTIONS)
173 && cross_origin(&req, origin_of(&state.config.public_url))
174 {
175 return (StatusCode::FORBIDDEN, "Cross-origin request rejected").into_response();
176 }
177
178 let jar = CookieJar::from_headers(req.headers());
179 let mut user = None;
180 let mut session_hash = None;
181 if !git {
182 if let Some(cookie) = jar.get(SESSION_COOKIE) {
183 let hash = hash_token(cookie.value());
184 match db::user_by_session(&state.db, &hash).await {
185 Ok(Some(u)) => {
186 user = Some(Arc::new(u));
187 session_hash = Some(hash);
188 }
189 Ok(None) => {}
190 Err(e) => tracing::error!("session lookup failed: {e}"),
191 }
192 }
193 }
194 let flash = jar.get(FLASH_COOKIE).and_then(|c| decode_flash(c.value()));
195 let had_flash = jar.get(FLASH_COOKIE).is_some();
196
197 let ctx = Ctx {
198 user,
199 flash,
200 path,
201 session_hash,
202 };
203 req.extensions_mut().insert(ctx.clone());
204 let mut resp = next.run(req).await;
205
206 if let Some(page) = resp.extensions_mut().remove::<ErrorPage>() {
207 let tpl = ErrorTemplate {
208 ctx: ctx.clone(),
209 title: page.title,
210 message: page.message,
211 status: page.status.as_u16(),
212 };
213 let body = tpl.render().unwrap_or_else(|_| page.title.to_string());
214 resp = (page.status, Html(body)).into_response();
215 }
216
217 let headers = resp.headers_mut();
218 // Consume the flash message unless this response sets a new one.
219 let sets_flash = headers
220 .get_all(header::SET_COOKIE)
221 .iter()
222 .any(|v| v.to_str().is_ok_and(|s| s.starts_with(FLASH_COOKIE)));
223 if had_flash && !sets_flash {
224 let clear = Cookie::build((FLASH_COOKIE, ""))
225 .path("/")
226 .max_age(time_duration(0))
227 .build();
228 if let Ok(v) = HeaderValue::from_str(&clear.to_string()) {
229 headers.append(header::SET_COOKIE, v);
230 }
231 }
232 let is_html = headers
233 .get(header::CONTENT_TYPE)
234 .and_then(|v| v.to_str().ok())
235 .is_some_and(|v| v.starts_with("text/html"));
236 if is_html {
237 headers.insert(
238 header::CONTENT_SECURITY_POLICY,
239 HeaderValue::from_static(
240 "default-src 'self'; img-src 'self' data: https:; style-src 'self' 'unsafe-inline'; \
241 script-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'",
242 ),
243 );
244 headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
245 }
246 headers.insert(
247 "x-content-type-options",
248 HeaderValue::from_static("nosniff"),
249 );
250 headers.insert(
251 header::REFERRER_POLICY,
252 HeaderValue::from_static("strict-origin-when-cross-origin"),
253 );
254 resp
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260
261 fn post(origin: Option<&str>, host: &str) -> Request {
262 let mut builder = Request::builder()
263 .method("POST")
264 .uri("/x")
265 .header(header::HOST, host);
266 if let Some(o) = origin {
267 builder = builder.header(header::ORIGIN, o);
268 }
269 builder.body(axum::body::Body::empty()).unwrap()
270 }
271
272 #[test]
273 fn origin_checks() {
274 let public = origin_of("https://git.example.com/");
275 assert_eq!(public, "https://git.example.com");
276 assert!(!cross_origin(&post(None, "localhost:3000"), public));
277 assert!(!cross_origin(
278 &post(Some("http://localhost:3000"), "localhost:3000"),
279 public
280 ));
281 // Behind a proxy that rewrites Host.
282 assert!(!cross_origin(
283 &post(Some("https://git.example.com"), "127.0.0.1:3000"),
284 public
285 ));
286 assert!(cross_origin(
287 &post(Some("https://evil.example"), "localhost:3000"),
288 public
289 ));
290 assert!(cross_origin(&post(Some("null"), "localhost:3000"), public));
291 }
292}