Sam Jeston
History
292 lines·
8.9 KB
·Rust
| 1 | //! Per-request context: the signed-in user, flash messages, CSRF protection |
| 2 | //! and error page rendering. |
| 3 | |
| 4 | use std::sync::Arc; |
| 5 | |
| 6 | use askama::Template; |
| 7 | use axum::extract::{FromRequestParts, Request, State}; |
| 8 | use axum::http::header::{self, HeaderValue}; |
| 9 | use axum::http::request::Parts; |
| 10 | use axum::http::{Method, StatusCode}; |
| 11 | use axum::middleware::Next; |
| 12 | use axum::response::{Html, IntoResponse, Response}; |
| 13 | use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite}; |
| 14 | use base64::Engine; |
| 15 | use base64::engine::general_purpose::URL_SAFE_NO_PAD; |
| 16 | use rand::RngCore; |
| 17 | use sha2::{Digest, Sha256}; |
| 18 | |
| 19 | use super::error::{AppError, ErrorPage}; |
| 20 | use crate::db::{self, User}; |
| 21 | use crate::state::AppState; |
| 22 | |
| 23 | pub const SESSION_COOKIE: &str = "openhub_session"; |
| 24 | const FLASH_COOKIE: &str = "openhub_flash"; |
| 25 | pub const SESSION_TTL_SECS: i64 = 30 * 24 * 3600; |
| 26 | |
| 27 | #[derive(Debug, Clone)] |
| 28 | pub struct Flash { |
| 29 | pub kind: String, |
| 30 | pub message: String, |
| 31 | } |
| 32 | |
| 33 | /// Available to every handler and template. |
| 34 | #[derive(Debug, Clone)] |
| 35 | pub 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 | |
| 42 | impl 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 | |
| 50 | impl<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 | |
| 62 | pub fn hash_token(token: &str) -> String { |
| 63 | hex::encode(Sha256::digest(token.as_bytes())) |
| 64 | } |
| 65 | |
| 66 | pub 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 | |
| 72 | pub 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 | |
| 82 | pub fn clear_session_cookie() -> Cookie<'static> { |
| 83 | Cookie::build((SESSION_COOKIE, "")) |
| 84 | .path("/") |
| 85 | .max_age(time_duration(0)) |
| 86 | .build() |
| 87 | } |
| 88 | |
| 89 | fn time_duration(secs: i64) -> cookie::time::Duration { |
| 90 | cookie::time::Duration::seconds(secs) |
| 91 | } |
| 92 | |
| 93 | pub 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 | |
| 102 | fn 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 | |
| 116 | fn 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). |
| 127 | fn 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` |
| 148 | fn 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")] |
| 158 | struct ErrorTemplate { |
| 159 | ctx: Ctx, |
| 160 | title: &'static str, |
| 161 | message: String, |
| 162 | status: u16, |
| 163 | } |
| 164 | |
| 165 | pub 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)] |
| 258 | mod 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 | } |