mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-05-12 00:58:09 +03:00
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:
@@ -2,7 +2,7 @@ use super::{linux::*, ResultType};
|
||||
use crate::client::{
|
||||
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_XSESSION_FAILED,
|
||||
LOGIN_MSG_DESKTOP_XSESSION_FAILED, LOGIN_MSG_PASSWORD_WRONG,
|
||||
};
|
||||
use hbb_common::{
|
||||
allow_err, bail, log,
|
||||
@@ -94,6 +94,49 @@ fn detect_headless() -> Option<&'static str> {
|
||||
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 {
|
||||
debug_assert!(crate::is_server());
|
||||
if _username.is_empty() {
|
||||
@@ -136,14 +179,21 @@ pub fn try_start_desktop(_username: &str, _passsword: &str) -> String {
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to start xsession {}", e);
|
||||
LOGIN_MSG_DESKTOP_XSESSION_FAILED.to_owned()
|
||||
match e.kind {
|
||||
XSessionStartErrorKind::Auth => {
|
||||
log::warn!("Failed to authenticate xsession user {}", e);
|
||||
}
|
||||
XSessionStartErrorKind::Env => {
|
||||
log::error!("Failed to start xsession {}", e);
|
||||
}
|
||||
}
|
||||
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();
|
||||
if let Some(desktop_manager) = &mut (*desktop_manager) {
|
||||
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(),
|
||||
))
|
||||
} 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)
|
||||
}
|
||||
|
||||
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) {
|
||||
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
|
||||
.conversation_mut()
|
||||
.set_credentials(username, password);
|
||||
@@ -267,17 +324,24 @@ impl DesktopManager {
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
bail!("failed to start x session, {}", e);
|
||||
Err(XSessionStartError::env(format!(
|
||||
"failed to start x session, {}",
|
||||
e
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
bail!("failed to check user pass for {}, {}", username, e);
|
||||
Err(_e) => {
|
||||
Err(XSessionStartError::auth(
|
||||
XSESSION_AUTH_FAILURE_DETAIL.to_owned(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
bail!("failed to get userinfo of {}", username);
|
||||
Err(XSessionStartError::auth(
|
||||
XSESSION_AUTH_FAILURE_DETAIL.to_owned(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +67,7 @@ pub mod input_service {
|
||||
}
|
||||
|
||||
mod connection;
|
||||
mod login_failure_check;
|
||||
pub mod display_service;
|
||||
#[cfg(windows)]
|
||||
pub mod portable_service;
|
||||
|
||||
@@ -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::*, *};
|
||||
#[cfg(feature = "unix-file-copy-paste")]
|
||||
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();
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
const TERMINAL_OS_LOGIN_FAILED_MSG: &str = "Incorrect username or password.";
|
||||
|
||||
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
|
||||
if a.len() != b.len() {
|
||||
return false;
|
||||
@@ -94,6 +102,32 @@ fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
|
||||
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"))]
|
||||
lazy_static::lazy_static! {
|
||||
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.
|
||||
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 {
|
||||
return false;
|
||||
}
|
||||
@@ -2376,33 +2413,6 @@ impl Connection {
|
||||
o.terminal_persistent.enum_value() == Ok(BoolOption::Yes);
|
||||
}
|
||||
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)) => {
|
||||
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")))]
|
||||
self.try_start_cm_ipc();
|
||||
if !should_use_terminal_os_login_scope(self.terminal, &lr.os_login.username) {
|
||||
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"))]
|
||||
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_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;
|
||||
return true;
|
||||
}
|
||||
@@ -2461,17 +2518,16 @@ impl Connection {
|
||||
crate::get_builtin_option(keys::OPTION_ALLOW_LOGON_SCREEN_PASSWORD) == "Y"
|
||||
&& is_logon();
|
||||
|
||||
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;
|
||||
} else if (password::approve_mode() == ApproveMode::Click
|
||||
&& !allow_logon_screen_password)
|
||||
if (password::approve_mode() == ApproveMode::Click && !allow_logon_screen_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);
|
||||
if hbb_common::get_version_number(&lr.version)
|
||||
>= hbb_common::get_version_number("1.2.0")
|
||||
@@ -2493,6 +2549,14 @@ impl Connection {
|
||||
}
|
||||
} else if lr.password.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);
|
||||
} else {
|
||||
self.send_login_error(
|
||||
@@ -2506,7 +2570,7 @@ impl Connection {
|
||||
return true;
|
||||
}
|
||||
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);
|
||||
if err_msg.is_empty() {
|
||||
self.send_login_error(crate::client::LOGIN_MSG_PASSWORD_WRONG)
|
||||
@@ -2519,7 +2583,7 @@ impl Connection {
|
||||
.await;
|
||||
}
|
||||
} else {
|
||||
self.update_failure(failure, true, 0);
|
||||
self.update_failure_with_scope(failure, true, 0, FailureScope::Default);
|
||||
if err_msg.is_empty() {
|
||||
#[cfg(target_os = "linux")]
|
||||
self.linux_headless_handle.wait_desktop_cm_ready().await;
|
||||
@@ -3484,16 +3548,16 @@ impl Connection {
|
||||
self.terminal_user_token = Some(TerminalUserToken::SelfUser);
|
||||
None
|
||||
} else {
|
||||
Some("The user is not an administrator.")
|
||||
Some(TERMINAL_OS_LOGIN_FAILED_MSG)
|
||||
}
|
||||
}
|
||||
Ok(Err(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) => {
|
||||
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.
|
||||
// Parsing an IPv4 address just returns None.
|
||||
// note: we specifically don't use hbb_common::is_ipv6_str to avoid divergence issues
|
||||
@@ -3555,18 +3759,37 @@ impl Connection {
|
||||
Some((p64, p56, p48))
|
||||
}
|
||||
|
||||
fn update_failure(&self, (failure, time): ((i32, i32, i32), i32), remove: bool, i: usize) {
|
||||
fn bump(mut cur: (i32, i32, i32), time: i32) -> (i32, i32, i32) {
|
||||
if cur.0 == time {
|
||||
cur.1 += 1;
|
||||
cur.2 += 1;
|
||||
} else {
|
||||
cur.0 = time;
|
||||
cur.1 = 1;
|
||||
cur.2 += 1;
|
||||
}
|
||||
cur
|
||||
fn bump_failure_entry(mut cur: (i32, i32, i32), time: i32) -> (i32, i32, i32) {
|
||||
if cur.0 == time {
|
||||
cur.1 += 1;
|
||||
cur.2 += 1;
|
||||
} else {
|
||||
cur.0 = time;
|
||||
cur.1 = 1;
|
||||
cur.2 += 1;
|
||||
}
|
||||
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];
|
||||
if remove {
|
||||
if failure.0 != 0 {
|
||||
@@ -3587,14 +3810,15 @@ impl Connection {
|
||||
let mut m = map_mutex.lock().unwrap();
|
||||
for key in [p64, p56, p48] {
|
||||
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
|
||||
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));
|
||||
} 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();
|
||||
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) {
|
||||
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;
|
||||
|
||||
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
|
||||
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 {
|
||||
@@ -5219,6 +5485,8 @@ pub enum AlarmAuditType {
|
||||
// MultipleLoginsAttemptsWithinOneMinute = 4,
|
||||
// MultipleLoginsAttemptsWithinOneHour = 5,
|
||||
ExceedIPv6PrefixAttempts = 6,
|
||||
TerminalOsLoginBackoff = 7,
|
||||
TerminalOsLoginConcurrency = 8,
|
||||
}
|
||||
|
||||
pub enum FileAuditType {
|
||||
|
||||
231
src/server/login_failure_check.rs
Normal file
231
src/server/login_failure_check.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user