CVE-2026-95305
Overview
Background
- Chromoting
- the Chrome Remote Desktop host/client stack, whose Linux host shows a disconnect dialog identifying the connected remote user.
- `DisconnectWindowGtk`
- the GTK implementation of the host disconnect window that renders the client identity in a
GtkLabelnamedmessage_. - `client_jid`
- the connecting client’s Jabber ID (e.g. an email plus a resource suffix); its local part is used as the displayed username.
- `PANGO_ELLIPSIZE_MIDDLE`
- a Pango/GTK label attribute that clips overflowing text and inserts an ellipsis rather than letting the label grow unbounded.
Root Cause Analysis
The vulnerable path is DisconnectWindowGtk::ShowSharingUI (via SetLabelText/label setup in the diff), which derives username from client_jid and passes it straight into l10n_util::GetStringFUTF8(IDS_MESSAGE_SHARED, username) for display in an unwrapped, un-ellipsized GtkLabel. Because the label had no length bound and no ellipsization, an authenticated remote user connecting with an oversized email/JID caused the label — and therefore the whole window — to grow past the screen boundary, pushing the disconnect button off-screen so the local user could not see or use it. The invariant violated is that host-controlled security UI must remain fully on-screen and legible regardless of attacker-supplied identity strings.
The fix truncates username to kMaxUsernameLength (50 UTF-16 characters) at a valid character boundary using base::i18n::UTF16CharIterator::LowerBound, collapses whitespace with base::CollapseWhitespace, configures the label with gtk_label_set_ellipsize/gtk_label_set_max_width_chars, and clamps the window’s horizontal placement with std::max(0, ...) so the dialog stays within screen bounds.
Attack Path
- Connect with a crafted identity An authenticated remote user initiates a Chrome Remote Desktop session to a Linux host using a JID whose local part (email) is extremely long.
- Username flows into the label unbounded
The host builds
usernamefromclient_jid.substr(0, client_jid.find('/'))and formats it intoIDS_MESSAGE_SHARED, setting it as the text of the un-ellipsizedmessage_label. - Window grows past screen edge
The oversized label expands the disconnect window horizontally beyond the display geometry, and centered placement (
(geometry.width - width) / 2) yields a negative left offset, shifting the window off-screen. - Disconnect control is hidden The disconnect button is pushed off the visible screen area, so the local host user cannot readily terminate the remote session (UI misrepresentation / spoofing of the security-relevant disconnect affordance). (inferred)
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
FakeClientSessionControlremoting/host/disconnect_window_linux_unittest.cc |
modified | |
TestResourceBundleDelegateremoting/host/disconnect_window_linux_unittest.cc |
modified | |
ifremoting/host/disconnect_window_linux_unittest.cc |
modified | |
DisconnectWindowLinuxTestremoting/host/disconnect_window_linux_unittest.cc |
modified | |
resource_swapper_remoting/host/disconnect_window_linux_unittest.cc |
modified | |
TEST_Fremoting/host/disconnect_window_linux_unittest.cc |
modified | |
ifremoting/host/BUILD.gn |
modified |
Files Changed
remoting/host/disconnect_window_linux.ccremoting/host/BUILD.gnremoting/host/disconnect_window_linux_unittest.cc
Audit Directions
- Attacker-controlled strings sizing UIAudit host/security dialogs that render remote-supplied identities (JIDs, emails, device names) in labels without ellipsization or length bounds.
- Unclamped window placementReview dialog positioning math that centers on screen geometry without clamping to non-negative on-screen coordinates.
- Unicode-unsafe truncationCheck other truncation sites to ensure they cut at character boundaries (e.g.
base::i18n::UTF16CharIterator) rather than splitting surrogate pairs.
Patch
From 368bc15964d9580eb6f6a875c25280d8a6f4c504 Mon Sep 17 00:00:00 2001 From: Joe Downing <[email protected]> Date: Mon, 31 Aug 2026 14:28:54 -0700 Subject: [PATCH] [remoting] Truncate client username in Linux disconnect window In Chrome Remote Desktop on Linux, the disconnect dialog displays the connecting client's identity in an unwrapped, un-ellipsized GtkLabel. When an authenticated remote user connects with an oversized email address, the window can expand beyond the screen boundary, pushing the disconnect button off-screen. This change collapses whitespace and safely truncates the username to 50 UTF-16 characters at a valid character boundary before formatting IDS_MESSAGE_SHARED, preserving localized punctuation. It also configures the message label with PANGO_ELLIPSIZE_MIDDLE and max_width_chars, and clamps window placement within screen boundaries. Fixed: 553921181 TAG=workflow TAG=agy CONV=180927ee-f343-45e4-a0ce-c2b7b7f11b75 Change-Id: Ie6088bc4d1e5d4dc48b9fb87efe3f4547868dc07 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8315589 Reviewed-by: Jamie Walch <[email protected]> Commit-Queue: Joe Downing <[email protected]> Cr-Commit-Position: refs/heads/main@{#1689275} --- diff --git a/remoting/host/disconnect_window_linux.cc b/remoting/host/disconnect_window_linux.cc index dd48ea8..687e36a 100644 --- a/remoting/host/disconnect_window_linux.cc +++ b/remoting/host/disconnect_window_linux.cc @@ -4,6 +4,7 @@ #include <gtk/gtk.h> +#include <algorithm> #include <memory> #include <numbers> #include <vector> @@ -11,6 +12,7 @@ #include "base/check_op.h" #include "base/compiler_specific.h" #include "base/functional/bind.h" +#include "base/i18n/char_iterator.h" #include "base/location.h" #include "base/memory/raw_ptr.h" #include "base/memory/weak_ptr.h" @@ -32,6 +34,9 @@ // The amount of time to wait before allowing another position toggle. constexpr base::TimeDelta kToggleCooldown = base::Seconds(3); +// Maximum length of the username / client identity in UTF-16 characters. +constexpr size_t kMaxUsernameLength = 50; + // Margins from screen edges to ensure the dialog is not obscured by the top bar // or an auto-hiding dock/panel at the bottom. constexpr int kTopMargin = 40; @@ -250,10 +255,13 @@ &DisconnectWindowGtk::OnToggleClicked); message_ = gtk_label_new(nullptr); + gtk_label_set_ellipsize(GTK_LABEL(message_.get()), PANGO_ELLIPSIZE_MIDDLE); + gtk_label_set_max_width_chars(GTK_LABEL(message_.get()), 30); #if GTK_CHECK_VERSION(3, 90, 0) gtk_box_pack_start(GTK_BOX(button_row), message_.get()); + gtk_widget_set_hexpand(message_.get(), TRUE); #else - gtk_box_pack_start(GTK_BOX(button_row), message_.get(), FALSE, FALSE, 0); + gtk_box_pack_start(GTK_BOX(button_row), message_.get(), TRUE, TRUE, 0); #endif button_ = gtk_button_new_with_label( @@ -296,9 +304,21 @@ std::string client_jid = client_session_control_->client_jid(); std::u16string username = base::UTF8ToUTF16(client_jid.substr(0, client_jid.find('/'))); - gtk_label_set_text( - GTK_LABEL(message_.get()), - l10n_util::GetStringFUTF8(IDS_MESSAGE_SHARED, username).c_str()); + username = base::CollapseWhitespace(username, + /*trim_sequences_with_line_breaks=*/true); + // Truncate username safely at a Unicode character boundary so that + // truncation does not split UTF-16 surrogate pairs. Truncating before + // formatting ensures localized punctuation and grammar in IDS_MESSAGE_SHARED + // (e.g. trailing periods) are preserved. + if (username.length() > kMaxUsernameLength) { + username.erase( + base::i18n::UTF16CharIterator::LowerBound(username, kMaxUsernameLength) + .array_pos()); + } + + std::string message_text = + l10n_util::GetStringFUTF8(IDS_MESSAGE_SHARED, username); + gtk_label_set_text(GTK_LABEL(message_.get()), message_text.c_str()); SetDialogPosition(); gtk_window_present(window); } @@ -405,7 +425,7 @@ height = requisition.height; } - int left = geometry.x + (geometry.width - width) / 2; + int left = geometry.x + std::max(0, (geometry.width - width) / 2); int top = (g_current_anchor == WindowAnchor::kTop) ? (geometry.y + kTopMargin) : (geometry.y + geometry.height - height - kBottomMargin); diff --git a/remoting/host/disconnect_window_linux_unittest.cc b/remoting/host/disconnect_window_linux_unittest.cc new file mode 100644 index 0000000..3b398b4 --- /dev/null +++ b/remoting/host/disconnect_window_linux_unittest.cc @@ -0,0 +1,180 @@ +// Copyright 2026 The Chromium Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include <gtk/gtk.h> + +#include <memory> +#include <string> +#include <utility> + +#include "base/memory/ref_counted_memory.h" +#include "base/memory/weak_ptr.h" +#include "base/test/task_environment.h" +#include "remoting/base/string_resources.h" +#include "remoting/host/client_session_control.h" +#include "remoting/host/host_mock_objects.h" +#include "remoting/host/host_window.h" +#include "testing/gmock/include/gmock/gmock.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "ui/base/resource/resource_bundle.h" +#include "ui/base/resource/resource_scale_factor.h" + +namespace remoting { + +namespace { + +class FakeClientSessionControl : public MockClientSessionControl { + public: + explicit FakeClientSessionControl(std::string client_jid) + : client_jid_(std::move(client_jid)) { + EXPECT_CALL(*this, client_jid()) + .WillRepeatedly(testing::ReturnRef(client_jid_)); + } + + ~FakeClientSessionControl() override = default; + + base::WeakPtr<ClientSessionControl> GetWeakPtr() { + return weak_factory_.GetWeakPtr(); + } + + private: + std::string client_jid_; + base::WeakPtrFactory<FakeClientSessionControl> weak_factory_{this}; +}; + +class TestResourceBundleDelegate : public ui::ResourceBundle::Delegate { + public: + TestResourceBundleDelegate() = default; + ~TestResourceBundleDelegate() override = default; + + base::FilePath GetPathForResourcePack( + const base::FilePath& pack_path, + ui::ResourceScaleFactor scale_factor) override { + return base::FilePath(); + } + gfx::Image GetImageNamed(int resource_id) override { return gfx::Image(); } + gfx::Image GetNativeImageNamed(int resource_id) override { + return gfx::Image(); + } + bool HasDataResource(int resource_id) const override { return false; } + scoped_refptr<base::RefCountedMemory> LoadDataResourceBytes( + int resource_id, + ui::ResourceScaleFactor scale_factor) override { + return nullptr; + } + std::optional<std::string> LoadDataResourceString(int resource_id) override { + return std::nullopt; + } + bool GetRawDataResource(int resource_id, + ui::ResourceScaleFactor scale_factor, + std::string_view* value) const override { + return false; + } + bool GetLocalizedString(int message_id, + std::u16string* value) const override { + if (message_id == IDS_MESSAGE_SHARED) { + *value = u"Your desktop is shared with $1."; + return true; + } + if (message_id == IDS_PRODUCT_NAME) { + *value = u"Chrome Remote Desktop"; + return true; + } + if (message_id == IDS_STOP_SHARING_BUTTON) { + *value = u"Stop Sharing"; + return true; + } + return false; + } +}; + +bool InitializeGtk() { +#if GTK_CHECK_VERSION(3, 90, 0) + return gtk_init_check(); +#else + return gtk_init_check(nullptr, nullptr); +#endif +} + +} // namespace + +class DisconnectWindowLinuxTest : public testing::Test { + public: + DisconnectWindowLinuxTest() + : task_environment_(base::test::TaskEnvironment::MainThreadType::UI), + resource_bundle_(&resource_delegate_), + resource_swapper_(&resource_bundle_) {} + ~DisconnectWindowLinuxTest() override = default; + + void TearDown() override { + // Pump the event loop to ensure GTK widget destruction and cleanup is + // processed. + task_environment_.RunUntilIdle(); + } + + protected: + base::test::TaskEnvironment task_environment_; + TestResourceBundleDelegate resource_delegate_; + ui::ResourceBundle resource_bundle_; + ui::ResourceBundle::SharedInstanceSwapperForTesting resource_swapper_; +}; + +TEST_F(DisconnectWindowLinuxTest, NormalEmailDoesNotCrash) { + if (!InitializeGtk()) { + GTEST_SKIP() << "No display available for GTK."; + } + + FakeClientSessionControl session_control( + "[email protected]/chromoting_ftl"); + std::unique_ptr<HostWindow> window = HostWindow::CreateDisconnectWindow(); + ASSERT_TRUE(window); + window->Start(session_control.GetWeakPtr()); + EXPECT_TRUE(session_control.GetWeakPtr()); + + window.reset(); + task_environment_.RunUntilIdle(); +} + +TEST_F(DisconnectWindowLinuxTest, LongEmailDoesNotCrash) { + if (!InitializeGtk()) { + GTEST_SKIP() << "No display available for GTK."; + } + + const std::string kLongEmail = + "it-support-desk-remote-assistance-session-verification-operators@" + "secure-remote-support-verification-and-customer-protection-desk." + "assistance-operations-center-for-workspace-hosted-accounts-team." + "example-corporation-global-technical-services-departments.com/" + "chromoting_ftl"; + + FakeClientSessionControl session_control(kLongEmail); + std::unique_ptr<HostWindow> window = HostWindow::CreateDisconnectWindow(); + ASSERT_TRUE(window); + window->Start(session_control.GetWeakPtr()); + EXPECT_TRUE(session_control.GetWeakPtr()); + + window.reset(); + task_environment_.RunUntilIdle(); +} + +TEST_F(DisconnectWindowLinuxTest, WhitespaceAndUnicodeEmailDoesNotCrash) { + if (!InitializeGtk()) { + GTEST_SKIP() << "No display available for GTK."; + } + + // Multi-byte Unicode with whitespace formatting + const std::string kUnicodeEmail = + " \t\n user_with_unicode_测试_🚀@example.com \n/chromoting_ftl"; + + FakeClientSessionControl session_control(kUnicodeEmail); + std::unique_ptr<HostWindow> window = HostWindow::CreateDisconnectWindow(); + ASSERT_TRUE(window); + window->Start(session_control.GetWeakPtr()); + EXPECT_TRUE(session_control.GetWeakPtr()); + + window.reset(); + task_environment_.RunUntilIdle(); +} + +} // namespace remoting diff --git a/remoting/host/BUILD.gn b/remoting/host/BUILD.gn index a234f50..c9e5a15 100644 --- a/remoting/host/BUILD.gn +++ b/remoting/host/BUILD.gn @@ -1031,6 +1031,7 @@ if (is_linux) { sources += [ + "disconnect_window_linux_unittest.cc", "pairing_registry_delegate_linux_unittest.cc", "terminal_process_monitor_linux_unittest.cc", ]
Regression Test / PoC
diff --git a/remoting/host/disconnect_window_linux_unittest.cc b/remoting/host/disconnect_window_linux_unittest.cc
new file mode 100644
index 0000000..3b398b4
--- /dev/null
+++ b/remoting/host/disconnect_window_linux_unittest.cc
@@ -0,0 +1,180 @@
+// Copyright 2026 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#include <gtk/gtk.h>
+
+#include <memory>
+#include <string>
+#include <utility>
+
+#include "base/memory/ref_counted_memory.h"
+#include "base/memory/weak_ptr.h"
+#include "base/test/task_environment.h"
+#include "remoting/base/string_resources.h"
+#include "remoting/host/client_session_control.h"
+#include "remoting/host/host_mock_objects.h"
+#include "remoting/host/host_window.h"
+#include "testing/gmock/include/gmock/gmock.h"
+#include "testing/gtest/include/gtest/gtest.h"
+#include "ui/base/resource/resource_bundle.h"
+#include "ui/base/resource/resource_scale_factor.h"
+
+namespace remoting {
+
+namespace {
+
+class FakeClientSessionControl : public MockClientSessionControl {
+ public:
+ explicit FakeClientSessionControl(std::string client_jid)
+ : client_jid_(std::move(client_jid)) {
+ EXPECT_CALL(*this, client_jid())
+ .WillRepeatedly(testing::ReturnRef(client_jid_));
+ }
+
+ ~FakeClientSessionControl() override = default;
+
+ base::WeakPtr<ClientSessionControl> GetWeakPtr() {
+ return weak_factory_.GetWeakPtr();
+ }
+
+ private:
+ std::string client_jid_;
+ base::WeakPtrFactory<FakeClientSessionControl> weak_factory_{this};
+};
+
+class TestResourceBundleDelegate : public ui::ResourceBundle::Delegate {
+ public:
+ TestResourceBundleDelegate() = default;
+ ~TestResourceBundleDelegate() override = default;
+
+ base::FilePath GetPathForResourcePack(
+ const base::FilePath& pack_path,
+ ui::ResourceScaleFactor scale_factor) override {
+ return base::FilePath();
+ }
+ gfx::Image GetImageNamed(int resource_id) override { return gfx::Image(); }
+ gfx::Image GetNativeImageNamed(int resource_id) override {
+ return gfx::Image();
+ }
+ bool HasDataResource(int resource_id) const override { return false; }
+ scoped_refptr<base::RefCountedMemory> LoadDataResourceBytes(
+ int resource_id,
+ ui::ResourceScaleFactor scale_factor) override {
+ return nullptr;
+ }
+ std::optional<std::string> LoadDataResourceString(int resource_id) override {
+ return std::nullopt;
+ }
+ bool GetRawDataResource(int resource_id,
+ ui::ResourceScaleFactor scale_factor,
+ std::string_view* value) const override {
+ return false;
+ }
+ bool GetLocalizedString(int message_id,
+ std::u16string* value) const override {
+ if (message_id == IDS_MESSAGE_SHARED) {
+ *value = u"Your desktop is shared with $1.";
+ return true;
+ }
+ if (message_id == IDS_PRODUCT_NAME) {
+ *value = u"Chrome Remote Desktop";
+ return true;
+ }
+ if (message_id == IDS_STOP_SHARING_BUTTON) {
+ *value = u"Stop Sharing";
+ return true;
+ }
+ return false;
+ }
+};
+
+bool InitializeGtk() {
+#if GTK_CHECK_VERSION(3, 90, 0)
+ return gtk_init_check();
+#else
+ return gtk_init_check(nullptr, nullptr);
+#endif
+}
+
+} // namespace
+
+class DisconnectWindowLinuxTest : public testing::Test {
+ public:
+ DisconnectWindowLinuxTest()
+ : task_environment_(base::test::TaskEnvironment::MainThreadType::UI),
+ resource_bundle_(&resource_delegate_),
+ resource_swapper_(&resource_bundle_) {}
+ ~DisconnectWindowLinuxTest() override = default;
+
+ void TearDown() override {
+ // Pump the event loop to ensure GTK widget destruction and cleanup is
+ // processed.
+ task_environment_.RunUntilIdle();
+ }
+
+ protected:
+ base::test::TaskEnvironment task_environment_;
+ TestResourceBundleDelegate resource_delegate_;
+ ui::ResourceBundle resource_bundle_;
+ ui::ResourceBundle::SharedInstanceSwapperForTesting resource_swapper_;
+};
+
+TEST_F(DisconnectWindowLinuxTest, NormalEmailDoesNotCrash) {
+ if (!InitializeGtk()) {
+ GTEST_SKIP() << "No display available for GTK.";
+ }
+
+ FakeClientSessionControl session_control(
+ "[email protected]/chromoting_ftl");
+ std::unique_ptr<HostWindow> window = HostWindow::CreateDisconnectWindow();
+ ASSERT_TRUE(window);
+ window->Start(session_control.GetWeakPtr());
+ EXPECT_TRUE(session_control.GetWeakPtr());
+
+ window.reset();
+ task_environment_.RunUntilIdle();
+}
+
+TEST_F(DisconnectWindowLinuxTest, LongEmailDoesNotCrash) {
+ if (!InitializeGtk()) {
+ GTEST_SKIP() << "No display available for GTK.";
+ }
+
+ const std::string kLongEmail =
+ "it-support-desk-remote-assistance-session-verification-operators@"
+ "secure-remote-support-verification-and-customer-protection-desk."
+ "assistance-operations-center-for-workspace-hosted-accounts-team."
+ "example-corporation-global-technical-services-departments.com/"
+ "chromoting_ftl";
+
+ FakeClientSessionControl session_control(kLongEmail);
+ std::unique_ptr<HostWindow> window = HostWindow::CreateDisconnectWindow();
+ ASSERT_TRUE(window);
+ window->Start(session_control.GetWeakPtr());
+ EXPECT_TRUE(session_control.GetWeakPtr());
+
+ window.reset();
+ task_environment_.RunUntilIdle();
+}
+
+TEST_F(DisconnectWindowLinuxTest, WhitespaceAndUnicodeEmailDoesNotCrash) {
+ if (!InitializeGtk()) {
+ GTEST_SKIP() << "No display available for GTK.";
+ }
+
+ // Multi-byte Unicode with whitespace formatting
+ const std::string kUnicodeEmail =
+ " \t\n user_with_unicode_测试_🚀@example.com \n/chromoting_ftl";
+
+ FakeClientSessionControl session_control(kUnicodeEmail);
+ std::unique_ptr<HostWindow> window = HostWindow::CreateDisconnectWindow();
+ ASSERT_TRUE(window);
+ window->Start(session_control.GetWeakPtr());
+ EXPECT_TRUE(session_control.GetWeakPtr());
+
+ window.reset();
+ task_environment_.RunUntilIdle();
+}
+
+} // namespace remoting