fix(web): bind change-password to live sessions and cap auth bodies

This commit is contained in:
Jinpy
2026-09-29 16:24:43 +08:00
committed by GitHub
parent 1b760f9bdf
commit d55b4c966a
2 changed files with 46 additions and 8 deletions
+37 -5
View File
@@ -37,6 +37,18 @@ fn session_cookie_path(state: &WebState) -> &str {
state.public_base_path.as_str()
}
/// `Secure` is opt-in: LAN/HTTP deployments would otherwise never receive the
/// session cookie back. Reverse-proxy TLS deployments set DBX_WEB_COOKIE_SECURE=1.
fn cookie_secure_enabled() -> bool {
static SECURE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*SECURE.get_or_init(|| matches!(std::env::var("DBX_WEB_COOKIE_SECURE").ok().as_deref(), Some("1") | Some("true")))
}
fn session_cookie(state: &WebState, token: &str) -> String {
let secure = if cookie_secure_enabled() { "; Secure" } else { "" };
format!("dbx_session={token}; Path={}; HttpOnly; SameSite=Lax{secure}", session_cookie_path(state))
}
fn api_path_suffix<'a>(path: &'a str, public_base_path: &str) -> Option<&'a str> {
if let Some(suffix) = path.strip_prefix("/api/") {
return Some(suffix);
@@ -64,9 +76,12 @@ pub(crate) fn middleware_api_path_suffix<'a>(path: &'a str, public_base_path: &s
pub async fn login(State(state): State<Arc<WebState>>, Json(body): Json<LoginRequest>) -> Result<Response, StatusCode> {
let hash_guard = state.password_hash.read().await;
let hash_str = match hash_guard.as_deref() {
// No password configured: nothing to authenticate against. Answer
// explicitly instead of an ok:true that reads as "authenticated" —
// the API remains locked by the middleware until setup completes.
Some(h) => h.to_string(),
None => {
return Ok((StatusCode::OK, Json(serde_json::json!({"ok": true}))).into_response());
return Ok((StatusCode::FORBIDDEN, Json(serde_json::json!({"error": "setup_required"}))).into_response());
}
};
drop(hash_guard);
@@ -108,7 +123,7 @@ pub async fn login(State(state): State<Arc<WebState>>, Json(body): Json<LoginReq
let token = uuid::Uuid::new_v4().to_string();
state.sessions.write().await.insert(token.clone());
let cookie = format!("dbx_session={token}; Path={}; HttpOnly; SameSite=Lax", session_cookie_path(&state));
let cookie = session_cookie(&state, &token);
Ok((StatusCode::OK, [("set-cookie", cookie.as_str())], Json(serde_json::json!({"ok": true}))).into_response())
}
@@ -147,7 +162,7 @@ pub async fn setup(State(state): State<Arc<WebState>>, Json(body): Json<LoginReq
let token = uuid::Uuid::new_v4().to_string();
state.sessions.write().await.insert(token.clone());
let cookie = format!("dbx_session={token}; Path={}; HttpOnly; SameSite=Lax", session_cookie_path(&state));
let cookie = session_cookie(&state, &token);
Ok((StatusCode::OK, [("set-cookie", cookie.as_str())], Json(serde_json::json!({"ok": true}))).into_response())
}
@@ -168,8 +183,20 @@ pub async fn check(State(state): State<Arc<WebState>>, req: Request<axum::body::
pub async fn change_password(
State(state): State<Arc<WebState>>,
headers: axum::http::HeaderMap,
Json(body): Json<ChangePasswordRequest>,
) -> Result<Response, StatusCode> {
// Session-bound: this endpoint used to sit outside the auth middleware,
// giving unauthenticated callers an unlimited online guess at the admin
// password with none of login's lockout. It now requires a live session
// (defense in depth — the middleware gates it too) and, after a successful
// change, revokes every OTHER session so a stolen old cookie cannot survive
// a credential rotation.
let current_token = session_token_from_headers(&headers).ok_or(StatusCode::UNAUTHORIZED)?;
if !state.sessions.read().await.contains(&current_token) {
return Err(StatusCode::UNAUTHORIZED);
}
let hash_guard = state.password_hash.read().await;
let hash_str = match hash_guard.as_deref() {
Some(h) => h.to_string(),
@@ -195,6 +222,9 @@ pub async fn change_password(
state.app.storage.save_password_hash(&new_hash).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
*state.password_hash.write().await = Some(new_hash);
// Rotation revokes sibling sessions; only the caller stays logged in.
state.sessions.write().await.retain(|token| *token == current_token);
Ok((StatusCode::OK, Json(serde_json::json!({"ok": true}))).into_response())
}
@@ -230,9 +260,11 @@ pub async fn auth_middleware(
req: Request<axum::body::Body>,
next: Next,
) -> Response {
// Auth endpoints are always accessible.
// Only the bootstrap auth endpoints are reachable without a session —
// login/setup/check. change-password and logout require one: change-password
// must not be an unauthenticated password oracle.
let api_suffix = middleware_api_path_suffix(req.uri().path(), &state.public_base_path);
if api_suffix.is_some_and(|suffix| suffix.starts_with("auth/")) {
if api_suffix.is_some_and(|suffix| matches!(suffix, "auth/login" | "auth/setup" | "auth/check")) {
return next.run(req).await;
}
+9 -3
View File
@@ -15,6 +15,9 @@ use argon2::password_hash::rand_core::OsRng;
use argon2::password_hash::SaltString;
use argon2::{Argon2, PasswordHasher};
use axum::extract::DefaultBodyLimit;
/// Login/setup payloads are one password field; 64 KiB is generous.
const AUTH_BODY_LIMIT_BYTES: usize = 64 * 1024;
use axum::http::{Request, StatusCode, Uri};
use axum::middleware;
use axum::response::{IntoResponse, Redirect, Response};
@@ -507,10 +510,13 @@ async fn serve() {
.route("/database-backups/{id}/files/{index}", get(routes::scheduled_backup::download))
.route("/database-backups/{id}/files/{index}/restore", post(routes::scheduled_backup::prepare_restore))
// Auth
.route("/auth/login", post(auth::login))
// Auth payloads are tiny password strings: cap them far below the
// global limit so the extractor cannot buffer an unauthenticated DoS
// body before any rate limiting runs.
.route("/auth/login", post(auth::login).layer(DefaultBodyLimit::max(AUTH_BODY_LIMIT_BYTES)))
.route("/auth/check", get(auth::check))
.route("/auth/setup", post(auth::setup))
.route("/auth/change-password", post(auth::change_password))
.route("/auth/setup", post(auth::setup).layer(DefaultBodyLimit::max(AUTH_BODY_LIMIT_BYTES)))
.route("/auth/change-password", post(auth::change_password).layer(DefaultBodyLimit::max(AUTH_BODY_LIMIT_BYTES)))
.route("/auth/logout", post(auth::logout))
// Connection
.route("/connection/test", post(routes::connection::test_connection))