diff --git a/flutter/lib/common/widgets/toolbar.dart b/flutter/lib/common/widgets/toolbar.dart index 9653b5478..83638000b 100644 --- a/flutter/lib/common/widgets/toolbar.dart +++ b/flutter/lib/common/widgets/toolbar.dart @@ -606,7 +606,7 @@ List 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) { diff --git a/libs/hbb_common b/libs/hbb_common index a920d0094..7e1c392c6 160000 --- a/libs/hbb_common +++ b/libs/hbb_common @@ -1 +1 @@ -Subproject commit a920d00945e1d2441b3f77b2677054cb8c3d9dd2 +Subproject commit 7e1c392c62d39c364127307cd408421dd5f8cfb0 diff --git a/src/client/io_loop.rs b/src/client/io_loop.rs index 68cd69700..a97b6ea70 100644 --- a/src/client/io_loop.rs +++ b/src/client/io_loop.rs @@ -1090,6 +1090,9 @@ impl Remote { } 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 Remote { } 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") diff --git a/src/flutter.rs b/src/flutter.rs index 73f2dbde3..e6b325cbe 100644 --- a/src/flutter.rs +++ b/src/flutter.rs @@ -1437,7 +1437,7 @@ fn try_send_close_event(event_stream: &Option>) { 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) diff --git a/src/flutter_ffi.rs b/src/flutter_ffi.rs index 9595ddd31..d83cc3720 100644 --- a/src/flutter_ffi.rs +++ b/src/flutter_ffi.rs @@ -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(); } } diff --git a/src/server/connection.rs b/src/server/connection.rs index a3e0ccafa..40a1606ef 100644 --- a/src/server/connection.rs +++ b/src/server/connection.rs @@ -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, 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 { 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 { + 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 { + 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, 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, 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, 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) -> 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)>, + ) { + 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) + ); + } } diff --git a/src/server/video_service.rs b/src/server/video_service.rs index 15f0ef893..9d97b1ce9 100644 --- a/src/server/video_service.rs +++ b/src/server/video_service.rs @@ -77,7 +77,7 @@ lazy_static::lazy_static! { pub static ref VIDEO_QOS: Arc> = Default::default(); pub static ref IS_UAC_RUNNING: Arc> = Default::default(); pub static ref IS_FOREGROUND_WINDOW_ELEVATED: Arc> = Default::default(); - static ref SCREENSHOTS: Mutex> = Default::default(); + static ref SCREENSHOTS: Mutex> = 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, diff --git a/src/ui_session_interface.rs b/src/ui_session_interface.rs index 1e35672ec..59f81562d 100644 --- a/src/ui_session_interface.rs +++ b/src/ui_session_interface.rs @@ -1809,10 +1809,12 @@ impl Interface for Session { 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(