diff --git a/components/js_injection/browser/BUILD.gn b/components/js_injection/browser/BUILD.gn index 250afa06f4b59..9f8d24c99ae68 100644 --- a/components/js_injection/browser/BUILD.gn +++ b/components/js_injection/browser/BUILD.gn @@ -34,7 +34,10 @@ source_set("browser_tests") { defines = [ "HAS_OUT_OF_PROC_TEST_RUNNER" ] - sources = [ "navigation_listener_browsertest.cc" ] + sources = [ + "js_to_browser_messaging_prerender_browsertest.cc", + "navigation_listener_browsertest.cc", + ] deps = [ ":browser", diff --git a/components/js_injection/browser/js_to_browser_messaging_prerender_browsertest.cc b/components/js_injection/browser/js_to_browser_messaging_prerender_browsertest.cc new file mode 100644 index 0000000000000..7163583dbc208 --- /dev/null +++ b/components/js_injection/browser/js_to_browser_messaging_prerender_browsertest.cc @@ -0,0 +1,226 @@ +// Copyright 2025 The Chromium Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// POC browser test demonstrating that JsToBrowserMessagingDocumentUserData's +// per-document prerender queue is shared across all registered +// (jsObjectName, world_id) listeners, so on activation the listener whose +// (name, world_id) key sorts first drains EVERY queued message — including +// messages the page posted to other listeners. +// +// On Android WebView this misroutes messages between unrelated +// WebMessageListeners (and across isolated JS worlds). The buggy logic lives +// in platform-neutral components/js_injection C++; this test exercises it via +// content_shell on desktop. + +#include +#include +#include + +#include "base/functional/bind.h" +#include "base/memory/raw_ptr.h" +#include "base/run_loop.h" +#include "base/strings/utf_string_conversions.h" +#include "components/js_injection/browser/js_communication_host.h" +#include "components/js_injection/browser/web_message.h" +#include "components/js_injection/browser/web_message_host.h" +#include "components/js_injection/browser/web_message_host_factory.h" +#include "content/public/browser/render_frame_host.h" +#include "content/public/browser/web_contents.h" +#include "content/public/test/browser_test.h" +#include "content/public/test/browser_test_utils.h" +#include "content/public/test/content_browser_test.h" +#include "content/public/test/content_browser_test_utils.h" +#include "content/public/test/prerender_test_util.h" +#include "content/shell/browser/shell.h" +#include "net/dns/mock_host_resolver.h" +#include "net/test/embedded_test_server/embedded_test_server.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "url/gurl.h" + +namespace js_injection { + +namespace { + +// Records every string-payload message delivered to a single listener. +class RecordingHost : public WebMessageHost { + public: + explicit RecordingHost(std::vector* sink) : sink_(sink) {} + ~RecordingHost() override = default; + + void OnPostMessage(std::unique_ptr message) override { + if (const std::u16string* s = + std::get_if(&message->message)) { + sink_->push_back(*s); + } else { + sink_->push_back(u""); + } + } + + private: + raw_ptr> sink_; +}; + +class RecordingHostFactory : public WebMessageHostFactory { + public: + ~RecordingHostFactory() override = default; + + std::unique_ptr CreateHost( + const std::string& top_level_origin_string, + const std::string& origin_string, + bool is_main_frame, + WebMessageReplyProxy* proxy) override { + ++hosts_created_; + if (on_host_created_) { + on_host_created_.Run(); + } + return std::make_unique(&messages_); + } + + const std::vector& messages() const { return messages_; } + int hosts_created() const { return hosts_created_; } + + void set_on_host_created(base::RepeatingClosure cb) { + on_host_created_ = std::move(cb); + } + + private: + std::vector messages_; + int hosts_created_ = 0; + base::RepeatingClosure on_host_created_; +}; + +} // namespace + +class JsToBrowserMessagingPrerenderBrowserTest + : public content::ContentBrowserTest { + public: + JsToBrowserMessagingPrerenderBrowserTest() + : prerender_helper_(base::BindRepeating( + &JsToBrowserMessagingPrerenderBrowserTest::web_contents, + base::Unretained(this))) { + prerender_helper_.RegisterServerRequestMonitor(embedded_test_server()); + } + + void SetUpOnMainThread() override { + content::ContentBrowserTest::SetUpOnMainThread(); + host_resolver()->AddRule("*", "127.0.0.1"); + ASSERT_TRUE(embedded_test_server()->Start()); + } + + void TearDownOnMainThread() override { + js_communication_host_.reset(); + content::ContentBrowserTest::TearDownOnMainThread(); + } + + content::WebContents* web_contents() { return shell()->web_contents(); } + + GURL GetUrl(const std::string& path) { + return embedded_test_server()->GetURL(path); + } + + protected: + content::test::PrerenderTestHelper prerender_helper_; + std::unique_ptr js_communication_host_; +}; + +IN_PROC_BROWSER_TEST_F(JsToBrowserMessagingPrerenderBrowserTest, + SharedPrerenderQueueMisroutesAcrossListeners) { + // Register two listeners (both main world). On Android WebView these would + // be two distinct Java WebMessageListeners — possibly bound to different + // isolated JS worlds. They are registered BEFORE prerendering starts (Add + // cancels prerendering, so this matches a real attack precondition). + js_communication_host_ = + std::make_unique(web_contents()); + + auto a_factory_owned = std::make_unique(); + auto b_factory_owned = std::make_unique(); + RecordingHostFactory* a_factory = a_factory_owned.get(); + RecordingHostFactory* b_factory = b_factory_owned.get(); + + ASSERT_EQ(u"", js_communication_host_->AddWebMessageHostFactory( + std::move(a_factory_owned), u"aListener", {"*"}, + /*world_identifier=*/0)); + ASSERT_EQ(u"", js_communication_host_->AddWebMessageHostFactory( + std::move(b_factory_owned), u"bListener", {"*"}, + /*world_identifier=*/0)); + + // Navigate to an initial page so prerendering has an initiator. + const GURL kInitialUrl = GetUrl("/empty.html"); + const GURL kPrerenderUrl = GetUrl("/empty.html?prerender"); + ASSERT_TRUE(content::NavigateToURL(web_contents(), kInitialUrl)); + + // Start a prerender. JsCommunicationHost::RenderFrameCreated() will push the + // two JsObjects to the new prerendered frame; the renderer-side + // JsCommunication (wired into content_shell for this test) installs them as + // window.aListener / window.bListener in the main world. + content::PrerenderHostId host_id = + prerender_helper_.AddPrerender(kPrerenderUrl); + content::RenderFrameHost* prerender_rfh = + prerender_helper_.GetPrerenderedMainFrameHost(host_id); + ASSERT_TRUE(prerender_rfh); + ASSERT_EQ(prerender_rfh->GetLifecycleState(), + content::RenderFrameHost::LifecycleState::kPrerendering); + + // Have the prerendered page post one message to each listener. The messages + // are tagged so we can see which listener actually receives which payload. + // Both PostMessage() calls land in the SAME shared + // JsToBrowserMessagingDocumentUserData::queued_messages_ vector. + base::RunLoop a_created, b_created; + a_factory->set_on_host_created(a_created.QuitClosure()); + b_factory->set_on_host_created(b_created.QuitClosure()); + ASSERT_TRUE(content::ExecJs(prerender_rfh, + "aListener.postMessage('msg-for-a');" + "bListener.postMessage('msg-for-b');")); + // Wait until both browser-side hosts have been created (which happens + // synchronously inside JsToBrowserMessaging::PostMessage just before the + // message is queued). After this point both messages are queued. + a_created.Run(); + b_created.Run(); + ASSERT_EQ(1, a_factory->hosts_created()); + ASSERT_EQ(1, b_factory->hosts_created()); + // Nothing delivered yet — still prerendering. + EXPECT_TRUE(a_factory->messages().empty()); + EXPECT_TRUE(b_factory->messages().empty()); + + // Activate the prerender. JsCommunicationHost::RenderFrameHostStateChanged + // iterates the std::map<(name,world_id), JsToBrowserMessaging> in key order + // and calls OnRenderFrameHostActivated() on each. ("aListener",0) sorts + // first; its OnRenderFrameHostActivated() drains and clears the ENTIRE + // shared queue. + prerender_helper_.NavigatePrimaryPage(kPrerenderUrl); + ASSERT_EQ(web_contents()->GetPrimaryMainFrame()->GetLastCommittedURL(), + kPrerenderUrl); + + // === BUG === + // Correct behaviour: each listener receives exactly the message addressed + // to it. Actual behaviour: listener "aListener" receives BOTH messages + // (including the one the page sent to bListener) and "bListener" receives + // nothing. + fprintf(stderr, + "[POC] aListener received %zu message(s):\n", + a_factory->messages().size()); + for (const auto& m : a_factory->messages()) { + fprintf(stderr, "[POC] aListener <- '%s'\n", + base::UTF16ToUTF8(m).c_str()); + } + fprintf(stderr, + "[POC] bListener received %zu message(s):\n", + b_factory->messages().size()); + for (const auto& m : b_factory->messages()) { + fprintf(stderr, "[POC] bListener <- '%s'\n", + base::UTF16ToUTF8(m).c_str()); + } + + // These EXPECTs assert correct behaviour and FAIL because of the bug, + // proving the misrouting. + EXPECT_EQ(a_factory->messages(), + std::vector({u"msg-for-a"})) + << "BUG: aListener absorbed message(s) addressed to bListener"; + EXPECT_EQ(b_factory->messages(), + std::vector({u"msg-for-b"})) + << "BUG: bListener never received its own message — it was drained by " + "aListener"; +} + +} // namespace js_injection diff --git a/content/shell/BUILD.gn b/content/shell/BUILD.gn index 462cfd3ef339e..717a0a0d44084 100644 --- a/content/shell/BUILD.gn +++ b/content/shell/BUILD.gn @@ -337,6 +337,7 @@ static_library("content_shell_lib") { "//components/input", "//components/keyed_service/content", "//components/metrics:net", + "//components/js_injection/renderer", "//components/network_hints/browser", "//components/network_hints/renderer", "//components/network_session_configurator/common", diff --git a/content/shell/renderer/DEPS b/content/shell/renderer/DEPS index 0bd6c577f9f4f..fa0f66eff8762 100644 --- a/content/shell/renderer/DEPS +++ b/content/shell/renderer/DEPS @@ -1,5 +1,6 @@ include_rules = [ "+components/cdm", + "+components/js_injection/renderer", "+components/network_hints/renderer", "+components/surface_embed/buildflags", "+components/surface_embed/renderer", diff --git a/content/shell/renderer/shell_content_renderer_client.cc b/content/shell/renderer/shell_content_renderer_client.cc index 41c3ac45e70d5..feff2a5318c9e 100644 --- a/content/shell/renderer/shell_content_renderer_client.cc +++ b/content/shell/renderer/shell_content_renderer_client.cc @@ -18,6 +18,7 @@ #include "base/task/single_thread_task_runner.h" #include "base/types/pass_key.h" #include "components/cdm/renderer/external_clear_key_key_system_info.h" +#include "components/js_injection/renderer/js_communication.h" #include "components/network_hints/renderer/web_prescient_networking_impl.h" #include "components/surface_embed/buildflags/buildflags.h" #include "components/web_cache/renderer/web_cache_impl.h" @@ -327,6 +328,11 @@ void ShellContentRendererClient::RenderFrameCreated(RenderFrame* render_frame) { // browser tests. If we only create that for browser tests then the override // of this method in WebTestContentRendererClient would not be needed. new ShellRenderFrameObserver(render_frame); + // POC: wire up the renderer side of components/js_injection so that + // js_injection browser-side prerender behaviour can be exercised in + // components_browsertests on desktop. On Android this is done by + // AwContentRendererClient. + new js_injection::JsCommunication(render_frame); } bool ShellContentRendererClient::OverrideCreatePlugin(