Id whitelist (#15586)

* id whitelist

* hbb_common

* Update flutter/lib/common/widgets/dialog.dart

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* support wss:// for web client

Signed-off-by: 21pages <sunboeasy@gmail.com>

* fix: handle ID copying separately and remove whitelist logs

Signed-off-by: 21pages <sunboeasy@gmail.com>

* fix en translation

Signed-off-by: 21pages <sunboeasy@gmail.com>

* fix: check switch-side ID whitelist after login initialization

Signed-off-by: 21pages <sunboeasy@gmail.com>

* track pending 2FA challenge state

Signed-off-by: 21pages <sunboeasy@gmail.com>

* support Unicode IDs in whitelist settings

Signed-off-by: 21pages <sunboeasy@gmail.com>

* refactor: unify client ID resolution

Signed-off-by: 21pages <sunboeasy@gmail.com>

---------

Signed-off-by: 21pages <sunboeasy@gmail.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: 21pages <sunboeasy@gmail.com>
This commit is contained in:
RustDesk
2026-07-27 23:24:32 +08:00
committed by GitHub
parent eefd22b205
commit d6ea170061
59 changed files with 866 additions and 7 deletions

View File

@@ -73,8 +73,14 @@ use windows::Win32::Foundation::{CloseHandle, HANDLE};
use crate::virtual_display_manager;
pub type Sender = mpsc::UnboundedSender<(Instant, Arc<Message>)>;
const FAILURE_IDX_ID_WHITELIST: usize = 2;
lazy_static::lazy_static! {
static ref LOGIN_FAILURES: [Arc::<Mutex<HashMap<String, (i32, i32, i32)>>>; 2] = Default::default();
// [0] password, [1] 2FA, [2] ID whitelist.
// The ID whitelist has its own bucket: its counters never decay, and a peer rejected by
// it can never clear them with a successful login, so sharing the password bucket would
// let a rejected ID lock out whitelisted peers behind the same IP / IPv6 prefix.
static ref LOGIN_FAILURES: [Arc::<Mutex<HashMap<String, (i32, i32, i32)>>>; 3] = Default::default();
static ref SESSIONS: Arc::<Mutex<HashMap<SessionKey, Session>>> = Default::default();
static ref ALIVE_CONNS: Arc::<Mutex<Vec<i32>>> = Default::default();
pub static ref AUTHED_CONNS: Arc::<Mutex<Vec<AuthedConn>>> = Default::default();
@@ -317,6 +323,7 @@ pub struct Connection {
tx_to_cm: mpsc::UnboundedSender<ipc::Data>,
authorized: bool,
require_2fa: Option<totp_rs::TOTP>,
awaiting_2fa: bool,
keyboard: bool,
clipboard: bool,
audio: bool,
@@ -503,6 +510,7 @@ impl Connection {
tx_video: Some(tx_video),
},
require_2fa: crate::auth_2fa::get_2fa(None),
awaiting_2fa: false,
// Defer display enumeration until login succeeds. Monitor login replaces this
// with the primary index returned with the refreshed display snapshot.
display_idx: 0,
@@ -1375,6 +1383,32 @@ impl Connection {
true
}
async fn check_id_whitelist(&mut self) -> bool {
let id_whitelist: Vec<String> = Config::get_option(keys::OPTION_ID_WHITELIST)
.split(',')
.map(|x| x.trim().to_owned())
.filter(|x| !x.is_empty())
.collect();
if id_whitelist_allows(&id_whitelist, &self.lr.my_id) {
return true;
}
// Rate limit rejections so that the whitelist can not be used as an oracle to
// enumerate allowed IDs. Whitelisted peers return above without touching this
// bucket, so a rejected ID can not lock them out.
let (failure, res) = self.check_failure(FAILURE_IDX_ID_WHITELIST).await;
if !res {
return false;
}
self.update_failure(failure, false, FAILURE_IDX_ID_WHITELIST);
self.send_login_error("Your ID is blocked by the peer")
.await;
self.post_alarm_audit(
AlarmAuditType::IdWhitelist,
json!({ "id": self.lr.my_id.clone(), "ip": self.ip.clone(), "name": self.lr.my_name.clone() }),
);
false
}
async fn on_open(&mut self, addr: SocketAddr) -> bool {
log::debug!("#{} Connection opened from {}.", self.inner.id, addr);
if !self.check_whitelist(&addr).await {
@@ -1508,7 +1542,7 @@ impl Connection {
v["typ"] = json!(typ as i8);
v["info"] = serde_json::Value::String(info.to_string());
v["conn_id"] = json!(self.inner.id());
if typ == AlarmAuditType::IpWhitelist {
if typ == AlarmAuditType::IpWhitelist || typ == AlarmAuditType::IdWhitelist {
if let Some(audit_ref) = self.conn_audit_ref() {
v["conn_audit_ref"] = json!(audit_ref);
}
@@ -1646,10 +1680,12 @@ impl Connection {
});
}
});
self.awaiting_2fa = true;
self.send_login_error(crate::client::REQUIRE_2FA).await;
// Keep the connection alive so the client can continue with 2FA.
return true;
}
self.awaiting_2fa = false;
if let Some(keep_alive) = self.prepare_terminal_login_for_authorization().await {
return keep_alive;
}
@@ -2527,11 +2563,15 @@ impl Connection {
}
// After handling CloseReason messages, proceed to process other message types
if let Some(message::Union::LoginRequest(lr)) = msg.union {
self.awaiting_2fa = false;
self.handle_login_request_without_validation(&lr).await;
if self.authorized {
return true;
}
self.reset_session_scope_for_login();
if !self.check_id_whitelist().await {
return false;
}
match lr.union {
Some(login_request::Union::FileTransfer(ft)) => {
if !Self::permission(
@@ -2756,6 +2796,11 @@ impl Connection {
}
}
} else if let Some(message::Union::Auth2fa(tfa)) = msg.union {
// A 2FA response may arrive after click authorization has completed.
// Ignore it unless this connection is still waiting for the response.
if !self.awaiting_2fa {
return true;
}
let (failure, res) = self.check_failure(1).await;
if !res {
return true;
@@ -2828,6 +2873,11 @@ impl Connection {
}
self.reset_session_scope_for_login();
self.handle_login_request_without_validation(&lr).await;
// Switching sides authorizes without a password, so it must not bypass
// the whitelist, which can be a locked policy pushed by the server.
if !self.check_id_whitelist().await {
return false;
}
self.from_switch = true;
self.set_conn_audit_primary_auth(ConnAuditPrimaryAuth::SwitchSides);
if !self.send_logon_response_and_keep_alive().await {
@@ -6125,6 +6175,7 @@ pub enum AlarmAuditType {
TerminalOsLoginBackoff = 7,
TerminalOsLoginConcurrency = 8,
SessionScopeViolation = 9,
IdWhitelist = 10,
}
pub enum FileAuditType {
@@ -6742,11 +6793,144 @@ mod raii {
}
}
// An empty whitelist allows everyone.
//
// A peer connecting across servers reports `<its id>@<its own server>` (see
// `create_login_msg`), so the bare id is matched as well. That suffix is self-asserted and
// unsigned, so matching only the full form would reject the honest cross-server peer while
// an attacker just reports the bare id: it can produce false rejects but no true ones.
fn id_whitelist_allows(id_whitelist: &[String], my_id: &str) -> bool {
if id_whitelist.is_empty() {
return true;
}
let bare_id = my_id.split('@').next().unwrap_or(my_id);
id_whitelist
.iter()
.any(|x| wildcard_match(x, my_id) || wildcard_match(x, bare_id))
}
// Simple glob matching for the ID whitelist: '*' matches any sequence of characters
// (including the empty one), '?' matches exactly one character. Case-insensitive.
fn wildcard_match(pattern: &str, text: &str) -> bool {
let p: Vec<char> = pattern.trim().to_lowercase().chars().collect();
let t: Vec<char> = text.trim().to_lowercase().chars().collect();
let (mut pi, mut ti) = (0, 0);
let mut star: Option<(usize, usize)> = None;
while ti < t.len() {
if pi < p.len() && (p[pi] == '?' || p[pi] == t[ti]) {
pi += 1;
ti += 1;
} else if pi < p.len() && p[pi] == '*' {
star = Some((pi + 1, ti));
pi += 1;
} else if let Some((sp, st)) = star {
pi = sp;
ti = st + 1;
star = Some((sp, st + 1));
} else {
return false;
}
}
while pi < p.len() && p[pi] == '*' {
pi += 1;
}
pi == p.len()
}
#[cfg(test)]
mod test {
#[allow(unused)]
use super::*;
#[test]
fn test_wildcard_match() {
// Exact match.
assert!(wildcard_match("123456789", "123456789"));
assert!(!wildcard_match("123456789", "123456780"));
assert!(!wildcard_match("12345678", "123456789"));
assert!(!wildcard_match("123456789", "12345678"));
// Case-insensitive.
assert!(wildcard_match("MyCustomId", "mycustomid"));
// '*' matches any sequence.
assert!(wildcard_match("*", "123456789"));
assert!(wildcard_match("*", ""));
assert!(wildcard_match("123*", "123456789"));
assert!(wildcard_match("123*", "123"));
assert!(!wildcard_match("123*", "124456789"));
assert!(wildcard_match("*789", "123456789"));
assert!(wildcard_match("1*9", "123456789"));
assert!(wildcard_match("1*4*9", "123456789"));
assert!(!wildcard_match("1*4*9", "123456780"));
assert!(wildcard_match("*456*", "123456789"));
// '?' matches exactly one character.
assert!(wildcard_match("12345678?", "123456789"));
assert!(!wildcard_match("123456789?", "123456789"));
assert!(wildcard_match("???456???", "123456789"));
assert!(wildcard_match("1?3*7?9", "123456789"));
// Whitespace around entries is ignored.
assert!(wildcard_match(" 123456789 ", "123456789"));
}
#[test]
fn test_id_whitelist_allows() {
let list = |v: &[&str]| v.iter().map(|x| x.to_string()).collect::<Vec<_>>();
// An empty whitelist allows everyone.
assert!(id_whitelist_allows(&[], "123456789"));
// Same server: the peer reports a bare id.
assert!(id_whitelist_allows(&list(&["123456789"]), "123456789"));
assert!(!id_whitelist_allows(&list(&["123456789"]), "987654321"));
// Cross server: the peer appends its own server, which must not reject it.
assert!(id_whitelist_allows(
&list(&["123456789"]),
"123456789@example.com:21116"
));
// Cross server from web, whose server is a WebSocket URI.
assert!(id_whitelist_allows(
&list(&["123456789"]),
"123456789@wss://example.com:21118/ws/id"
));
// A different id is still rejected, suffix or not.
assert!(!id_whitelist_allows(
&list(&["123456789"]),
"987654321@example.com:21116"
));
// An entry pinned to one server keeps matching that exact form.
assert!(id_whitelist_allows(
&list(&["123456789@example.com:21116"]),
"123456789@example.com:21116"
));
assert!(!id_whitelist_allows(
&list(&["123456789@example.com:21116"]),
"123456789@other.com:21116"
));
// ... and no longer matches the bare id, which is the point of pinning.
assert!(!id_whitelist_allows(
&list(&["123456789@example.com:21116"]),
"123456789"
));
// Wildcards keep working on both forms.
assert!(id_whitelist_allows(&list(&["abc*"]), "abcdef"));
assert!(id_whitelist_allows(
&list(&["abc*"]),
"abcdef@example.com:21116"
));
assert!(id_whitelist_allows(
&list(&["*"]),
"123456789@example.com:21116"
));
// Any entry of the list is enough.
assert!(id_whitelist_allows(
&list(&["111111111", "123456789", "222222222"]),
"123456789@example.com:21116"
));
}
#[cfg(target_os = "macos")]
#[test]
fn retina() {