//! Page: first-run setup. Creates the initial admin while the user table is empty. use forge_core::AuditAction; use forge_core::names::validate_username; use serde::Deserialize; use topcoat::Result; use topcoat::context::{Cx, app_context}; use topcoat::router::content::Form; use topcoat::router::error::{forbidden, not_found, see_other}; use topcoat::router::page; use topcoat::view::{View, component, view}; use crate::audit::{self, Actor, Target}; use crate::auth::{check_pre_csrf, clear_pre_csrf, hash_password_async, login, pre_csrf_token}; use crate::i18n::{self, Key, Lang, password_too_long, password_too_short, t}; use crate::state::AppState; use crate::views::{alert, shell, user_facing_message}; const MIN_PASSWORD_CHARS: usize = 12; const MAX_PASSWORD_BYTES: usize = 1024; #[derive(Deserialize)] struct SetupForm { #[serde(default)] username: String, #[serde(default)] email: String, #[serde(default)] password: String, #[serde(default)] confirm: String, #[serde(default)] precsrf: String, } #[component] async fn setup_form( lang: Lang, username: &str, email: &str, precsrf: &str, error: Option, ) -> Result { Ok(view! {

(t(lang, Key::WelcomeToForge))

(t(lang, Key::SetupSub))

if let Some(message) = error { alert(message: message.as_str()) }
}) } #[page("/setup")] async fn setup_page(cx: &Cx) -> Result { let state = app_context::(cx); if state.store.user_count().await? != 0 { return Err(not_found().into()); } let precsrf = pre_csrf_token(cx); let lang = i18n::lang(cx, None); Ok(view! { shell(title: t(lang, Key::Setup), viewer: None, setup_form(lang: lang, username: "", email: "", precsrf: precsrf.as_str(), error: None)) }) } #[page(POST "/setup")] async fn setup_submit(cx: &Cx, Form(form): Form) -> Result { // Login CSRF: the pre-session token must match the cookie before anything else runs. if !check_pre_csrf(cx, &form.precsrf) { return Err(forbidden().into()); } let state = app_context::(cx); if state.store.user_count().await? != 0 { return Err(not_found().into()); } let lang = i18n::lang(cx, None); let problem = match validate_username(&form.username) { Err(e) => user_facing_message(&e), Ok(_) if form.password.chars().count() < MIN_PASSWORD_CHARS => { Some(password_too_short(lang, MIN_PASSWORD_CHARS)) } Ok(_) if form.password.len() > MAX_PASSWORD_BYTES => { Some(password_too_long(lang, MAX_PASSWORD_BYTES)) } Ok(_) if form.password != form.confirm => Some(t(lang, Key::ErrPasswordsDiffer).to_owned()), Ok(_) => None, }; if let Some(message) = problem { let (username, email) = (form.username.clone(), form.email.clone()); let precsrf = form.precsrf.clone(); return Ok(view! { 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))) }); } let (username, _) = validate_username(&form.username)?; let email = form.email.trim(); let email = (!email.is_empty()).then_some(email); let hash = hash_password_async(form.password.clone()).await?; // Atomic check-and-insert: if another request won the race, this is a 404 like the GET. let Some(user) = state .store .create_first_admin(&username, email, &hash) .await? else { return Err(not_found().into()); }; if let Some(address) = email { // The first admin confirms the address like anyone else. Nothing waits on the mail. crate::mail::account::send_verify(state, &user, address).await?; } audit::record( &state.store, Some(cx), Actor::User(&user), AuditAction::UserCreated, Target { kind: "user", id: &user.id, name: &user.username, }, serde_json::json!({ "setup": true, "admin": true }), ) .await; login(cx, &user).await?; clear_pre_csrf(cx); Err(see_other("/").into()) } #[cfg(test)] mod tests { use std::path::Path; use topcoat::router::request::Request; use topcoat::router::{Body, Router, to_bytes}; use crate::state::AppState; async fn app(dir: &Path) -> (Router, AppState) { let state = crate::test_support::test_state(dir).await; (crate::test_support::app_router(state.clone()), state) } async fn send( router: &Router, method: &str, uri: &str, cookie: Option<&str>, form: Option<&str>, ) -> (u16, http::HeaderMap, String) { let mut builder = Request::builder().method(method).uri(uri); if let Some(c) = cookie { builder = builder.header("cookie", c); } let body = match form { Some(f) => { builder = builder.header("content-type", "application/x-www-form-urlencoded"); Body::from(f.to_owned()) } None => Body::empty(), }; let res = router.handle(builder.body(body).unwrap()).await; let status = res.status().as_u16(); let headers = res.headers().clone(); let bytes = to_bytes(res.into_body(), usize::MAX).await.unwrap(); ( status, headers, String::from_utf8_lossy(&bytes).into_owned(), ) } /// The value of a hidden `` field. fn field_from(html: &str, name: &str) -> String { let marker = format!("name=\"{name}\" value=\""); let start = html.find(&marker).unwrap_or_else(|| panic!("{name} field")) + marker.len(); let end = html[start..].find('"').unwrap() + start; html[start..end].to_owned() } /// The `name=value` pair of a named Set-Cookie header, if present. fn cookie_pair(headers: &http::HeaderMap, name: &str) -> Option { headers .get_all("set-cookie") .iter() .filter_map(|v| v.to_str().ok()) .find(|raw| raw.starts_with(name)) .map(|raw| raw.split(';').next().unwrap().to_owned()) } const PRECSRF_COOKIE: &str = "__Host-forge_precsrf"; #[tokio::test] async fn setup_login_repo_flow_escapes_and_enforces_csrf() { let tmp = tempfile::tempdir().expect("tempdir"); let (router, state) = app(tmp.path()).await; // Setup is open while there are no users, and issues a pre-session CSRF cookie + field. let (status, headers, html) = send(&router, "GET", "/setup", None, None).await; assert_eq!(status, 200); assert!(html.contains("Welcome to forge")); assert!( headers["content-security-policy"] .to_str() .unwrap() .contains("frame-ancestors 'none'") ); let precsrf_cookie = cookie_pair(&headers, PRECSRF_COOKIE).expect("precsrf cookie"); let precsrf = field_from(&html, "precsrf"); assert_eq!(precsrf_cookie, format!("{PRECSRF_COOKIE}={precsrf}")); let raw_precsrf = headers["set-cookie"].to_str().unwrap(); assert!(raw_precsrf.contains("HttpOnly") && raw_precsrf.contains("SameSite=Strict")); assert!(raw_precsrf.contains("Max-Age=3600") && raw_precsrf.contains("Secure")); // Without the matching token, setup is refused and creates nothing. let creds = "username=Alice&email=&password=correct-horse-battery&confirm=correct-horse-battery"; let (status, _, _) = send( &router, "POST", "/setup", Some(&precsrf_cookie), Some(creds), ) .await; assert_eq!(status, 403); let (status, _, _) = send( &router, "POST", "/setup", None, Some(&format!("{creds}&precsrf={precsrf}")), ) .await; assert_eq!(status, 403); assert_eq!(state.store.user_count().await.unwrap(), 0); let (status, headers, body) = send( &router, "POST", "/setup", Some(&precsrf_cookie), Some(&format!("{creds}&precsrf={precsrf}")), ) .await; assert_eq!( status, 303, "{}", body.split("class=\"alert\"").nth(1).unwrap_or("no alert") ); assert_eq!(headers["location"], "/"); let cookie = cookie_pair(&headers, "__Host-forge_session=").expect("session cookie"); let set_cookie = headers .get_all("set-cookie") .iter() .filter_map(|v| v.to_str().ok()) .find(|v| v.starts_with("__Host-forge_session=")) .expect("session set-cookie"); assert!( set_cookie.contains("HttpOnly") && set_cookie.contains("Secure") && set_cookie.contains("SameSite=Lax") ); // Setup closes once a user exists. let (status, _, _) = send(&router, "GET", "/setup", None, None).await; assert_eq!(status, 404); // The pre-session cookie is cleared once setup succeeds. assert!( cookie_pair(&headers, PRECSRF_COOKIE) .is_some_and(|p| p == format!("{PRECSRF_COOKIE}=")) ); // The session cookie authenticates the dashboard. let (status, _, home) = send(&router, "GET", "/", Some(&cookie), None).await; assert_eq!(status, 200); assert!(home.contains("Your repositories")); let csrf = field_from(&home, "csrf"); // Wrong CSRF token is refused. let (status, _, _) = send( &router, "POST", "/new", Some(&cookie), Some("csrf=nope&name=site"), ) .await; assert_eq!(status, 403); // Create a repo whose description is markup. It must come back escaped. let description = "%3Cscript%3Ealert(1)%3C%2Fscript%3E"; let form = format!( "csrf={csrf}&name=site&description={description}&visibility=public&default_branch=main" ); let (status, headers, _) = send(&router, "POST", "/new", Some(&cookie), Some(&form)).await; assert_eq!(status, 303); let location = headers["location"].to_str().unwrap().to_owned(); assert!(location.ends_with("/site"), "location {location}"); let owner = state .store .user_by_username("alice") .await .unwrap() .unwrap(); let repo = state .store .repo_by_owner_and_name("alice", "site") .await .unwrap() .unwrap(); assert_eq!(repo.owner_id, owner.id); assert_eq!(repo.description, ""); // The public repo page shows the escaped description and both clone URLs. let (status, _, page) = send(&router, "GET", &location, None, None).await; assert_eq!(status, 200); assert!(page.contains("<script>alert(1)</script>")); assert!(!page.contains("