Harden os password (terminal windows and headless linux) anti brute force (#14985)

* fix(windows): terminal, preauth bruteforce

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(linux): headless, preauth bruteforce

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(linux): headless, OS login, minimal fix

Signed-off-by: fufesou <linlong1266@gmail.com>

* Terminal session, click-only

Signed-off-by: fufesou <linlong1266@gmail.com>

* Simple refactor, logs

Signed-off-by: fufesou <linlong1266@gmail.com>

* harden os password, better scoped failure set

Signed-off-by: fufesou <linlong1266@gmail.com>

* harden os password, ip failure count

Signed-off-by: fufesou <linlong1266@gmail.com>

* Check prelogin before starting cm

Signed-off-by: fufesou <linlong1266@gmail.com>

* Isolate terminal OS login failure tracking

Terminal OS login no longer reads or updates the default RustDesk
per-IP failure bucket. It now uses only the OS credential policy, while
RustDesk password attempts keep using the existing LOGIN_FAILURES[0]
bucket.

Signed-off-by: fufesou <linlong1266@gmail.com>

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
This commit is contained in:
fufesou
2026-05-11 12:58:01 +08:00
committed by GitHub
parent 9c831dc59b
commit 0e4b91b8d7
4 changed files with 633 additions and 69 deletions

View File

@@ -2,7 +2,7 @@ use super::{linux::*, ResultType};
use crate::client::{ use crate::client::{
LOGIN_MSG_DESKTOP_NO_DESKTOP, LOGIN_MSG_DESKTOP_SESSION_ANOTHER_USER, LOGIN_MSG_DESKTOP_NO_DESKTOP, LOGIN_MSG_DESKTOP_SESSION_ANOTHER_USER,
LOGIN_MSG_DESKTOP_SESSION_NOT_READY, LOGIN_MSG_DESKTOP_XORG_NOT_FOUND, LOGIN_MSG_DESKTOP_SESSION_NOT_READY, LOGIN_MSG_DESKTOP_XORG_NOT_FOUND,
LOGIN_MSG_DESKTOP_XSESSION_FAILED, LOGIN_MSG_DESKTOP_XSESSION_FAILED, LOGIN_MSG_PASSWORD_WRONG,
}; };
use hbb_common::{ use hbb_common::{
allow_err, bail, log, allow_err, bail, log,
@@ -94,6 +94,49 @@ fn detect_headless() -> Option<&'static str> {
None None
} }
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
enum XSessionStartErrorKind {
Auth,
Env,
}
const XSESSION_AUTH_FAILURE_DETAIL: &str = "authentication failed";
#[derive(Debug)]
struct XSessionStartError {
kind: XSessionStartErrorKind,
detail: String,
}
impl XSessionStartError {
fn auth(detail: String) -> Self {
Self {
kind: XSessionStartErrorKind::Auth,
detail,
}
}
fn env(detail: String) -> Self {
Self {
kind: XSessionStartErrorKind::Env,
detail,
}
}
}
impl std::fmt::Display for XSessionStartError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.detail)
}
}
fn map_xsession_start_error_to_login_msg(kind: XSessionStartErrorKind) -> &'static str {
match kind {
XSessionStartErrorKind::Auth => LOGIN_MSG_PASSWORD_WRONG,
XSessionStartErrorKind::Env => LOGIN_MSG_DESKTOP_XSESSION_FAILED,
}
}
pub fn try_start_desktop(_username: &str, _passsword: &str) -> String { pub fn try_start_desktop(_username: &str, _passsword: &str) -> String {
debug_assert!(crate::is_server()); debug_assert!(crate::is_server());
if _username.is_empty() { if _username.is_empty() {
@@ -136,14 +179,21 @@ pub fn try_start_desktop(_username: &str, _passsword: &str) -> String {
} }
} }
Err(e) => { Err(e) => {
match e.kind {
XSessionStartErrorKind::Auth => {
log::warn!("Failed to authenticate xsession user {}", e);
}
XSessionStartErrorKind::Env => {
log::error!("Failed to start xsession {}", e); log::error!("Failed to start xsession {}", e);
LOGIN_MSG_DESKTOP_XSESSION_FAILED.to_owned() }
}
map_xsession_start_error_to_login_msg(e.kind).to_owned()
} }
} }
} }
} }
fn try_start_x_session(username: &str, password: &str) -> ResultType<(String, bool)> { fn try_start_x_session(username: &str, password: &str) -> Result<(String, bool), XSessionStartError> {
let mut desktop_manager = DESKTOP_MANAGER.lock().unwrap(); let mut desktop_manager = DESKTOP_MANAGER.lock().unwrap();
if let Some(desktop_manager) = &mut (*desktop_manager) { if let Some(desktop_manager) = &mut (*desktop_manager) {
if let Some(seat0_username) = desktop_manager.get_supported_display_seat0_username() { if let Some(seat0_username) = desktop_manager.get_supported_display_seat0_username() {
@@ -161,7 +211,9 @@ fn try_start_x_session(username: &str, password: &str) -> ResultType<(String, bo
desktop_manager.is_running(), desktop_manager.is_running(),
)) ))
} else { } else {
bail!(crate::client::LOGIN_MSG_DESKTOP_NOT_INITED); Err(XSessionStartError::env(
crate::client::LOGIN_MSG_DESKTOP_NOT_INITED.to_owned(),
))
} }
} }
@@ -247,10 +299,15 @@ impl DesktopManager {
self.is_child_running.load(Ordering::SeqCst) self.is_child_running.load(Ordering::SeqCst)
} }
fn try_start_x_session(&mut self, username: &str, password: &str) -> ResultType<()> { fn try_start_x_session(
&mut self,
username: &str,
password: &str,
) -> Result<(), XSessionStartError> {
match get_user_by_name(username) { match get_user_by_name(username) {
Some(userinfo) => { Some(userinfo) => {
let mut client = pam::Client::with_password(&pam_get_service_name())?; let mut client = pam::Client::with_password(&pam_get_service_name())
.map_err(|e| XSessionStartError::env(format!("failed to init pam client, {}", e)))?;
client client
.conversation_mut() .conversation_mut()
.set_credentials(username, password); .set_credentials(username, password);
@@ -267,17 +324,24 @@ impl DesktopManager {
Ok(()) Ok(())
} }
Err(e) => { Err(e) => {
bail!("failed to start x session, {}", e); Err(XSessionStartError::env(format!(
"failed to start x session, {}",
e
)))
} }
} }
} }
Err(e) => { Err(_e) => {
bail!("failed to check user pass for {}, {}", username, e); Err(XSessionStartError::auth(
XSESSION_AUTH_FAILURE_DETAIL.to_owned(),
))
} }
} }
} }
None => { None => {
bail!("failed to get userinfo of {}", username); Err(XSessionStartError::auth(
XSESSION_AUTH_FAILURE_DETAIL.to_owned(),
))
} }
} }
} }

View File

@@ -67,6 +67,7 @@ pub mod input_service {
} }
mod connection; mod connection;
mod login_failure_check;
pub mod display_service; pub mod display_service;
#[cfg(windows)] #[cfg(windows)]
pub mod portable_service; pub mod portable_service;

View File

@@ -1,3 +1,8 @@
#[cfg(target_os = "windows")]
use super::login_failure_check::try_acquire_os_credential_login_gate;
use super::login_failure_check::{
evaluate_os_credential_policy, record_os_credential_failure, FailureScope,
};
use super::{input_service::*, *}; use super::{input_service::*, *};
#[cfg(feature = "unix-file-copy-paste")] #[cfg(feature = "unix-file-copy-paste")]
use crate::clipboard::try_empty_clipboard_files; use crate::clipboard::try_empty_clipboard_files;
@@ -82,6 +87,9 @@ lazy_static::lazy_static! {
static ref PENDING_SWITCH_SIDES_UUID: Arc::<Mutex<HashMap<String, (Instant, uuid::Uuid)>>> = Default::default(); static ref PENDING_SWITCH_SIDES_UUID: Arc::<Mutex<HashMap<String, (Instant, uuid::Uuid)>>> = Default::default();
} }
#[cfg(target_os = "windows")]
const TERMINAL_OS_LOGIN_FAILED_MSG: &str = "Incorrect username or password.";
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() { if a.len() != b.len() {
return false; return false;
@@ -94,6 +102,32 @@ fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
x == 0 x == 0
} }
#[cfg(target_os = "linux")]
fn should_check_linux_headless_os_auth_before_desktop_start(
is_headless_allowed: bool,
username: &str,
) -> bool {
is_headless_allowed
&& !username.trim().is_empty()
&& linux_desktop_manager::get_username().is_empty()
}
#[cfg(target_os = "linux")]
fn should_record_linux_headless_os_auth_failure(
is_headless_allowed: bool,
username: &str,
err_msg: &str,
) -> bool {
is_headless_allowed
&& !username.trim().is_empty()
&& err_msg == crate::client::LOGIN_MSG_PASSWORD_WRONG
}
#[cfg(not(any(target_os = "android", target_os = "ios")))]
fn should_use_terminal_os_login_scope(is_terminal: bool, os_login_username: &str) -> bool {
cfg!(target_os = "windows") && is_terminal && !os_login_username.trim().is_empty()
}
#[cfg(any(target_os = "windows", target_os = "linux"))] #[cfg(any(target_os = "windows", target_os = "linux"))]
lazy_static::lazy_static! { lazy_static::lazy_static! {
static ref WALLPAPER_REMOVER: Arc<Mutex<Option<WallPaperRemover>>> = Default::default(); static ref WALLPAPER_REMOVER: Arc<Mutex<Option<WallPaperRemover>>> = Default::default();
@@ -1497,6 +1531,9 @@ impl Connection {
// Keep the connection alive so the client can continue with 2FA. // Keep the connection alive so the client can continue with 2FA.
return true; return true;
} }
if let Some(keep_alive) = self.prepare_terminal_login_for_authorization().await {
return keep_alive;
}
if !self.connect_port_forward_if_needed().await { if !self.connect_port_forward_if_needed().await {
return false; return false;
} }
@@ -2376,33 +2413,6 @@ impl Connection {
o.terminal_persistent.enum_value() == Ok(BoolOption::Yes); o.terminal_persistent.enum_value() == Ok(BoolOption::Yes);
} }
self.terminal_service_id = terminal.service_id; self.terminal_service_id = terminal.service_id;
#[cfg(not(any(target_os = "android", target_os = "ios")))]
if let Some(msg) =
self.fill_terminal_user_token(&lr.os_login.username, &lr.os_login.password)
{
self.send_login_error(msg).await;
sleep(1.).await;
return false;
}
#[cfg(not(any(target_os = "android", target_os = "ios")))]
if let Some(is_user) =
terminal_service::is_service_specified_user(&self.terminal_service_id)
{
if let Some(user_token) = &self.terminal_user_token {
let has_service_token =
user_token.to_terminal_service_token().is_some();
if is_user != has_service_token {
// This occurs when the service id (in the configuration) is manually changed by the user, causing a mismatch in validation.
log::error!("Terminal service user mismatch detected. The service ID may have been manually changed in the configuration, causing validation to fail.");
// No need to translate the following message, because it is in an abnormal case.
self.send_login_error("Terminal service user mismatch detected.")
.await;
sleep(1.).await;
return false;
}
}
}
} }
Some(login_request::Union::PortForward(mut pf)) => { Some(login_request::Union::PortForward(mut pf)) => {
if !Self::permission(keys::OPTION_ENABLE_TUNNEL, &self.control_permissions) { if !Self::permission(keys::OPTION_ENABLE_TUNNEL, &self.control_permissions) {
@@ -2420,8 +2430,43 @@ impl Connection {
} }
} }
if !hbb_common::is_ip_str(&lr.username)
&& !hbb_common::is_domain_port_str(&lr.username)
&& lr.username != Config::get_id()
{
self.send_login_error(crate::client::LOGIN_MSG_OFFLINE)
.await;
return false;
}
#[cfg(target_os = "windows")]
if self.terminal
&& lr.os_login.username.trim().is_empty()
&& crate::platform::is_prelogin()
{
self.send_login_error(
"No active console user logged on, please connect and logon first.",
)
.await;
sleep(1.).await;
return false;
}
#[cfg(not(any(target_os = "android", target_os = "ios")))] #[cfg(not(any(target_os = "android", target_os = "ios")))]
if !should_use_terminal_os_login_scope(self.terminal, &lr.os_login.username) {
self.try_start_cm_ipc(); self.try_start_cm_ipc();
}
#[cfg(target_os = "linux")]
if should_check_linux_headless_os_auth_before_desktop_start(
self.linux_headless_handle.is_headless_allowed,
&lr.os_login.username,
) {
let (_failure, res) = self.check_failure(0).await;
if !res {
return true;
}
}
#[cfg(not(target_os = "linux"))] #[cfg(not(target_os = "linux"))]
let err_msg = "".to_owned(); let err_msg = "".to_owned();
@@ -2433,6 +2478,18 @@ impl Connection {
// If err is LOGIN_MSG_DESKTOP_SESSION_NOT_READY, just keep this msg and go on checking password. // If err is LOGIN_MSG_DESKTOP_SESSION_NOT_READY, just keep this msg and go on checking password.
if !err_msg.is_empty() && err_msg != crate::client::LOGIN_MSG_DESKTOP_SESSION_NOT_READY if !err_msg.is_empty() && err_msg != crate::client::LOGIN_MSG_DESKTOP_SESSION_NOT_READY
{ {
#[cfg(target_os = "linux")]
if should_record_linux_headless_os_auth_failure(
self.linux_headless_handle.is_headless_allowed,
&lr.os_login.username,
&err_msg,
) {
let (failure, res) = self.check_failure(0).await;
if !res {
return true;
}
self.update_failure(failure, false, 0);
}
self.send_login_error(err_msg).await; self.send_login_error(err_msg).await;
return true; return true;
} }
@@ -2461,17 +2518,16 @@ impl Connection {
crate::get_builtin_option(keys::OPTION_ALLOW_LOGON_SCREEN_PASSWORD) == "Y" crate::get_builtin_option(keys::OPTION_ALLOW_LOGON_SCREEN_PASSWORD) == "Y"
&& is_logon(); && is_logon();
if !hbb_common::is_ip_str(&lr.username) if (password::approve_mode() == ApproveMode::Click && !allow_logon_screen_password)
&& !hbb_common::is_domain_port_str(&lr.username)
&& lr.username != Config::get_id()
{
self.send_login_error(crate::client::LOGIN_MSG_OFFLINE)
.await;
return false;
} else if (password::approve_mode() == ApproveMode::Click
&& !allow_logon_screen_password)
|| password::approve_mode() == ApproveMode::Both && !password::has_valid_password() || password::approve_mode() == ApproveMode::Both && !password::has_valid_password()
{ {
#[cfg(not(any(target_os = "android", target_os = "ios")))]
if should_use_terminal_os_login_scope(self.terminal, &lr.os_login.username) {
if let Some(keep_alive) = self.prepare_terminal_login_for_authorization().await
{
return keep_alive;
}
}
self.try_start_cm(lr.my_id, lr.my_name, false); self.try_start_cm(lr.my_id, lr.my_name, false);
if hbb_common::get_version_number(&lr.version) if hbb_common::get_version_number(&lr.version)
>= hbb_common::get_version_number("1.2.0") >= hbb_common::get_version_number("1.2.0")
@@ -2493,6 +2549,14 @@ impl Connection {
} }
} else if lr.password.is_empty() { } else if lr.password.is_empty() {
if err_msg.is_empty() { if err_msg.is_empty() {
#[cfg(not(any(target_os = "android", target_os = "ios")))]
if should_use_terminal_os_login_scope(self.terminal, &lr.os_login.username) {
if let Some(keep_alive) =
self.prepare_terminal_login_for_authorization().await
{
return keep_alive;
}
}
self.try_start_cm(lr.my_id, lr.my_name, false); self.try_start_cm(lr.my_id, lr.my_name, false);
} else { } else {
self.send_login_error( self.send_login_error(
@@ -2506,7 +2570,7 @@ impl Connection {
return true; return true;
} }
if !self.validate_password(allow_logon_screen_password) { if !self.validate_password(allow_logon_screen_password) {
self.update_failure(failure, false, 0); self.update_failure_with_scope(failure, false, 0, FailureScope::Default);
self.check_update_temporary_password(false); self.check_update_temporary_password(false);
if err_msg.is_empty() { if err_msg.is_empty() {
self.send_login_error(crate::client::LOGIN_MSG_PASSWORD_WRONG) self.send_login_error(crate::client::LOGIN_MSG_PASSWORD_WRONG)
@@ -2519,7 +2583,7 @@ impl Connection {
.await; .await;
} }
} else { } else {
self.update_failure(failure, true, 0); self.update_failure_with_scope(failure, true, 0, FailureScope::Default);
if err_msg.is_empty() { if err_msg.is_empty() {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
self.linux_headless_handle.wait_desktop_cm_ready().await; self.linux_headless_handle.wait_desktop_cm_ready().await;
@@ -3484,16 +3548,16 @@ impl Connection {
self.terminal_user_token = Some(TerminalUserToken::SelfUser); self.terminal_user_token = Some(TerminalUserToken::SelfUser);
None None
} else { } else {
Some("The user is not an administrator.") Some(TERMINAL_OS_LOGIN_FAILED_MSG)
} }
} }
Ok(Err(e)) => { Ok(Err(e)) => {
log::error!("Failed to check if the user is an administrator: {}", e); log::error!("Failed to check if the user is an administrator: {}", e);
Some("Failed to check if the user is an administrator.") Some(TERMINAL_OS_LOGIN_FAILED_MSG)
} }
Err(e) => { Err(e) => {
log::error!("Failed to get logon user token: {}", e); log::error!("Failed to get logon user token: {}", e);
Some("Incorrect username or password.") Some(TERMINAL_OS_LOGIN_FAILED_MSG)
} }
} }
} }
@@ -3529,6 +3593,146 @@ impl Connection {
} }
} }
#[cfg(not(any(target_os = "android", target_os = "ios")))]
async fn prepare_terminal_login_for_authorization(&mut self) -> Option<bool> {
if !self.terminal || self.terminal_user_token.is_some() {
return None;
}
#[derive(Copy, Clone)]
enum TerminalAuthorizationMode {
OsLogin {
failure: ((i32, i32, i32), i32),
scope: FailureScope,
},
SessionUser,
}
let normalized_username = self.lr.os_login.username.trim().to_owned();
let auth_mode = if should_use_terminal_os_login_scope(self.terminal, &normalized_username) {
// Check failure state
let failure_scope = FailureScope::TerminalOsLogin;
let (failure, res) = self.check_failure_with_scope(0, failure_scope).await;
if !res {
log::warn!(
"OS credential login blocked by failure policy: ip={} conn_id={} scope={:?}",
self.ip,
self.inner.id(),
failure_scope
);
// Terminal OS login is sensitive. Close this connection instead of keeping it
// alive for retries on the same socket after a rate-limit block.
return Some(false);
}
TerminalAuthorizationMode::OsLogin {
failure,
scope: failure_scope,
}
} else {
TerminalAuthorizationMode::SessionUser
};
let is_terminal_os_login = matches!(auth_mode, TerminalAuthorizationMode::OsLogin { .. });
let failure_scope = match auth_mode {
TerminalAuthorizationMode::OsLogin { scope, .. } => scope,
TerminalAuthorizationMode::SessionUser => FailureScope::Default,
};
let username = normalized_username;
let password = self.lr.os_login.password.clone();
let terminal_login_error = {
#[cfg(target_os = "windows")]
{
// Concurrency gate for terminal OS login with credentials, to prevent brute-force attacks.
let _os_login_concurrency_guard = if is_terminal_os_login {
let guard = try_acquire_os_credential_login_gate();
if guard.is_err() {
log::warn!(
"OS credential login blocked by concurrency gate: ip={} conn_id={} scope={:?}",
self.ip,
self.inner.id(),
failure_scope
);
self.send_login_error("Please try 1 minute later").await;
sleep(1.).await;
Self::post_alarm_audit(
AlarmAuditType::TerminalOsLoginConcurrency,
json!({
"ip": self.ip,
"id": self.lr.my_id.clone(),
"name": self.lr.my_name.clone(),
}),
);
return Some(false);
}
guard.ok()
} else {
None
};
self.fill_terminal_user_token(&username, &password)
}
#[cfg(not(target_os = "windows"))]
{
self.fill_terminal_user_token(&username, &password)
}
};
if let Some(msg) = terminal_login_error {
if let TerminalAuthorizationMode::OsLogin { failure, scope } = auth_mode {
self.update_failure_with_scope(failure, false, 0, scope);
}
let auth_context = if is_terminal_os_login {
"OS credential login verification"
} else {
"Terminal session-user authorization"
};
log::warn!(
"{} failed: ip={} conn_id={} scope={:?} msg='{}'",
auth_context,
self.ip,
self.inner.id(),
failure_scope,
msg
);
self.send_login_error(msg).await;
sleep(1.).await;
return Some(false);
}
if let TerminalAuthorizationMode::OsLogin { failure, scope } = auth_mode {
self.update_failure_with_scope(failure, true, 0, scope);
}
if let Some(is_user) =
terminal_service::is_service_specified_user(&self.terminal_service_id)
{
if let Some(user_token) = &self.terminal_user_token {
let has_service_token = user_token.to_terminal_service_token().is_some();
if is_user != has_service_token {
log::error!(
"Terminal service user mismatch: ip={} conn_id={} service_is_user={} has_service_token={}. The service ID may have been manually changed in the configuration, causing validation to fail.",
self.ip,
self.inner.id(),
is_user,
has_service_token
);
// No need to translate the following message, because it is in an abnormal case.
self.send_login_error("Terminal service user mismatch detected.")
.await;
sleep(1.).await;
return Some(false);
}
}
}
if is_terminal_os_login {
self.try_start_cm_ipc();
}
None
}
#[cfg(any(target_os = "android", target_os = "ios"))]
async fn prepare_terminal_login_for_authorization(&mut self) -> Option<bool> {
None
}
// Try to parse connection IP as IPv6 address, returning /64, /56, and /48 prefixes. // Try to parse connection IP as IPv6 address, returning /64, /56, and /48 prefixes.
// Parsing an IPv4 address just returns None. // Parsing an IPv4 address just returns None.
// note: we specifically don't use hbb_common::is_ipv6_str to avoid divergence issues // note: we specifically don't use hbb_common::is_ipv6_str to avoid divergence issues
@@ -3555,8 +3759,7 @@ impl Connection {
Some((p64, p56, p48)) Some((p64, p56, p48))
} }
fn update_failure(&self, (failure, time): ((i32, i32, i32), i32), remove: bool, i: usize) { fn bump_failure_entry(mut cur: (i32, i32, i32), time: i32) -> (i32, i32, i32) {
fn bump(mut cur: (i32, i32, i32), time: i32) -> (i32, i32, i32) {
if cur.0 == time { if cur.0 == time {
cur.1 += 1; cur.1 += 1;
cur.2 += 1; cur.2 += 1;
@@ -3567,6 +3770,26 @@ impl Connection {
} }
cur cur
} }
fn update_failure(&self, failure: ((i32, i32, i32), i32), remove: bool, i: usize) {
self.update_failure_with_scope(failure, remove, i, FailureScope::Default);
}
fn update_failure_with_scope(
&self,
(failure, time): ((i32, i32, i32), i32),
remove: bool,
i: usize,
scope: FailureScope,
) {
let os_credential_scope = matches!(scope, FailureScope::TerminalOsLogin);
if os_credential_scope {
if !remove {
record_os_credential_failure(scope);
}
return;
}
let map_mutex = &LOGIN_FAILURES[i]; let map_mutex = &LOGIN_FAILURES[i];
if remove { if remove {
if failure.0 != 0 { if failure.0 != 0 {
@@ -3587,14 +3810,15 @@ impl Connection {
let mut m = map_mutex.lock().unwrap(); let mut m = map_mutex.lock().unwrap();
for key in [p64, p56, p48] { for key in [p64, p56, p48] {
let cur = m.get(&key).copied().unwrap_or((0, 0, 0)); let cur = m.get(&key).copied().unwrap_or((0, 0, 0));
m.insert(key, bump(cur, time)); m.insert(key, Self::bump_failure_entry(cur, time));
} }
// Update full IP: bump from the *original* passed-in failure let current_ip = m.get(&self.ip).copied().unwrap_or((0, 0, 0));
m.insert(self.ip.clone(), bump(failure, time)); m.insert(self.ip.clone(), Self::bump_failure_entry(current_ip, time));
} else { } else {
// Update full IP: bump from the *original* passed-in failure // Re-read the full IP bucket in case another failed attempt updated it.
let mut m = map_mutex.lock().unwrap(); let mut m = map_mutex.lock().unwrap();
m.insert(self.ip.clone(), bump(failure, time)); let current_ip = m.get(&self.ip).copied().unwrap_or((0, 0, 0));
m.insert(self.ip.clone(), Self::bump_failure_entry(current_ip, time));
} }
} }
@@ -3634,8 +3858,50 @@ impl Connection {
} }
async fn check_failure(&mut self, i: usize) -> (((i32, i32, i32), i32), bool) { async fn check_failure(&mut self, i: usize) -> (((i32, i32, i32), i32), bool) {
self.check_failure_with_scope(i, FailureScope::Default)
.await
}
async fn check_failure_with_scope(
&mut self,
i: usize,
scope: FailureScope,
) -> (((i32, i32, i32), i32), bool) {
let time = (get_time() / 60_000) as i32; let time = (get_time() / 60_000) as i32;
if matches!(scope, FailureScope::TerminalOsLogin) {
let decision = evaluate_os_credential_policy(scope, get_time());
let res = if decision.allowed {
true
} else {
log::warn!(
"OS credential login blocked by policy: ip={} conn_id={} i={} msg='{}'",
self.ip,
self.inner.id(),
i,
decision.login_error.as_deref().unwrap_or("")
);
if let Some(login_error) = decision.login_error {
// Rare branch and currently temporary response copy; translation can be added later if needed.
self.send_login_error(login_error).await;
}
if let Some(audit) = decision.audit {
// For OS blocked/backoff events, we currently emit one alarm report per blocked attempt.
// TODO: Add unified cumulative/aggregation fields across alarm producers.
Self::post_alarm_audit(
audit,
json!({
"ip": self.ip,
"id": self.lr.my_id.clone(),
"name": self.lr.my_name.clone(),
}),
);
}
false
};
return (((0, 0, 0), time), res);
}
// IPv6 addresses are cheap to make so we check prefix/netblock as well // IPv6 addresses are cheap to make so we check prefix/netblock as well
if let Some((p64, p56, p48)) = self.get_ipv6_prefixes() { if let Some((p64, p56, p48)) = self.get_ipv6_prefixes() {
if let Some(res) = self.check_failure_ipv6_prefix(i, time, &p64, 64, 60).await { if let Some(res) = self.check_failure_ipv6_prefix(i, time, &p64, 64, 60).await {
@@ -5219,6 +5485,8 @@ pub enum AlarmAuditType {
// MultipleLoginsAttemptsWithinOneMinute = 4, // MultipleLoginsAttemptsWithinOneMinute = 4,
// MultipleLoginsAttemptsWithinOneHour = 5, // MultipleLoginsAttemptsWithinOneHour = 5,
ExceedIPv6PrefixAttempts = 6, ExceedIPv6PrefixAttempts = 6,
TerminalOsLoginBackoff = 7,
TerminalOsLoginConcurrency = 8,
} }
pub enum FileAuditType { pub enum FileAuditType {

View File

@@ -0,0 +1,231 @@
use crate::AlarmAuditType;
use hbb_common::get_time;
#[cfg(target_os = "windows")]
use hbb_common::tokio::sync::{Mutex as TokioMutex, OwnedMutexGuard};
use std::sync::Mutex;
#[cfg(target_os = "windows")]
use std::sync::Arc;
const OS_CREDENTIAL_LOGIN_TOTAL_IDLE_RESET_MS: i64 = 120 * 60 * 1_000;
const OS_CREDENTIAL_LOGIN_BACKOFF_BASE_SECONDS: i64 = 15;
const OS_CREDENTIAL_LOGIN_BACKOFF_MAX_SECONDS: i64 = 30 * 60;
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub(crate) enum FailureScope {
Default,
TerminalOsLogin,
}
pub(crate) struct OsCredentialPolicyDecision {
pub allowed: bool,
pub login_error: Option<String>,
pub audit: Option<AlarmAuditType>,
}
#[derive(Copy, Clone, Debug, Default)]
struct OsCredentialFailureState {
total_failures: i32,
backoff_until_ms: Option<i64>,
last_failure_ms: Option<i64>,
}
lazy_static::lazy_static! {
static ref OS_CREDENTIAL_LOGIN_FAILURE_STATE: Mutex<OsCredentialFailureState> =
Mutex::new(OsCredentialFailureState::default());
}
#[cfg(target_os = "windows")]
lazy_static::lazy_static! {
static ref OS_CREDENTIAL_LOGIN_MUTEX: Arc<TokioMutex<()>> = Arc::new(TokioMutex::new(()));
}
fn is_os_credential_scope(scope: FailureScope) -> bool {
matches!(scope, FailureScope::TerminalOsLogin)
}
fn state_for_os_credential_scope(
scope: FailureScope,
) -> Option<&'static Mutex<OsCredentialFailureState>> {
if is_os_credential_scope(scope) {
Some(&OS_CREDENTIAL_LOGIN_FAILURE_STATE)
} else {
None
}
}
fn backoff_audit_type_for_scope(scope: FailureScope) -> Option<AlarmAuditType> {
match scope {
FailureScope::TerminalOsLogin => Some(AlarmAuditType::TerminalOsLoginBackoff),
FailureScope::Default => None,
}
}
fn os_credential_login_backoff_seconds(total_failures: i32) -> i64 {
if total_failures <= 2 {
return 0;
}
let exp = (total_failures - 3).min(7);
let seconds = OS_CREDENTIAL_LOGIN_BACKOFF_BASE_SECONDS * (1_i64 << exp);
seconds.min(OS_CREDENTIAL_LOGIN_BACKOFF_MAX_SECONDS)
}
fn normalize_backoff(state: &mut OsCredentialFailureState, now_ms: i64) {
if let Some(until_ms) = state.backoff_until_ms {
if until_ms <= now_ms {
state.backoff_until_ms = None;
}
}
}
fn reset_totals_on_idle(state: &mut OsCredentialFailureState, now_ms: i64) {
if let Some(last_ms) = state.last_failure_ms {
if now_ms.saturating_sub(last_ms) >= OS_CREDENTIAL_LOGIN_TOTAL_IDLE_RESET_MS {
state.total_failures = 0;
state.backoff_until_ms = None;
state.last_failure_ms = None;
}
}
}
fn allow_decision() -> OsCredentialPolicyDecision {
OsCredentialPolicyDecision {
allowed: true,
login_error: None,
audit: None,
}
}
fn block_decision(
login_error: String,
alarm_type: Option<AlarmAuditType>,
) -> OsCredentialPolicyDecision {
OsCredentialPolicyDecision {
allowed: false,
login_error: Some(login_error),
audit: alarm_type,
}
}
pub(crate) fn evaluate_os_credential_policy(
scope: FailureScope,
now_ms: i64,
) -> OsCredentialPolicyDecision {
if !is_os_credential_scope(scope) {
return allow_decision();
}
let Some(state_mutex) = state_for_os_credential_scope(scope) else {
return allow_decision();
};
let mut state = state_mutex.lock().unwrap();
reset_totals_on_idle(&mut state, now_ms);
normalize_backoff(&mut state, now_ms);
if let Some(until_ms) = state.backoff_until_ms {
let remaining_ms = (until_ms - now_ms).max(0);
let remaining_seconds = ((remaining_ms + 999) / 1_000).max(1);
let seconds_label = if remaining_seconds == 1 {
"second"
} else {
"seconds"
};
block_decision(
format!(
"Please try again in {} {}.",
remaining_seconds, seconds_label
),
backoff_audit_type_for_scope(scope),
)
} else {
allow_decision()
}
}
pub(crate) fn record_os_credential_failure(scope: FailureScope) {
if !is_os_credential_scope(scope) {
return;
}
let Some(state_mutex) = state_for_os_credential_scope(scope) else {
return;
};
let mut state = state_mutex.lock().unwrap();
let now_ms = get_time();
reset_totals_on_idle(&mut state, now_ms);
normalize_backoff(&mut state, now_ms);
state.total_failures = state.total_failures.saturating_add(1);
state.last_failure_ms = Some(now_ms);
let backoff_seconds = os_credential_login_backoff_seconds(state.total_failures);
if backoff_seconds > 0 {
state.backoff_until_ms = Some(now_ms + backoff_seconds * 1_000);
}
}
#[cfg(target_os = "windows")]
pub(crate) fn try_acquire_os_credential_login_gate() -> Result<OwnedMutexGuard<()>, ()> {
OS_CREDENTIAL_LOGIN_MUTEX
.clone()
.try_lock_owned()
.map_err(|_| ())
}
#[cfg(test)]
mod tests {
use super::*;
static TEST_MUTEX: Mutex<()> = Mutex::new(());
fn clear_os_credential_failure_state(scope: FailureScope) {
if let Some(state_mutex) = state_for_os_credential_scope(scope) {
*state_mutex.lock().unwrap() = OsCredentialFailureState::default();
}
}
#[test]
fn os_credential_policy_prioritizes_backoff() {
let _guard = TEST_MUTEX.lock().unwrap();
clear_os_credential_failure_state(FailureScope::TerminalOsLogin);
let now_ms = get_time();
for _ in 0..3 {
record_os_credential_failure(FailureScope::TerminalOsLogin);
}
let decision = evaluate_os_credential_policy(FailureScope::TerminalOsLogin, now_ms);
assert!(!decision.allowed);
assert!(decision.login_error.is_some());
clear_os_credential_failure_state(FailureScope::TerminalOsLogin);
}
#[test]
fn os_credential_policy_idle_window_resets_total_counter() {
let _guard = TEST_MUTEX.lock().unwrap();
clear_os_credential_failure_state(FailureScope::TerminalOsLogin);
for _ in 0..13 {
record_os_credential_failure(FailureScope::TerminalOsLogin);
}
let blocked = evaluate_os_credential_policy(FailureScope::TerminalOsLogin, get_time());
assert!(!blocked.allowed);
let after_failures_ms = get_time();
let after_idle_ms = after_failures_ms + OS_CREDENTIAL_LOGIN_TOTAL_IDLE_RESET_MS + 1_000;
let allowed = evaluate_os_credential_policy(FailureScope::TerminalOsLogin, after_idle_ms);
assert!(allowed.allowed);
clear_os_credential_failure_state(FailureScope::TerminalOsLogin);
}
#[test]
fn os_credential_policy_audits_every_backoff_block() {
let _guard = TEST_MUTEX.lock().unwrap();
clear_os_credential_failure_state(FailureScope::TerminalOsLogin);
for _ in 0..3 {
record_os_credential_failure(FailureScope::TerminalOsLogin);
}
let now_ms = get_time();
let first = evaluate_os_credential_policy(FailureScope::TerminalOsLogin, now_ms);
let second = evaluate_os_credential_policy(FailureScope::TerminalOsLogin, now_ms + 1_000);
assert!(!first.allowed);
assert!(!second.allowed);
assert!(first.audit.is_some());
assert!(second.audit.is_some());
clear_os_credential_failure_state(FailureScope::TerminalOsLogin);
}
}