fix(server): --session-cookie-secure flag; scope+char-count password; invalid-email test

This commit is contained in:
2026-06-02 15:16:46 +02:00
parent dbff95c2a9
commit 369eee4098
3 changed files with 31 additions and 14 deletions
+5 -1
View File
@@ -21,6 +21,10 @@ pub struct Config {
/// Send the session cookie with the `Secure` attribute (HTTPS-only). Disable /// Send the session cookie with the `Secure` attribute (HTTPS-only). Disable
/// only for plain-HTTP self-hosting behind no TLS at all. /// 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, pub cookie_secure: bool,
} }
+16 -13
View File
@@ -48,24 +48,27 @@ pub async fn serve(listener: TcpListener, state: AppState) -> anyhow::Result<()>
Ok(()) Ok(())
} }
/// Create a user from the CLI (admin bootstrap). Reads the password from the /// Create a user from the CLI (admin bootstrap). Opens its own connection (CLI
/// `BOOTSTRAP_PASSWORD` env var if set, otherwise prompts (hidden input). /// 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<()> { 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 email = Email::parse(email).map_err(|err| anyhow::anyhow!("{err}"))?;
let password = match std::env::var("BOOTSTRAP_PASSWORD") { // Read, validate, and hash the password in a scope so the plaintext `String` is
Ok(p) => p, // dropped before we open a connection / run any awaits.
Err(_) => rpassword::prompt_password("Password: ").context("reading password")?, 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) let db = Db::connect(database_url)
.await .await
.context("connecting to the database")?; .context("connecting to the database")?;
+10
View File
@@ -38,3 +38,13 @@ async fn create_user_persists_and_password_verifies(pool: PgPool) {
assert_eq!(user.role, Role::Admin); assert_eq!(user.role, Role::Admin);
assert!(auth::verify_password("bootstrap-pw-123", &stored_hash)); 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}");
}