Tahinli/forge
public0 watching
★ 0
| 1 | //! Page: first-run setup. Creates the initial admin while the user table is empty. |
| 2 | |
| 3 | use forge_core::AuditAction; |
| 4 | use forge_core::names::validate_username; |
| 5 | use serde::Deserialize; |
| 6 | use topcoat::Result; |
| 7 | use topcoat::context::{Cx, app_context}; |
| 8 | use topcoat::router::content::Form; |
| 9 | use topcoat::router::error::{forbidden, not_found, see_other}; |
| 10 | use topcoat::router::page; |
| 11 | use topcoat::view::{View, component, view}; |
| 12 | |
| 13 | use crate::audit::{self, Actor, Target}; |
| 14 | use crate::auth::{check_pre_csrf, clear_pre_csrf, hash_password_async, login, pre_csrf_token}; |
| 15 | use crate::i18n::{self, Key, Lang, password_too_long, password_too_short, t}; |
| 16 | use crate::state::AppState; |
| 17 | use crate::views::{alert, shell, user_facing_message}; |
| 18 | |
| 19 | const MIN_PASSWORD_CHARS: usize = 12; |
| 20 | const MAX_PASSWORD_BYTES: usize = 1024; |
| 21 | |
| 22 | #[derive(Deserialize)] |
| 23 | struct SetupForm { |
| 24 | #[serde(default)] |
| 25 | username: String, |
| 26 | #[serde(default)] |
| 27 | email: String, |
| 28 | #[serde(default)] |
| 29 | password: String, |
| 30 | #[serde(default)] |
| 31 | confirm: String, |
| 32 | #[serde(default)] |
| 33 | precsrf: String, |
| 34 | } |
| 35 | |
| 36 | #[component] |
| 37 | async fn setup_form( |
| 38 | lang: Lang, |
| 39 | username: &str, |
| 40 | email: &str, |
| 41 | precsrf: &str, |
| 42 | error: Option<String>, |
| 43 | ) -> Result<impl View> { |
| 44 | Ok(view! { |
| 45 | <section class="card narrow"> |
| 46 | <h1>(t(lang, Key::WelcomeToForge))</h1> |
| 47 | <p class="muted">(t(lang, Key::SetupSub))</p> |
| 48 | if let Some(message) = error { |
| 49 | alert(message: message.as_str()) |
| 50 | } |
| 51 | <form data-hard="true" method="post" action="/setup" class="stack"> |
| 52 | <input type="hidden" name="precsrf" value=(precsrf)> |
| 53 | <label> |
| 54 | <span>(t(lang, Key::Username))</span> |
| 55 | <input type="text" name="username" value=(username) autocomplete="username" required=(true)> |
| 56 | </label> |
| 57 | <label> |
| 58 | <span>(t(lang, Key::EmailOptional))</span> |
| 59 | <input type="email" name="email" value=(email) autocomplete="email"> |
| 60 | </label> |
| 61 | <label> |
| 62 | <span>(t(lang, Key::Password))</span> |
| 63 | <input type="password" name="password" autocomplete="new-password" required=(true)> |
| 64 | <small class="muted">(t(lang, Key::AtLeast12Chars))</small> |
| 65 | </label> |
| 66 | <label> |
| 67 | <span>(t(lang, Key::ConfirmPassword))</span> |
| 68 | <input type="password" name="confirm" autocomplete="new-password" required=(true)> |
| 69 | </label> |
| 70 | <button type="submit" class="primary">(t(lang, Key::CreateAdmin))</button> |
| 71 | </form> |
| 72 | </section> |
| 73 | }) |
| 74 | } |
| 75 | |
| 76 | #[page("/setup")] |
| 77 | async fn setup_page(cx: &Cx) -> Result<impl View> { |
| 78 | let state = app_context::<AppState>(cx); |
| 79 | if state.store.user_count().await? != 0 { |
| 80 | return Err(not_found().into()); |
| 81 | } |
| 82 | let precsrf = pre_csrf_token(cx); |
| 83 | let lang = i18n::lang(cx, None); |
| 84 | Ok(view! { |
| 85 | shell(title: t(lang, Key::Setup), viewer: None, setup_form(lang: lang, username: "", email: "", precsrf: precsrf.as_str(), error: None)) |
| 86 | }) |
| 87 | } |
| 88 | |
| 89 | #[page(POST "/setup")] |
| 90 | async fn setup_submit(cx: &Cx, Form(form): Form<SetupForm>) -> Result<impl View> { |
| 91 | // Login CSRF: the pre-session token must match the cookie before anything else runs. |
| 92 | if !check_pre_csrf(cx, &form.precsrf) { |
| 93 | return Err(forbidden().into()); |
| 94 | } |
| 95 | let state = app_context::<AppState>(cx); |
| 96 | if state.store.user_count().await? != 0 { |
| 97 | return Err(not_found().into()); |
| 98 | } |
| 99 | |
| 100 | let lang = i18n::lang(cx, None); |
| 101 | let problem = match validate_username(&form.username) { |
| 102 | Err(e) => user_facing_message(&e), |
| 103 | Ok(_) if form.password.chars().count() < MIN_PASSWORD_CHARS => { |
| 104 | Some(password_too_short(lang, MIN_PASSWORD_CHARS)) |
| 105 | } |
| 106 | Ok(_) if form.password.len() > MAX_PASSWORD_BYTES => { |
| 107 | Some(password_too_long(lang, MAX_PASSWORD_BYTES)) |
| 108 | } |
| 109 | Ok(_) if form.password != form.confirm => Some(t(lang, Key::ErrPasswordsDiffer).to_owned()), |
| 110 | Ok(_) => None, |
| 111 | }; |
| 112 | if let Some(message) = problem { |
| 113 | let (username, email) = (form.username.clone(), form.email.clone()); |
| 114 | let precsrf = form.precsrf.clone(); |
| 115 | return Ok(view! { |
| 116 | shell(title: t(lang, Key::Setup), viewer: None, setup_form(lang: lang, username: username.as_str(), email: email.as_str(), precsrf: precsrf.as_str(), error: Some(message))) |
| 117 | }); |
| 118 | } |
| 119 | |
| 120 | let (username, _) = validate_username(&form.username)?; |
| 121 | let email = form.email.trim(); |
| 122 | let email = (!email.is_empty()).then_some(email); |
| 123 | let hash = hash_password_async(form.password.clone()).await?; |
| 124 | // Atomic check-and-insert: if another request won the race, this is a 404 like the GET. |
| 125 | let Some(user) = state |
| 126 | .store |
| 127 | .create_first_admin(&username, email, &hash) |
| 128 | .await? |
| 129 | else { |
| 130 | return Err(not_found().into()); |
| 131 | }; |
| 132 | if let Some(address) = email { |
| 133 | // The first admin confirms the address like anyone else. Nothing waits on the mail. |
| 134 | crate::mail::account::send_verify(state, &user, address).await?; |
| 135 | } |
| 136 | audit::record( |
| 137 | &state.store, |
| 138 | Some(cx), |
| 139 | Actor::User(&user), |
| 140 | AuditAction::UserCreated, |
| 141 | Target { |
| 142 | kind: "user", |
| 143 | id: &user.id, |
| 144 | name: &user.username, |
| 145 | }, |
| 146 | serde_json::json!({ "setup": true, "admin": true }), |
| 147 | ) |
| 148 | .await; |
| 149 | login(cx, &user).await?; |
| 150 | clear_pre_csrf(cx); |
| 151 | Err(see_other("/").into()) |
| 152 | } |
| 153 | |
| 154 | #[cfg(test)] |
| 155 | mod tests { |
| 156 | use std::path::Path; |
| 157 | |
| 158 | use topcoat::router::request::Request; |
| 159 | use topcoat::router::{Body, Router, to_bytes}; |
| 160 | |
| 161 | use crate::state::AppState; |
| 162 | |
| 163 | async fn app(dir: &Path) -> (Router, AppState) { |
| 164 | let state = crate::test_support::test_state(dir).await; |
| 165 | (crate::test_support::app_router(state.clone()), state) |
| 166 | } |
| 167 | |
| 168 | async fn send( |
| 169 | router: &Router, |
| 170 | method: &str, |
| 171 | uri: &str, |
| 172 | cookie: Option<&str>, |
| 173 | form: Option<&str>, |
| 174 | ) -> (u16, http::HeaderMap, String) { |
| 175 | let mut builder = Request::builder().method(method).uri(uri); |
| 176 | if let Some(c) = cookie { |
| 177 | builder = builder.header("cookie", c); |
| 178 | } |
| 179 | let body = match form { |
| 180 | Some(f) => { |
| 181 | builder = builder.header("content-type", "application/x-www-form-urlencoded"); |
| 182 | Body::from(f.to_owned()) |
| 183 | } |
| 184 | None => Body::empty(), |
| 185 | }; |
| 186 | let res = router.handle(builder.body(body).unwrap()).await; |
| 187 | let status = res.status().as_u16(); |
| 188 | let headers = res.headers().clone(); |
| 189 | let bytes = to_bytes(res.into_body(), usize::MAX).await.unwrap(); |
| 190 | ( |
| 191 | status, |
| 192 | headers, |
| 193 | String::from_utf8_lossy(&bytes).into_owned(), |
| 194 | ) |
| 195 | } |
| 196 | |
| 197 | /// The value of a hidden `<input name=... value=...>` field. |
| 198 | fn field_from(html: &str, name: &str) -> String { |
| 199 | let marker = format!("name=\"{name}\" value=\""); |
| 200 | let start = html.find(&marker).unwrap_or_else(|| panic!("{name} field")) + marker.len(); |
| 201 | let end = html[start..].find('"').unwrap() + start; |
| 202 | html[start..end].to_owned() |
| 203 | } |
| 204 | |
| 205 | /// The `name=value` pair of a named Set-Cookie header, if present. |
| 206 | fn cookie_pair(headers: &http::HeaderMap, name: &str) -> Option<String> { |
| 207 | headers |
| 208 | .get_all("set-cookie") |
| 209 | .iter() |
| 210 | .filter_map(|v| v.to_str().ok()) |
| 211 | .find(|raw| raw.starts_with(name)) |
| 212 | .map(|raw| raw.split(';').next().unwrap().to_owned()) |
| 213 | } |
| 214 | |
| 215 | const PRECSRF_COOKIE: &str = "__Host-forge_precsrf"; |
| 216 | |
| 217 | #[tokio::test] |
| 218 | async fn setup_login_repo_flow_escapes_and_enforces_csrf() { |
| 219 | let tmp = tempfile::tempdir().expect("tempdir"); |
| 220 | let (router, state) = app(tmp.path()).await; |
| 221 | |
| 222 | // Setup is open while there are no users, and issues a pre-session CSRF cookie + field. |
| 223 | let (status, headers, html) = send(&router, "GET", "/setup", None, None).await; |
| 224 | assert_eq!(status, 200); |
| 225 | assert!(html.contains("Welcome to forge")); |
| 226 | assert!( |
| 227 | headers["content-security-policy"] |
| 228 | .to_str() |
| 229 | .unwrap() |
| 230 | .contains("frame-ancestors 'none'") |
| 231 | ); |
| 232 | let precsrf_cookie = cookie_pair(&headers, PRECSRF_COOKIE).expect("precsrf cookie"); |
| 233 | let precsrf = field_from(&html, "precsrf"); |
| 234 | assert_eq!(precsrf_cookie, format!("{PRECSRF_COOKIE}={precsrf}")); |
| 235 | let raw_precsrf = headers["set-cookie"].to_str().unwrap(); |
| 236 | assert!(raw_precsrf.contains("HttpOnly") && raw_precsrf.contains("SameSite=Strict")); |
| 237 | assert!(raw_precsrf.contains("Max-Age=3600") && raw_precsrf.contains("Secure")); |
| 238 | |
| 239 | // Without the matching token, setup is refused and creates nothing. |
| 240 | let creds = |
| 241 | "username=Alice&email=&password=correct-horse-battery&confirm=correct-horse-battery"; |
| 242 | let (status, _, _) = send( |
| 243 | &router, |
| 244 | "POST", |
| 245 | "/setup", |
| 246 | Some(&precsrf_cookie), |
| 247 | Some(creds), |
| 248 | ) |
| 249 | .await; |
| 250 | assert_eq!(status, 403); |
| 251 | let (status, _, _) = send( |
| 252 | &router, |
| 253 | "POST", |
| 254 | "/setup", |
| 255 | None, |
| 256 | Some(&format!("{creds}&precsrf={precsrf}")), |
| 257 | ) |
| 258 | .await; |
| 259 | assert_eq!(status, 403); |
| 260 | assert_eq!(state.store.user_count().await.unwrap(), 0); |
| 261 | |
| 262 | let (status, headers, body) = send( |
| 263 | &router, |
| 264 | "POST", |
| 265 | "/setup", |
| 266 | Some(&precsrf_cookie), |
| 267 | Some(&format!("{creds}&precsrf={precsrf}")), |
| 268 | ) |
| 269 | .await; |
| 270 | assert_eq!( |
| 271 | status, |
| 272 | 303, |
| 273 | "{}", |
| 274 | body.split("class=\"alert\"").nth(1).unwrap_or("no alert") |
| 275 | ); |
| 276 | assert_eq!(headers["location"], "/"); |
| 277 | let cookie = cookie_pair(&headers, "__Host-forge_session=").expect("session cookie"); |
| 278 | let set_cookie = headers |
| 279 | .get_all("set-cookie") |
| 280 | .iter() |
| 281 | .filter_map(|v| v.to_str().ok()) |
| 282 | .find(|v| v.starts_with("__Host-forge_session=")) |
| 283 | .expect("session set-cookie"); |
| 284 | assert!( |
| 285 | set_cookie.contains("HttpOnly") |
| 286 | && set_cookie.contains("Secure") |
| 287 | && set_cookie.contains("SameSite=Lax") |
| 288 | ); |
| 289 | |
| 290 | // Setup closes once a user exists. |
| 291 | let (status, _, _) = send(&router, "GET", "/setup", None, None).await; |
| 292 | assert_eq!(status, 404); |
| 293 | |
| 294 | // The pre-session cookie is cleared once setup succeeds. |
| 295 | assert!( |
| 296 | cookie_pair(&headers, PRECSRF_COOKIE) |
| 297 | .is_some_and(|p| p == format!("{PRECSRF_COOKIE}=")) |
| 298 | ); |
| 299 | |
| 300 | // The session cookie authenticates the dashboard. |
| 301 | let (status, _, home) = send(&router, "GET", "/", Some(&cookie), None).await; |
| 302 | assert_eq!(status, 200); |
| 303 | assert!(home.contains("Your repositories")); |
| 304 | let csrf = field_from(&home, "csrf"); |
| 305 | |
| 306 | // Wrong CSRF token is refused. |
| 307 | let (status, _, _) = send( |
| 308 | &router, |
| 309 | "POST", |
| 310 | "/new", |
| 311 | Some(&cookie), |
| 312 | Some("csrf=nope&name=site"), |
| 313 | ) |
| 314 | .await; |
| 315 | assert_eq!(status, 403); |
| 316 | |
| 317 | // Create a repo whose description is markup. It must come back escaped. |
| 318 | let description = "%3Cscript%3Ealert(1)%3C%2Fscript%3E"; |
| 319 | let form = format!( |
| 320 | "csrf={csrf}&name=site&description={description}&visibility=public&default_branch=main" |
| 321 | ); |
| 322 | let (status, headers, _) = send(&router, "POST", "/new", Some(&cookie), Some(&form)).await; |
| 323 | assert_eq!(status, 303); |
| 324 | let location = headers["location"].to_str().unwrap().to_owned(); |
| 325 | assert!(location.ends_with("/site"), "location {location}"); |
| 326 | |
| 327 | let owner = state |
| 328 | .store |
| 329 | .user_by_username("alice") |
| 330 | .await |
| 331 | .unwrap() |
| 332 | .unwrap(); |
| 333 | let repo = state |
| 334 | .store |
| 335 | .repo_by_owner_and_name("alice", "site") |
| 336 | .await |
| 337 | .unwrap() |
| 338 | .unwrap(); |
| 339 | assert_eq!(repo.owner_id, owner.id); |
| 340 | assert_eq!(repo.description, "<script>alert(1)</script>"); |
| 341 | |
| 342 | // The public repo page shows the escaped description and both clone URLs. |
| 343 | let (status, _, page) = send(&router, "GET", &location, None, None).await; |
| 344 | assert_eq!(status, 200); |
| 345 | assert!(page.contains("<script>alert(1)</script>")); |
| 346 | assert!(!page.contains("<script>alert(1)")); |
| 347 | assert!(page.contains("https://forge.test/Alice/site.git")); |
| 348 | assert!(page.contains("git@forge.test:Alice/site.git")); |
| 349 | assert!(page.contains("This repository is empty")); |
| 350 | |
| 351 | // Anonymous visitors cannot see the admin page. |
| 352 | let (status, _, _) = send(&router, "GET", "/admin/users", None, None).await; |
| 353 | assert_eq!(status, 404); |
| 354 | |
| 355 | // Logging out with a bad token is refused; with the right token the cookie is cleared. |
| 356 | let (status, _, _) = |
| 357 | send(&router, "POST", "/logout", Some(&cookie), Some("csrf=nope")).await; |
| 358 | assert_eq!(status, 403); |
| 359 | let (status, headers, _) = send( |
| 360 | &router, |
| 361 | "POST", |
| 362 | "/logout", |
| 363 | Some(&cookie), |
| 364 | Some(&format!("csrf={csrf}")), |
| 365 | ) |
| 366 | .await; |
| 367 | assert_eq!(status, 303); |
| 368 | assert!( |
| 369 | headers["set-cookie"] |
| 370 | .to_str() |
| 371 | .unwrap() |
| 372 | .contains("__Host-forge_session=") |
| 373 | ); |
| 374 | let (status, _, _) = send(&router, "GET", "/", Some(&cookie), None).await; |
| 375 | assert_eq!(status, 200); |
| 376 | |
| 377 | // Login is protected the same way: a mismatched pre-session token is refused. |
| 378 | let (status, headers, html) = send(&router, "GET", "/login", None, None).await; |
| 379 | assert_eq!(status, 200); |
| 380 | let login_cookie = cookie_pair(&headers, PRECSRF_COOKIE).expect("login precsrf cookie"); |
| 381 | let login_token = field_from(&html, "precsrf"); |
| 382 | let bad = "username=alice&password=correct-horse-battery&precsrf=nope"; |
| 383 | let (status, _, _) = send(&router, "POST", "/login", Some(&login_cookie), Some(bad)).await; |
| 384 | assert_eq!(status, 403); |
| 385 | |
| 386 | // With the matching token the login succeeds and the pre-session cookie is cleared. |
| 387 | let good = format!("username=alice&password=correct-horse-battery&precsrf={login_token}"); |
| 388 | let (status, headers, _) = |
| 389 | send(&router, "POST", "/login", Some(&login_cookie), Some(&good)).await; |
| 390 | assert_eq!(status, 303); |
| 391 | assert!(cookie_pair(&headers, "__Host-forge_session=").is_some()); |
| 392 | assert!( |
| 393 | cookie_pair(&headers, PRECSRF_COOKIE) |
| 394 | .is_some_and(|p| p == format!("{PRECSRF_COOKIE}=")) |
| 395 | ); |
| 396 | } |
| 397 | } |