Fix/session scope permission audit (#15469)

* fix: enforce session-scoped permissions

Restrict non-remote sessions to their allowed message types, filter
out-of-scope login options, and audit rejected or filtered messages.
Hide screenshot controls outside default remote sessions.

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

* fix: typo

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

* fix: prevent privacy mode in view-camera sessions

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

* fix: switch display, check non-view-camera

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

* fix: avoid sending unsupported messages

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

* fix: session scope, add option to control close/alarm

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

* Fix: scoped session handling for view-camera compatibility

  - Skip view-camera auto-login and display-management side effects
  - Allow harmless render broadcasts without affecting non-video sessions
  - Keep legacy view-camera management messages compatible as no-ops
  - Preserve stricter scope violations for non-video session types

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

* update libs/hbb_common

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

* fix: ignore repeated login request

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

* fix: view camera, support "Take screenshot"

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

* fix: session scoped messages, check update options

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

* fix: session scope, check portforward before conn type voolations

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

* fix: scoped messages, reduce changes.

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

* fix: session scope, comments

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

* fix: keep scoped sessions compatible with render broadcasts

Allow legacy render-broadcast no-op messages for file transfer and terminal
sessions while keeping port forward and mixed options scoped. Also avoid sending
new render updates to non-video Flutter sessions.

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

* fix: scope screenshot requests by video source

Key screenshot requests by video source and display index so camera and
monitor sessions cannot consume each other's requests. Deduplicate the Flutter
render-target predicate while keeping render updates limited to video sessions.

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

* fix: Harden scoped session message handling

Filter option updates by authenticated connection type,
keep legacy no-op messages compatible, and avoid noisy repeated
scope violation alarms.

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

* fix: session scope, comments

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

* fix: Send close reason for scoped session violations

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

* fix: Enforce scoped session message filtering

  - filter out-of-scope messages for limited session types
  - scope option updates by authenticated connection type
  - keep render-broadcast no-op compatibility for non-video scoped sessions
  - restore view-camera screenshot handling
  - improve session scope violation audit labels
  - avoid cloning option messages on the remote hot path

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

* Fix scoped session clipboard broadcast compatibility

Treat text clipboard broadcasts as no-op compatibility messages for FileTransfer and Terminal sessions, matching existing
handler behavior and preventing optional scope-violation close from disconnecting those sessions. Keep ViewCamera and
PortForward clipboard messages subject to normal scope enforcement.

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

* fix: log warn

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

* fix: restrict Flutter clipboard sync to default sessions

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

* fix: session scope, comments and tests

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

* fix: session scope, reset sessions in login handle

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

* fix: session scope, view camera, allow clipboard noop

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
This commit is contained in:
fufesou
2026-07-05 23:28:32 +08:00
committed by GitHub
parent cf1de4de62
commit 493b14ba78
8 changed files with 837 additions and 32 deletions
+1 -1
View File
@@ -606,7 +606,7 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
// to-do:
// 1. Web desktop
// 2. Mobile, copy the image to the clipboard
if (isDesktop) {
if ((isDefaultConn || ffi.connType == ConnType.viewCamera) && isDesktop) {
final isScreenshotSupported = bind.sessionGetCommonSync(
sessionId: sessionId, key: 'is_screenshot_supported', param: '');
if ('true' == isScreenshotSupported) {
+6
View File
@@ -1090,6 +1090,9 @@ impl<T: InvokeUiSession> Remote<T> {
}
async fn send_toggle_virtual_display_msg(&self, peer: &mut Stream) {
if self.handler.is_view_camera() {
return;
}
if !self.peer_info.is_support_virtual_display() {
return;
}
@@ -1111,6 +1114,9 @@ impl<T: InvokeUiSession> Remote<T> {
}
async fn send_toggle_privacy_mode_msg(&self, peer: &mut Stream) {
if self.handler.is_view_camera() {
return;
}
let lc = self.handler.lc.read().unwrap();
if lc.version >= hbb_common::get_version_number("1.2.4")
&& lc.get_toggle_option("privacy-mode")
+5 -2
View File
@@ -1437,7 +1437,7 @@ fn try_send_close_event(event_stream: &Option<StreamSink<EventToUI>>) {
pub fn update_text_clipboard_required() {
let is_required = sessions::get_sessions()
.iter()
.any(|s| s.is_text_clipboard_required());
.any(|s| s.is_default() && s.is_text_clipboard_required());
#[cfg(target_os = "android")]
let _ = scrap::android::ffi::call_clipboard_manager_enable_client_clipboard(is_required);
Client::set_is_text_clipboard_required(is_required);
@@ -1447,13 +1447,16 @@ pub fn update_text_clipboard_required() {
pub fn update_file_clipboard_required() {
let is_required = sessions::get_sessions()
.iter()
.any(|s| s.is_file_clipboard_required());
.any(|s| s.is_default() && s.is_file_clipboard_required());
Client::set_is_file_clipboard_required(is_required);
}
#[cfg(not(target_os = "ios"))]
pub fn send_clipboard_msg(msg: Message, _is_file: bool) {
for s in sessions::get_sessions() {
if !s.is_default() {
continue;
}
#[cfg(feature = "unix-file-copy-paste")]
if _is_file {
if crate::is_support_file_copy_paste_num(s.lc.read().unwrap().version)
+8
View File
@@ -1224,9 +1224,14 @@ pub fn main_set_local_option(key: String, value: String) {
let is_texture_render_key = key.eq(config::keys::OPTION_TEXTURE_RENDER);
let is_d3d_render_key = key.eq(config::keys::OPTION_ALLOW_D3D_RENDER);
set_local_option(key, value.clone());
let is_render_target =
|session: &crate::flutter::FlutterSession| session.is_default() || session.is_view_camera();
if is_texture_render_key {
let session_event = [("v", &value)];
for session in sessions::get_sessions() {
if !is_render_target(&session) {
continue;
}
session.push_event("use_texture_render", &session_event, &[]);
session.use_texture_render_changed();
session.ui_handler.update_use_texture_render();
@@ -1234,6 +1239,9 @@ pub fn main_set_local_option(key: String, value: String) {
}
if is_d3d_render_key {
for session in sessions::get_sessions() {
if !is_render_target(&session) {
continue;
}
session.update_supported_decodings();
}
}
+800 -18
View File
@@ -240,6 +240,18 @@ pub enum AuthConnType {
Terminal,
}
impl AuthConnType {
fn as_str(self) -> &'static str {
match self {
AuthConnType::Remote => "remote",
AuthConnType::FileTransfer => "file_transfer",
AuthConnType::PortForward => "port_forward",
AuthConnType::ViewCamera => "view_camera",
AuthConnType::Terminal => "terminal",
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[repr(i64)]
enum ConnAuditPrimaryAuth {
@@ -384,6 +396,8 @@ pub struct Connection {
cm_read_job_ids: HashSet<i32>,
terminal_service_id: String,
terminal_persistent: bool,
// Used to avoid too many repeated scope violation warnings.
scope_violation_messages: HashSet<&'static str>,
// The user token must be set when terminal is enabled.
// 0 indicates SYSTEM user
// other values indicate current user
@@ -571,6 +585,7 @@ impl Connection {
cm_read_job_ids: HashSet::new(),
terminal_service_id: "".to_owned(),
terminal_persistent: false,
scope_violation_messages: HashSet::new(),
#[cfg(not(any(target_os = "android", target_os = "ios")))]
terminal_user_token: None,
terminal_generic_service: None,
@@ -1501,6 +1516,23 @@ impl Connection {
});
}
fn post_session_scope_violation_alarm(&self, message: &'static str) {
let conn_type = self
.authed_conn_type()
.map(AuthConnType::as_str)
.unwrap_or("unknown");
self.post_alarm_audit(
AlarmAuditType::SessionScopeViolation,
json!({
"id": self.lr.my_id.clone(),
"name": self.lr.my_name.clone(),
"ip": &self.ip,
"conn_type": conn_type,
"message": message,
}),
);
}
#[inline]
async fn post_audit_async(url: String, v: Value) -> ResultType<String> {
crate::post_request(url, v.to_string(), "").await
@@ -1897,10 +1929,9 @@ impl Connection {
let mut msg_out = Message::new();
msg_out.set_login_response(res);
self.send(msg_out).await;
if let Some(o) = self.options_in_login.take() {
self.update_options(&o).await;
}
self.update_scoped_login_options().await;
if let Some((dir, show_hidden)) = self.file_transfer.clone() {
self.keyboard = false;
let dir = if !dir.is_empty() && std::path::Path::new(&dir).is_dir() {
&dir
} else {
@@ -2407,6 +2438,14 @@ impl Connection {
)
}
fn reset_session_scope_for_login(&mut self) {
self.file_transfer = None;
self.view_camera = false;
self.terminal = false;
self.port_forward_address.clear();
self.terminal_persistent = false;
}
async fn handle_login_request_without_validation(&mut self, lr: &LoginRequest) {
self.lr = lr.clone();
self.peer_argb = crate::str2color(&format!("{}{}", &lr.my_id, &lr.my_platform), 0xff);
@@ -2474,12 +2513,21 @@ impl Connection {
return false;
}
}
if self.authorized {
if matches!(msg.union.as_ref(), Some(message::Union::LoginRequest(_))) {
return true;
}
if let Some(message) = self.authorized_scope_violation(&msg) {
return self.handle_authorized_scope_violation(message).await;
}
}
// After handling CloseReason messages, proceed to process other message types
if let Some(message::Union::LoginRequest(lr)) = msg.union {
self.handle_login_request_without_validation(&lr).await;
if self.authorized {
return true;
}
self.reset_session_scope_for_login();
match lr.union {
Some(login_request::Union::FileTransfer(ft)) => {
if !Self::permission(
@@ -2989,7 +3037,7 @@ impl Connection {
self.update_auto_disconnect_timer();
}
Some(message::Union::Clipboard(cb)) => {
if self.clipboard_enabled() {
if self.should_handle_text_clipboard_message() && self.clipboard_enabled() {
#[cfg(not(any(target_os = "android", target_os = "ios")))]
update_clipboard(vec![cb], ClipboardSide::Host);
// ios as the controlled side is actually not supported for now.
@@ -3017,7 +3065,7 @@ impl Connection {
}
}
Some(message::Union::MultiClipboards(_mcb)) => {
if self.clipboard_enabled() {
if self.should_handle_text_clipboard_message() && self.clipboard_enabled() {
#[cfg(not(any(target_os = "android", target_os = "ios")))]
update_clipboard(_mcb.clipboards, ClipboardSide::Host);
#[cfg(target_os = "android")]
@@ -3404,10 +3452,14 @@ impl Connection {
}
#[cfg(windows)]
Some(misc::Union::ToggleVirtualDisplay(t)) => {
self.toggle_virtual_display(t).await;
if !self.view_camera {
self.toggle_virtual_display(t).await;
}
}
Some(misc::Union::TogglePrivacyMode(t)) => {
self.toggle_privacy_mode(t).await;
if !self.view_camera {
self.toggle_privacy_mode(t).await;
}
}
Some(misc::Union::ChatMessage(c)) => {
self.send_to_cm(ipc::Data::ChatMessage { text: c.text });
@@ -3415,19 +3467,27 @@ impl Connection {
self.update_auto_disconnect_timer();
}
Some(misc::Union::Option(o)) => {
self.update_options(&o).await;
if self.authed_conn_type() == Some(AuthConnType::Remote) {
self.update_options(&o).await;
} else if let Some(option) = self.scoped_update_option_message(&o) {
self.update_options(&option).await;
}
}
Some(misc::Union::RefreshVideo(r)) => {
if r {
// Refresh all videos.
// Compatibility with old versions and sciter(remote).
self.refresh_video_display(None);
if self.should_handle_render_broadcast_message() {
if r {
// Refresh all videos.
// Compatibility with old versions and sciter(remote).
self.refresh_video_display(None);
}
self.update_auto_disconnect_timer();
}
self.update_auto_disconnect_timer();
}
Some(misc::Union::RefreshVideoDisplay(display)) => {
self.refresh_video_display(Some(display as usize));
self.update_auto_disconnect_timer();
if self.should_handle_render_broadcast_message() {
self.refresh_video_display(Some(display as usize));
self.update_auto_disconnect_timer();
}
}
Some(misc::Union::VideoReceived(_)) => {
video_service::notify_video_frame_fetched_by_conn_id(
@@ -3495,10 +3555,16 @@ impl Connection {
}
}
#[cfg(not(any(target_os = "android", target_os = "ios")))]
Some(misc::Union::ChangeResolution(r)) => self.change_resolution(None, &r),
Some(misc::Union::ChangeResolution(r)) => {
if !self.view_camera {
self.change_resolution(None, &r);
}
}
#[cfg(not(any(target_os = "android", target_os = "ios")))]
Some(misc::Union::ChangeDisplayResolution(dr)) => {
self.change_resolution(Some(dr.display as _), &dr.resolution)
if !self.view_camera {
self.change_resolution(Some(dr.display as _), &dr.resolution);
}
}
#[cfg(all(feature = "flutter", feature = "plugin_framework"))]
#[cfg(not(any(target_os = "android", target_os = "ios")))]
@@ -3584,6 +3650,7 @@ impl Connection {
Some(message::Union::ScreenshotRequest(request)) => {
if let Some(tx) = self.inner.tx.clone() {
crate::video_service::set_take_screenshot(
self.video_source(),
request.display as _,
request.sid.clone(),
tx,
@@ -4080,7 +4147,7 @@ impl Connection {
self.switch_display_to(display_idx, server.clone());
#[cfg(not(any(target_os = "android", target_os = "ios")))]
if s.width != 0 && s.height != 0 {
if !self.view_camera && s.width != 0 && s.height != 0 {
self.change_resolution(
None,
&Resolution {
@@ -5204,6 +5271,398 @@ impl Connection {
false
}
fn should_handle_render_broadcast_message(&self) -> bool {
matches!(
self.authed_conn_type(),
Some(AuthConnType::Remote | AuthConnType::ViewCamera)
)
}
fn should_handle_text_clipboard_message(&self) -> bool {
matches!(self.authed_conn_type(), Some(AuthConnType::Remote))
}
fn scoped_update_option_message(&self, option: &OptionMessage) -> Option<OptionMessage> {
match self.authed_conn_type() {
Some(AuthConnType::ViewCamera) => Self::scoped_view_camera_option(option).0,
Some(AuthConnType::Terminal) => Self::scoped_terminal_login_option(option).0,
Some(AuthConnType::Remote | AuthConnType::FileTransfer | AuthConnType::PortForward)
| None => None,
}
}
fn authed_conn_type(&self) -> Option<AuthConnType> {
self.authed_conn_id.as_ref().map(|id| id.conn_type())
}
async fn handle_authorized_scope_violation(&mut self, message: &'static str) -> bool {
let conn_type = self
.authed_conn_type()
.map(AuthConnType::as_str)
.unwrap_or("unknown");
let is_first = self.scope_violation_messages.insert(message);
if is_first {
log::warn!(
"Received out-of-scope message in {} session: {}",
conn_type,
message
);
} else {
log::debug!(
"Received repeated out-of-scope message in {} session: {}",
conn_type,
message
);
}
if is_first && Config::get_bool_option(keys::OPTION_ALLOW_SCOPE_VIOLATION_ALARM) {
self.post_session_scope_violation_alarm(message);
}
if Config::get_bool_option(keys::OPTION_ALLOW_SCOPE_VIOLATION_CLOSE) {
self.send_close_reason_no_retry("Connection not allowed")
.await;
self.on_close("Session scope violation", true).await;
return false;
}
true
}
fn authorized_scope_violation(&self, msg: &Message) -> Option<&'static str> {
let Some(conn_type) = self.authed_conn_type() else {
return (!Self::is_connection_housekeeping_message(msg)).then_some("session.auth_type");
};
Self::authorized_message_scope_violation(conn_type, msg)
}
async fn update_scoped_login_options(&mut self) {
let Some(option) = self.options_in_login.take() else {
return;
};
let Some(conn_type) = self.authed_conn_type() else {
// Unreachable, but just in case, we drop the options if the connection type is unknown.
log::warn!(
"Dropping scoped login options because authorized connection type is unknown"
);
return;
};
let (scoped, violation) = Self::scoped_login_option(conn_type, &option);
if let Some(message) = violation {
log::debug!(
"Filtering {} session login options outside scope: {}",
conn_type.as_str(),
message
);
}
if let Some(option) = scoped {
self.update_options(&option).await;
}
}
fn scoped_login_option(
conn_type: AuthConnType,
option: &OptionMessage,
) -> (Option<OptionMessage>, Option<&'static str>) {
match conn_type {
AuthConnType::Remote => (Some(option.clone()), None),
AuthConnType::ViewCamera => Self::scoped_view_camera_option(option),
AuthConnType::Terminal => Self::scoped_terminal_login_option(option),
AuthConnType::FileTransfer | AuthConnType::PortForward => {
let violation = Self::option_has_any_field(option).then_some("login.option");
(None, violation)
}
}
}
fn scoped_terminal_login_option(
option: &OptionMessage,
) -> (Option<OptionMessage>, Option<&'static str>) {
let mut scoped = OptionMessage::new();
let mut violation = false;
match option.terminal_persistent.enum_value() {
Ok(value) => scoped.terminal_persistent = value.into(),
Err(_) => violation = true,
}
if Self::option_has_non_terminal_login_field(option) {
violation = true;
}
let scoped = Self::option_has_any_field(&scoped).then_some(scoped);
(scoped, violation.then_some("login.option"))
}
fn authorized_message_scope_violation(
conn_type: AuthConnType,
msg: &Message,
) -> Option<&'static str> {
if Self::is_connection_housekeeping_message(msg) {
return None;
}
// Legacy clients can broadcast render-refresh messages to all opened sessions.
// Clipboard messages may also be broadcast to FileTransfer/Terminal sessions while
// the client still considers text clipboard sync required, and handlers ignore them.
let noop_compat = match conn_type {
AuthConnType::FileTransfer | AuthConnType::Terminal => {
Self::is_render_broadcast_noop_compat_message(msg)
|| Self::is_text_clipboard_noop_compat_message(msg)
}
AuthConnType::PortForward => Self::is_render_broadcast_noop_compat_message(msg),
AuthConnType::ViewCamera => Self::is_text_clipboard_noop_compat_message(msg),
_ => false,
};
if noop_compat {
return None;
}
let allowed = match conn_type {
AuthConnType::Remote => true,
AuthConnType::FileTransfer => Self::is_file_transfer_scoped_message(msg),
AuthConnType::PortForward => false,
AuthConnType::ViewCamera => Self::is_view_camera_scoped_message(msg),
AuthConnType::Terminal => Self::is_terminal_scoped_message(msg),
};
(!allowed).then(|| Self::message_family(msg))
}
fn is_render_broadcast_noop_compat_message(msg: &Message) -> bool {
let Some(message::Union::Misc(misc)) = msg.union.as_ref() else {
return false;
};
match misc.union.as_ref() {
Some(misc::Union::RefreshVideo(_)) | Some(misc::Union::RefreshVideoDisplay(_)) => true,
Some(misc::Union::Option(option)) => Self::is_supported_decoding_only_option(option),
_ => false,
}
}
fn is_text_clipboard_noop_compat_message(msg: &Message) -> bool {
matches!(
msg.union.as_ref(),
Some(message::Union::Clipboard(_)) | Some(message::Union::MultiClipboards(_))
)
}
fn is_supported_decoding_only_option(option: &OptionMessage) -> bool {
option.supported_decoding.is_some()
&& option.image_quality.enum_value() == Ok(ImageQuality::NotSet)
&& option.custom_image_quality == 0
&& option.custom_fps == 0
&& Self::is_bool_option_not_set(option.lock_after_session_end)
&& Self::is_bool_option_not_set(option.show_remote_cursor)
&& Self::is_bool_option_not_set(option.privacy_mode)
&& Self::is_bool_option_not_set(option.block_input)
&& Self::is_bool_option_not_set(option.disable_audio)
&& Self::is_bool_option_not_set(option.disable_clipboard)
&& Self::is_bool_option_not_set(option.enable_file_transfer)
&& Self::is_bool_option_not_set(option.disable_keyboard)
&& Self::is_bool_option_not_set(option.follow_remote_cursor)
&& Self::is_bool_option_not_set(option.follow_remote_window)
&& Self::is_bool_option_not_set(option.disable_camera)
&& Self::is_bool_option_not_set(option.terminal_persistent)
&& Self::is_bool_option_not_set(option.show_my_cursor)
}
fn is_connection_housekeeping_message(msg: &Message) -> bool {
match msg.union.as_ref() {
Some(message::Union::LoginRequest(_)) => true,
Some(message::Union::TestDelay(_)) => true,
Some(message::Union::Misc(misc)) => {
matches!(misc.union.as_ref(), Some(misc::Union::CloseReason(_)))
}
_ => false,
}
}
fn is_file_transfer_scoped_message(msg: &Message) -> bool {
match msg.union.as_ref() {
Some(message::Union::FileAction(_)) | Some(message::Union::FileResponse(_)) => true,
Some(message::Union::Misc(misc)) => Self::is_file_transfer_scoped_misc(misc),
_ => false,
}
}
fn is_file_transfer_scoped_misc(misc: &Misc) -> bool {
#[cfg(windows)]
if matches!(misc.union.as_ref(), Some(misc::Union::SelectedSid(_))) {
return true;
}
#[cfg(not(windows))]
let _ = misc;
false
}
fn is_terminal_scoped_message(msg: &Message) -> bool {
match msg.union.as_ref() {
Some(message::Union::TerminalAction(_)) => true,
Some(message::Union::Misc(misc)) => Self::is_terminal_scoped_misc(misc),
_ => false,
}
}
fn is_terminal_scoped_misc(misc: &Misc) -> bool {
match misc.union.as_ref() {
Some(misc::Union::ChatMessage(_)) => true,
Some(misc::Union::Option(option)) => Self::is_terminal_scoped_option(option),
_ => false,
}
}
fn is_terminal_scoped_option(option: &OptionMessage) -> bool {
Self::scoped_terminal_login_option(option).1.is_none()
}
fn is_view_camera_scoped_message(msg: &Message) -> bool {
match msg.union.as_ref() {
Some(message::Union::ScreenshotRequest(_)) => true,
Some(message::Union::Misc(misc)) => Self::is_view_camera_scoped_misc(misc),
// Legacy clients may send auto-login input during view-camera connect.
// The handlers intentionally ignore these messages for view-camera sessions.
Some(message::Union::MouseEvent(_))
| Some(message::Union::PointerDeviceEvent(_))
| Some(message::Union::KeyEvent(_)) => true,
Some(message::Union::AudioFrame(_))
| Some(message::Union::VoiceCallRequest(_))
| Some(message::Union::VoiceCallResponse(_)) => true,
_ => false,
}
}
fn is_view_camera_scoped_misc(misc: &Misc) -> bool {
match misc.union.as_ref() {
Some(misc::Union::SwitchDisplay(_))
| Some(misc::Union::CaptureDisplays(_))
| Some(misc::Union::RefreshVideo(_))
| Some(misc::Union::RefreshVideoDisplay(_))
| Some(misc::Union::VideoReceived(_))
| Some(misc::Union::ChatMessage(_))
| Some(misc::Union::AudioFormat(_))
| Some(misc::Union::ClientRecordStatus(_))
// Though these messages are not expected in normal view-camera sessions,
// keep them allowed to avoid breaking existing clients that may send them.
| Some(misc::Union::MessageQuery(_))
| Some(misc::Union::TogglePrivacyMode(_))
| Some(misc::Union::ToggleVirtualDisplay(_))
| Some(misc::Union::ChangeResolution(_))
| Some(misc::Union::ChangeDisplayResolution(_)) => true,
Some(misc::Union::Option(option)) => Self::is_view_camera_scoped_option(option),
#[cfg(windows)]
Some(misc::Union::SelectedSid(_)) => true,
_ => false,
}
}
fn is_view_camera_scoped_option(option: &OptionMessage) -> bool {
Self::scoped_view_camera_option(option).1.is_none()
}
// Keep these OptionMessage field lists in sync with message.proto and update_options().
// New fields must be classified here before limited session types can receive them.
fn scoped_view_camera_option(
option: &OptionMessage,
) -> (Option<OptionMessage>, Option<&'static str>) {
let mut scoped = OptionMessage::new();
let mut violation = false;
if option.image_quality.enum_value().is_ok() {
scoped.image_quality = option.image_quality;
}
if option.custom_image_quality >= 0 {
scoped.custom_image_quality = option.custom_image_quality;
}
if option.custom_fps >= 0 {
scoped.custom_fps = option.custom_fps;
}
scoped.supported_decoding = option.supported_decoding.clone();
if let Ok(value) = option.disable_audio.enum_value() {
scoped.disable_audio = value.into();
}
if Self::option_has_non_view_camera_login_field(option) {
violation = true;
}
let scoped = Self::option_has_any_field(&scoped).then_some(scoped);
(scoped, violation.then_some("login.option"))
}
fn option_has_non_view_camera_login_field(option: &OptionMessage) -> bool {
!(Self::is_bool_option_not_set(option.lock_after_session_end)
&& Self::is_bool_option_not_set(option.show_remote_cursor)
&& Self::is_bool_option_not_set(option.privacy_mode)
&& Self::is_bool_option_not_set(option.block_input)
&& Self::is_bool_option_not_set(option.disable_clipboard)
&& Self::is_bool_option_not_set(option.enable_file_transfer)
&& Self::is_bool_option_not_set(option.disable_keyboard)
&& Self::is_bool_option_not_set(option.follow_remote_cursor)
&& Self::is_bool_option_not_set(option.follow_remote_window)
&& Self::is_bool_option_not_set(option.disable_camera)
&& Self::is_bool_option_not_set(option.terminal_persistent)
&& Self::is_bool_option_not_set(option.show_my_cursor))
}
fn option_has_non_terminal_login_field(option: &OptionMessage) -> bool {
option.image_quality.enum_value() != Ok(ImageQuality::NotSet)
|| option.custom_image_quality != 0
|| option.custom_fps != 0
|| option.supported_decoding.is_some()
|| !Self::is_bool_option_not_set(option.lock_after_session_end)
|| !Self::is_bool_option_not_set(option.show_remote_cursor)
|| !Self::is_bool_option_not_set(option.privacy_mode)
|| !Self::is_bool_option_not_set(option.block_input)
|| !Self::is_bool_option_not_set(option.disable_audio)
|| !Self::is_bool_option_not_set(option.disable_clipboard)
|| !Self::is_bool_option_not_set(option.enable_file_transfer)
|| !Self::is_bool_option_not_set(option.disable_keyboard)
|| !Self::is_bool_option_not_set(option.follow_remote_cursor)
|| !Self::is_bool_option_not_set(option.follow_remote_window)
|| !Self::is_bool_option_not_set(option.disable_camera)
|| !Self::is_bool_option_not_set(option.show_my_cursor)
}
fn option_has_any_field(option: &OptionMessage) -> bool {
Self::option_has_non_terminal_login_field(option)
|| !Self::is_bool_option_not_set(option.terminal_persistent)
}
fn is_bool_option_not_set(option: hbb_common::protobuf::EnumOrUnknown<BoolOption>) -> bool {
option.enum_value() == Ok(BoolOption::NotSet)
}
fn message_family(msg: &Message) -> &'static str {
match msg.union.as_ref() {
Some(message::Union::MouseEvent(_)) => "mouse_event",
Some(message::Union::AudioFrame(_)) => "audio_frame",
Some(message::Union::PointerDeviceEvent(_)) => "pointer_device_event",
Some(message::Union::KeyEvent(_)) => "key_event",
Some(message::Union::Clipboard(_)) => "clipboard",
Some(message::Union::FileAction(_)) => "file_action",
Some(message::Union::FileResponse(_)) => "file_response",
Some(message::Union::VoiceCallRequest(_)) => "voice_call_request",
Some(message::Union::VoiceCallResponse(_)) => "voice_call_response",
Some(message::Union::MultiClipboards(_)) => "multi_clipboards",
Some(message::Union::ScreenshotRequest(_)) => "screenshot_request",
Some(message::Union::ScreenshotResponse(_)) => "screenshot_response",
Some(message::Union::TerminalAction(_)) => "terminal_action",
Some(message::Union::TerminalResponse(_)) => "terminal_response",
Some(message::Union::Misc(misc)) => Self::misc_message_family(misc),
Some(_) => "message.other",
None => "empty",
}
}
fn misc_message_family(misc: &Misc) -> &'static str {
match misc.union.as_ref() {
Some(misc::Union::ChatMessage(_)) => "misc.chat_message",
Some(misc::Union::SwitchDisplay(_)) => "misc.switch_display",
Some(misc::Union::Option(_)) => "misc.option",
Some(misc::Union::AudioFormat(_)) => "misc.audio_format",
Some(misc::Union::CaptureDisplays(_)) => "misc.capture_displays",
Some(misc::Union::ClientRecordStatus(_)) => "misc.client_record_status",
Some(misc::Union::TogglePrivacyMode(_)) => "misc.toggle_privacy_mode",
Some(misc::Union::ToggleVirtualDisplay(_)) => "misc.toggle_virtual_display",
Some(misc::Union::SelectedSid(_)) => "misc.selected_sid",
Some(misc::Union::ChangeResolution(_)) => "misc.change_resolution",
Some(misc::Union::ChangeDisplayResolution(_)) => "misc.change_display_resolution",
Some(misc::Union::MessageQuery(_)) => "misc.message_query",
Some(misc::Union::FollowCurrentDisplay(_)) => "misc.follow_current_display",
Some(_) => "misc.other",
None => "misc.empty",
}
}
#[cfg(feature = "unix-file-copy-paste")]
async fn handle_file_clip(&mut self, clip: clipboard::ClipboardFile) {
let is_stopping_allowed = clip.is_stopping_allowed();
@@ -5599,6 +6058,7 @@ pub enum AlarmAuditType {
ExceedIPv6PrefixAttempts = 6,
TerminalOsLoginBackoff = 7,
TerminalOsLoginConcurrency = 8,
SessionScopeViolation = 9,
}
pub enum FileAuditType {
@@ -6216,6 +6676,7 @@ mod raii {
}
}
#[cfg(test)]
mod test {
#[allow(unused)]
use super::*;
@@ -6258,4 +6719,325 @@ mod test {
assert!(Ipv6Addr::from_str("127.0.0.1").is_err());
assert!(Ipv6Addr::from_str("0").is_err());
}
fn msg(set: impl FnOnce(&mut Message)) -> Message {
let mut msg = Message::new();
set(&mut msg);
msg
}
fn misc_msg(set: impl FnOnce(&mut Misc)) -> Message {
msg(|msg| {
let mut misc = Misc::new();
set(&mut misc);
msg.set_misc(misc);
})
}
fn option_msg(set: impl FnOnce(&mut OptionMessage)) -> Message {
misc_msg(|misc| {
let mut option = OptionMessage::new();
set(&mut option);
misc.set_option(option);
})
}
fn set_supported_decoding(option: &mut OptionMessage) {
option.supported_decoding = hbb_common::protobuf::MessageField::some(Default::default());
}
fn assert_scopes(
conn_type: AuthConnType,
cases: impl IntoIterator<Item = (Message, Option<&'static str>)>,
) {
for (msg, expected) in cases {
assert_eq!(
Connection::authorized_message_scope_violation(conn_type, &msg),
expected
);
}
}
#[test]
fn session_scope_allows_only_messages_for_authenticated_session_type() {
let cases = [
(
AuthConnType::FileTransfer,
vec![
(msg(|m| m.set_file_action(FileAction::new())), None),
(msg(|m| m.set_file_response(FileResponse::new())), None),
(msg(|m| m.set_login_request(LoginRequest::new())), None),
(
msg(|m| m.set_screenshot_request(ScreenshotRequest::new())),
Some("screenshot_request"),
),
(
misc_msg(|m| m.set_capture_displays(CaptureDisplays::new())),
Some("misc.capture_displays"),
),
(msg(|m| m.set_clipboard(Clipboard::new())), None),
(
msg(|m| m.set_multi_clipboards(MultiClipboards::new())),
None,
),
(misc_msg(|m| m.set_refresh_video(true)), None),
(misc_msg(|m| m.set_refresh_video_display(0)), None),
(
option_msg(|o| {
o.supported_decoding =
hbb_common::protobuf::MessageField::some(Default::default())
}),
None,
),
(
option_msg(|o| {
o.supported_decoding =
hbb_common::protobuf::MessageField::some(Default::default());
o.disable_audio = BoolOption::Yes.into();
}),
Some("misc.option"),
),
],
),
(
AuthConnType::Terminal,
vec![
(msg(|m| m.set_terminal_action(TerminalAction::new())), None),
(
option_msg(|o| o.terminal_persistent = BoolOption::Yes.into()),
None,
),
(
msg(|m| m.set_screenshot_request(ScreenshotRequest::new())),
Some("screenshot_request"),
),
(
msg(|m| m.set_file_action(FileAction::new())),
Some("file_action"),
),
(
misc_msg(|m| m.set_toggle_privacy_mode(TogglePrivacyMode::new())),
Some("misc.toggle_privacy_mode"),
),
(misc_msg(|m| m.set_chat_message(ChatMessage::new())), None),
(msg(|m| m.set_clipboard(Clipboard::new())), None),
(
msg(|m| m.set_multi_clipboards(MultiClipboards::new())),
None,
),
(
misc_msg(|m| m.set_toggle_virtual_display(ToggleVirtualDisplay::new())),
Some("misc.toggle_virtual_display"),
),
(
misc_msg(|m| m.set_change_resolution(Resolution::new())),
Some("misc.change_resolution"),
),
(
misc_msg(|m| m.set_change_display_resolution(DisplayResolution::new())),
Some("misc.change_display_resolution"),
),
(misc_msg(|m| m.set_refresh_video(true)), None),
(misc_msg(|m| m.set_refresh_video_display(0)), None),
(
option_msg(|o| {
o.supported_decoding =
hbb_common::protobuf::MessageField::some(Default::default())
}),
None,
),
(
option_msg(|o| {
o.supported_decoding =
hbb_common::protobuf::MessageField::some(Default::default());
o.disable_audio = BoolOption::Yes.into();
}),
Some("misc.option"),
),
],
),
(
AuthConnType::ViewCamera,
vec![
(
misc_msg(|m| m.set_switch_display(SwitchDisplay::new())),
None,
),
(misc_msg(|m| m.set_chat_message(ChatMessage::new())), None),
(
msg(|m| m.set_voice_call_request(VoiceCallRequest::new())),
None,
),
(msg(|m| m.set_audio_frame(AudioFrame::new())), None),
(
option_msg(|o| o.image_quality = ImageQuality::Balanced.into()),
None,
),
(
misc_msg(|m| m.set_toggle_privacy_mode(TogglePrivacyMode::new())),
None,
),
(
misc_msg(|m| m.set_toggle_virtual_display(ToggleVirtualDisplay::new())),
None,
),
(
misc_msg(|m| m.set_change_resolution(Resolution::new())),
None,
),
(
misc_msg(|m| m.set_change_display_resolution(DisplayResolution::new())),
None,
),
(msg(|m| m.set_mouse_event(MouseEvent::new())), None),
(
msg(|m| m.set_pointer_device_event(PointerDeviceEvent::new())),
None,
),
(msg(|m| m.set_key_event(KeyEvent::new())), None),
(misc_msg(|m| m.set_client_record_status(true)), None),
(
msg(|m| m.set_file_response(FileResponse::new())),
Some("file_response"),
),
(
msg(|m| m.set_terminal_action(TerminalAction::new())),
Some("terminal_action"),
),
],
),
(
AuthConnType::Remote,
vec![
(
msg(|m| m.set_screenshot_request(ScreenshotRequest::new())),
None,
),
(msg(|m| m.set_terminal_action(TerminalAction::new())), None),
],
),
(
AuthConnType::PortForward,
vec![
(msg(|m| m.set_test_delay(TestDelay::new())), None),
(misc_msg(|m| m.set_close_reason("closed".to_owned())), None),
(
msg(|m| m.set_file_action(FileAction::new())),
Some("file_action"),
),
(
msg(|m| m.set_terminal_action(TerminalAction::new())),
Some("terminal_action"),
),
(
msg(|m| m.set_screenshot_request(ScreenshotRequest::new())),
Some("screenshot_request"),
),
(misc_msg(|m| m.set_refresh_video(true)), None),
(misc_msg(|m| m.set_refresh_video_display(0)), None),
(
option_msg(|o| {
o.supported_decoding =
hbb_common::protobuf::MessageField::some(Default::default())
}),
None,
),
],
),
];
for (conn_type, messages) in cases {
assert_scopes(conn_type, messages);
}
}
#[test]
fn session_scope_login_options_are_limited_to_authenticated_session_type() {
let mut option = OptionMessage::new();
option.image_quality = ImageQuality::Balanced.into();
option.disable_audio = BoolOption::Yes.into();
option.block_input = BoolOption::Yes.into();
option.privacy_mode = BoolOption::Yes.into();
let (scoped, violation) =
Connection::scoped_login_option(AuthConnType::ViewCamera, &option);
let scoped = scoped.unwrap();
assert_eq!(violation, Some("login.option"));
assert_eq!(
scoped.image_quality.enum_value(),
Ok(ImageQuality::Balanced)
);
assert_eq!(scoped.disable_audio.enum_value(), Ok(BoolOption::Yes));
assert_eq!(scoped.block_input.enum_value(), Ok(BoolOption::NotSet));
assert_eq!(scoped.privacy_mode.enum_value(), Ok(BoolOption::NotSet));
let (scoped, violation) =
Connection::scoped_login_option(AuthConnType::FileTransfer, &option);
assert!(scoped.is_none());
assert_eq!(violation, Some("login.option"));
}
#[test]
fn session_scope_limited_render_noop_options_reject_mixed_fields() {
for conn_type in [
AuthConnType::FileTransfer,
AuthConnType::Terminal,
AuthConnType::PortForward,
] {
let supported_decoding_only = option_msg(set_supported_decoding);
assert_eq!(
Connection::authorized_message_scope_violation(conn_type, &supported_decoding_only),
None
);
let mixed_option = option_msg(|o| {
set_supported_decoding(o);
o.disable_audio = BoolOption::Yes.into();
});
assert_eq!(
Connection::authorized_message_scope_violation(conn_type, &mixed_option),
Some("misc.option")
);
}
}
#[test]
fn session_scope_view_camera_options_keep_only_camera_fields() {
let mut option = OptionMessage::new();
option.image_quality = ImageQuality::Balanced.into();
option.custom_image_quality = 80;
option.custom_fps = 24;
set_supported_decoding(&mut option);
option.disable_audio = BoolOption::Yes.into();
option.block_input = BoolOption::Yes.into();
option.disable_clipboard = BoolOption::Yes.into();
option.enable_file_transfer = BoolOption::Yes.into();
option.terminal_persistent = BoolOption::Yes.into();
let (scoped, violation) =
Connection::scoped_login_option(AuthConnType::ViewCamera, &option);
let scoped = scoped.unwrap();
assert_eq!(violation, Some("login.option"));
assert_eq!(
scoped.image_quality.enum_value(),
Ok(ImageQuality::Balanced)
);
assert_eq!(scoped.custom_image_quality, 80);
assert_eq!(scoped.custom_fps, 24);
assert!(scoped.supported_decoding.is_some());
assert_eq!(scoped.disable_audio.enum_value(), Ok(BoolOption::Yes));
assert_eq!(scoped.block_input.enum_value(), Ok(BoolOption::NotSet));
assert_eq!(
scoped.disable_clipboard.enum_value(),
Ok(BoolOption::NotSet)
);
assert_eq!(
scoped.enable_file_transfer.enum_value(),
Ok(BoolOption::NotSet)
);
assert_eq!(
scoped.terminal_persistent.enum_value(),
Ok(BoolOption::NotSet)
);
}
}
+10 -6
View File
@@ -77,7 +77,7 @@ lazy_static::lazy_static! {
pub static ref VIDEO_QOS: Arc<Mutex<VideoQoS>> = Default::default();
pub static ref IS_UAC_RUNNING: Arc<Mutex<bool>> = Default::default();
pub static ref IS_FOREGROUND_WINDOW_ELEVATED: Arc<Mutex<bool>> = Default::default();
static ref SCREENSHOTS: Mutex<HashMap<usize, Screenshot>> = Default::default();
static ref SCREENSHOTS: Mutex<HashMap<(VideoSource, usize), Screenshot>> = Default::default();
}
struct Screenshot {
@@ -192,7 +192,7 @@ impl VideoFrameController {
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
pub enum VideoSource {
Monitor,
Camera,
@@ -725,7 +725,8 @@ fn run(vs: VideoService) -> ResultType<()> {
Ok(frame) => {
repeat_encode_counter = 0;
if frame.valid() {
let screenshot = SCREENSHOTS.lock().unwrap().remove(&display_idx);
let screenshot_key = (vs.source, display_idx);
let screenshot = SCREENSHOTS.lock().unwrap().remove(&screenshot_key);
if let Some(mut screenshot) = screenshot {
let restore_vram = screenshot.restore_vram;
let (msg, w, h, data) = match &frame {
@@ -754,7 +755,10 @@ fn run(vs: VideoService) -> ResultType<()> {
#[cfg(all(windows, feature = "vram"))]
VRamEncoder::set_not_use(sp.name(), true);
screenshot.restore_vram = true;
SCREENSHOTS.lock().unwrap().insert(display_idx, screenshot);
SCREENSHOTS
.lock()
.unwrap()
.insert(screenshot_key, screenshot);
_raii.try_vram = false;
bail!("SWITCH");
}
@@ -1348,9 +1352,9 @@ fn check_qos(
Ok(())
}
pub fn set_take_screenshot(display_idx: usize, sid: String, tx: Sender) {
pub fn set_take_screenshot(source: VideoSource, display_idx: usize, sid: String, tx: Sender) {
SCREENSHOTS.lock().unwrap().insert(
display_idx,
(source, display_idx),
Screenshot {
sid,
tx,
+6 -4
View File
@@ -1809,10 +1809,12 @@ impl<T: InvokeUiSession> Interface for Session<T> {
self.msgbox("error", "Error", msg, "");
return;
}
self.try_change_init_resolution(pi.current_display);
let p = self.lc.read().unwrap().should_auto_login();
if !p.is_empty() {
input_os_password(p, true, self.clone());
if !self.is_view_camera() {
self.try_change_init_resolution(pi.current_display);
let p = self.lc.read().unwrap().should_auto_login();
if !p.is_empty() {
input_os_password(p, true, self.clone());
}
}
let current = &pi.displays[pi.current_display as usize];
self.set_display(