fix(server): move bcrypt to spawn_blocking, async file I/O, validate JWT_SECRET
Three independent hardening changes: 1. bcrypt on a blocking thread: hash() and verify() are CPU-bound (~300 ms at cost 12). Running them directly on an async task starved the Tokio runtime under concurrent load. Wrapped in spawn_blocking. 2. Async avatar file I/O: std::fs::write/rename/remove_file in an async handler blocks the executor. Replaced with tokio::fs equivalents. 3. JWT_SECRET minimum length: a secret shorter than 32 bytes is fatally weak. validate_jwt_secret() now rejects it at startup with a clear message rather than silently accepting it. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -39,6 +39,18 @@ use std::{
|
||||
str::FromStr,
|
||||
};
|
||||
|
||||
const JWT_SECRET_MIN_BYTES: usize = 32;
|
||||
|
||||
fn validate_jwt_secret(secret: &str) -> Result<(), String> {
|
||||
if secret.len() < JWT_SECRET_MIN_BYTES {
|
||||
Err(format!(
|
||||
"JWT_SECRET must be at least {JWT_SECRET_MIN_BYTES} bytes; generate a high-entropy production secret"
|
||||
))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
// Load .env file if present (silently ignored when absent).
|
||||
@@ -103,8 +115,13 @@ async fn run_reset_password(username: &str) {
|
||||
/// optionally `SERVER_PORT`) in the environment.
|
||||
async fn run_server() {
|
||||
let db_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
||||
// Load JWT_SECRET once at startup — a missing secret is a fatal configuration error.
|
||||
// Load JWT_SECRET once at startup — a missing or weak secret is a fatal
|
||||
// configuration error rather than a per-request failure.
|
||||
let jwt_secret = std::env::var("JWT_SECRET").expect("JWT_SECRET must be set");
|
||||
if let Err(msg) = validate_jwt_secret(&jwt_secret) {
|
||||
eprintln!("{msg}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
let port: u16 = std::env::var("SERVER_PORT")
|
||||
.unwrap_or_else(|_| "8080".into())
|
||||
.parse()
|
||||
@@ -137,3 +154,30 @@ async fn run_server() {
|
||||
|
||||
axum::serve(listener, app).await.expect("server error");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn jwt_secret_rejects_short_secret() {
|
||||
let secret = "x".repeat(JWT_SECRET_MIN_BYTES - 1);
|
||||
let err = validate_jwt_secret(&secret).expect_err("short secret must be rejected");
|
||||
assert!(
|
||||
err.contains("JWT_SECRET must be at least"),
|
||||
"error should explain the minimum length, got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jwt_secret_accepts_exact_minimum_length() {
|
||||
let secret = "x".repeat(JWT_SECRET_MIN_BYTES);
|
||||
assert!(validate_jwt_secret(&secret).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jwt_secret_accepts_longer_secret() {
|
||||
let secret = "x".repeat(JWT_SECRET_MIN_BYTES + 16);
|
||||
assert!(validate_jwt_secret(&secret).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user