diff --git a/crates/server/src/config.rs b/crates/server/src/config.rs index 7127a22..458f2be 100644 --- a/crates/server/src/config.rs +++ b/crates/server/src/config.rs @@ -21,6 +21,10 @@ pub struct Config { /// Send the session cookie with the `Secure` attribute (HTTPS-only). Disable /// only for plain-HTTP self-hosting behind no TLS at all. - #[arg(long, env = "SESSION_COOKIE_SECURE", default_value_t = true)] + #[arg( + long = "session-cookie-secure", + env = "SESSION_COOKIE_SECURE", + default_value_t = true + )] pub cookie_secure: bool, } diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index 13ef962..0d5c3dd 100644 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -48,24 +48,27 @@ pub async fn serve(listener: TcpListener, state: AppState) -> anyhow::Result<()> Ok(()) } -/// Create a user from the CLI (admin bootstrap). Reads the password from the -/// `BOOTSTRAP_PASSWORD` env var if set, otherwise prompts (hidden input). +/// Create a user from the CLI (admin bootstrap). Opens its own connection (CLI +/// one-shot); reads the password from the `BOOTSTRAP_PASSWORD` env var if set, +/// otherwise prompts (hidden input). The plaintext is not zeroized, but it is +/// confined to the scope below and dropped before any network I/O. pub async fn create_user(database_url: &str, email: &str, role: Role) -> anyhow::Result<()> { let email = Email::parse(email).map_err(|err| anyhow::anyhow!("{err}"))?; - let password = match std::env::var("BOOTSTRAP_PASSWORD") { - Ok(p) => p, - Err(_) => rpassword::prompt_password("Password: ").context("reading password")?, + // Read, validate, and hash the password in a scope so the plaintext `String` is + // dropped before we open a connection / run any awaits. + let password_hash = { + let password = match std::env::var("BOOTSTRAP_PASSWORD") { + Ok(p) => p, + Err(_) => rpassword::prompt_password("Password: ").context("reading password")?, + }; + anyhow::ensure!( + password.chars().count() >= 8, + "password must be at least 8 characters" + ); + auth::hash_password(&password).map_err(|err| anyhow::anyhow!("hashing password: {err}"))? }; - anyhow::ensure!( - password.len() >= 8, - "password must be at least 8 characters" - ); - - let password_hash = - auth::hash_password(&password).map_err(|err| anyhow::anyhow!("hashing password: {err}"))?; - let db = Db::connect(database_url) .await .context("connecting to the database")?; diff --git a/crates/server/tests/create_user.rs b/crates/server/tests/create_user.rs index ac06e68..a3df21f 100644 --- a/crates/server/tests/create_user.rs +++ b/crates/server/tests/create_user.rs @@ -38,3 +38,13 @@ async fn create_user_persists_and_password_verifies(pool: PgPool) { assert_eq!(user.role, Role::Admin); assert!(auth::verify_password("bootstrap-pw-123", &stored_hash)); } + +#[tokio::test] +async fn create_user_rejects_invalid_email() { + // The email is parsed before the password is read or the DB is touched, so an + // invalid email errors out without reaching the (unreachable) database URL. + let err = server::create_user("postgres://unused", "not-an-email", Role::Admin) + .await + .unwrap_err(); + assert!(err.to_string().contains("email"), "got: {err}"); +}