mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-02 02:34:42 +08:00
fix(web): bind change-password to live sessions and cap auth bodies
This commit is contained in:
@@ -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(¤t_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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user