blob: b2038ecfe77b366bdbc08ae6d917c1c3db1e8564 [file] [log] [blame]
// Copyright 2022 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#include "extensions/browser/offscreen_document_host.h"
#include "extensions/common/mojom/context_type.mojom.h"
#include "base/test/bind.h"
#include "base/test/values_test_util.h"
#include "chrome/browser/devtools/devtools_window.h"
#include "chrome/browser/devtools/devtools_window_testing.h"
#include "chrome/browser/extensions/extension_apitest.h"
#include "chrome/browser/ui/browser.h"
#include "chrome/browser/ui/browser_finder.h"
#include "chrome/browser/ui/tabs/tab_strip_model.h"
#include "chrome/test/base/ui_test_utils.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/test_navigation_observer.h"
#include "extensions/browser/background_script_executor.h"
#include "extensions/browser/extension_host_registry.h"
#include "extensions/browser/process_manager.h"
#include "extensions/browser/process_map.h"
#include "extensions/browser/script_result_queue.h"
#include "extensions/browser/view_type_utils.h"
#include "extensions/common/constants.h"
#include "extensions/common/features/feature.h"
#include "extensions/common/permissions/permissions_data.h"
#include "extensions/test/test_extension_dir.h"
#include "net/dns/mock_host_resolver.h"
#include "testing/gmock/include/gmock/gmock.h"
namespace extensions {
class OffscreenDocumentBrowserTest : public ExtensionApiTest {
public:
OffscreenDocumentBrowserTest() = default;
~OffscreenDocumentBrowserTest() override = default;
// Creates a new OffscreenDocumentHost and waits for it to load.
std::unique_ptr<OffscreenDocumentHost> CreateOffscreenDocument(
const Extension& extension,
const GURL& url) {
scoped_refptr<content::SiteInstance> site_instance =
ProcessManager::Get(profile())->GetSiteInstanceForURL(url);
content::TestNavigationObserver navigation_observer(url);
navigation_observer.StartWatchingNewWebContents();
auto offscreen_document = std::make_unique<OffscreenDocumentHost>(
extension, site_instance.get(), profile(), url);
offscreen_document->CreateRendererSoon();
navigation_observer.Wait();
EXPECT_TRUE(navigation_observer.last_navigation_succeeded());
return offscreen_document;
}
void SetUpOnMainThread() override {
ExtensionBrowserTest::SetUpOnMainThread();
host_resolver()->AddRule("*", "127.0.0.1");
}
};
// Test basic properties of offscreen documents.
IN_PROC_BROWSER_TEST_F(OffscreenDocumentBrowserTest,
CreateBasicOffscreenDocument) {
static constexpr char kManifest[] =
R"({
"name": "Offscreen Document Test",
"manifest_version": 3,
"version": "0.1"
})";
static constexpr char kOffscreenDocumentHtml[] =
R"(<html>
<body>
<div id="signal">Hello, World</div>
</body>
</html>)";
TestExtensionDir test_dir;
test_dir.WriteManifest(kManifest);
test_dir.WriteFile(FILE_PATH_LITERAL("offscreen.html"),
kOffscreenDocumentHtml);
test_dir.WriteFile(FILE_PATH_LITERAL("other.html"), "<html>Empty</html>");
const Extension* extension = LoadExtension(test_dir.UnpackedPath());
ASSERT_TRUE(extension);
const GURL offscreen_url = extension->GetResourceURL("offscreen.html");
ProcessManager* const process_manager = ProcessManager::Get(profile());
std::unique_ptr<OffscreenDocumentHost> offscreen_document =
CreateOffscreenDocument(*extension, offscreen_url);
// Check basic properties:
content::WebContents* contents = offscreen_document->host_contents();
ASSERT_TRUE(contents);
// - The URL should match the extension's URL.
EXPECT_EQ(offscreen_url, contents->GetLastCommittedURL());
// - The offscreen document should be, well, offscreen; it should not be
// contained within any Browser window.
EXPECT_EQ(nullptr, chrome::FindBrowserWithTab(contents));
// - The view type should be correctly set (it should not be considered a
// background page, tab, or other type of view).
EXPECT_EQ(mojom::ViewType::kOffscreenDocument,
offscreen_document->extension_host_type());
EXPECT_EQ(mojom::ViewType::kOffscreenDocument, GetViewType(contents));
// The offscreen document should be marked as never composited, excluding it
// from certain a11y considerations.
EXPECT_TRUE(contents->GetDelegate()->IsNeverComposited(contents));
{
// Check the registration in the ProcessManager: the offscreen document
// should be associated with the extension and have a registered frame.
ProcessManager::FrameSet frames_for_extension =
process_manager->GetRenderFrameHostsForExtension(extension->id());
ASSERT_EQ(1u, frames_for_extension.size());
content::RenderFrameHost* frame_host = *frames_for_extension.begin();
EXPECT_EQ(offscreen_url, frame_host->GetLastCommittedURL());
EXPECT_EQ(contents, content::WebContents::FromRenderFrameHost(frame_host));
EXPECT_EQ(extension, process_manager->GetExtensionForWebContents(contents));
}
{
// Check the registration in the ExtensionHostRegistry.
ExtensionHostRegistry* host_registry =
ExtensionHostRegistry::Get(profile());
std::vector<ExtensionHost*> hosts =
host_registry->GetHostsForExtension(extension->id());
EXPECT_THAT(hosts, testing::ElementsAre(offscreen_document.get()));
EXPECT_EQ(offscreen_document.get(),
host_registry->GetExtensionHostForPrimaryMainFrame(
offscreen_document->main_frame_host()));
}
{
mojom::ContextType context_type =
ProcessMap::Get(profile())->GetMostLikelyContextType(
extension,
contents->GetPrimaryMainFrame()->GetProcess()->GetDeprecatedID(),
&offscreen_url);
// TODO(crbug.com/40849649): The following check should be:
// EXPECT_EQ(mojom::ContextType::kOffscreenExtension, context_type);
// However, currently the ProcessMap can't differentiate between a
// privileged extension context and an offscreen document, as both run in
// the primary extension process and have committed to the extension origin.
// This is okay (this boundary isn't a security boundary), but is
// technically incorrect.
// See also comment in ProcessMap::GetMostLikelyContextType().
EXPECT_EQ(mojom::ContextType::kPrivilegedExtension, context_type);
}
{
// Check the document loaded properly (and, implicitly check that it does,
// in fact, have a DOM).
static constexpr char kScript[] =
R"({
let div = document.getElementById('signal');
div ? div.innerText : '<no div>';
})";
EXPECT_EQ("Hello, World", EvalJs(contents, kScript));
}
{
// Check that the offscreen document runs in the same process as other
// extension frames. Do this by comparing it to another extension page in
// a tab.
ASSERT_TRUE(ui_test_utils::NavigateToURL(
browser(), extension->GetResourceURL("other.html")));
content::WebContents* tab_contents =
browser()->tab_strip_model()->GetActiveWebContents();
EXPECT_EQ(tab_contents->GetPrimaryMainFrame()->GetProcess(),
contents->GetPrimaryMainFrame()->GetProcess());
}
}
// Tests that extension API access in offscreen documents is extremely limited.
IN_PROC_BROWSER_TEST_F(OffscreenDocumentBrowserTest, APIAccessIsLimited) {
static constexpr char kManifest[] =
R"({
"name": "Offscreen Document Test",
"manifest_version": 3,
"version": "0.1",
"permissions": ["storage", "tabs"]
})";
TestExtensionDir test_dir;
test_dir.WriteManifest(kManifest);
test_dir.WriteFile(FILE_PATH_LITERAL("offscreen.html"),
"<html>Offscreen</html>");
const Extension* extension = LoadExtension(test_dir.UnpackedPath());
ASSERT_TRUE(extension);
const GURL offscreen_url = extension->GetResourceURL("offscreen.html");
std::unique_ptr<OffscreenDocumentHost> offscreen_document =
CreateOffscreenDocument(*extension, offscreen_url);
content::WebContents* contents = offscreen_document->host_contents();
{
// Offscreen documents have very limited API access. Even though the
// extension has the storage and tabs permissions, the only extension API
// exposed should be `runtime` (and our test API).
constexpr char kScript[] =
R"({
let keys = Object.keys(chrome);
JSON.stringify(keys.sort());
})";
EXPECT_EQ(R"(["csi","loadTimes","runtime","test"])",
EvalJs(contents, kScript));
}
{
// Even runtime should be fairly restricted. Enums are always exposed, and
// offscreen documents have access to message passing capabilities and their
// own extension ID and URL. Intentionally absent are methods like
// `runtime.getViews()`.
constexpr char kScript[] =
R"({
let keys = Object.keys(chrome.runtime);
JSON.stringify(keys.sort());
})";
static constexpr char kExpectedProperties[] =
// Enums.
R"(["ContextType","OnInstalledReason","OnRestartRequiredReason",)"
R"("PlatformArch","PlatformNaclArch","PlatformOs",)"
R"("RequestUpdateCheckStatus",)"
// Methods and events.
R"("connect","dynamicId","getURL","id","onConnect",)"
R"("onConnectExternal","onMessage","onMessageExternal",)"
R"("sendMessage"])";
EXPECT_EQ(kExpectedProperties, EvalJs(contents, kScript));
}
}
// Exercise message passing between the offscreen document and a corresponding
// service worker.
IN_PROC_BROWSER_TEST_F(OffscreenDocumentBrowserTest, MessagingTest) {
static constexpr char kManifest[] =
R"({
"name": "Offscreen Document Test",
"manifest_version": 3,
"version": "0.1",
"background": { "service_worker": "background.js" }
})";
static constexpr char kOffscreenDocumentHtml[] =
R"(<html>
Offscreen
<script src="offscreen.js"></script>
</html>)";
// Both the offscreen document and the service worker have methods to send a
// message and to echo back arguments with a reply.
static constexpr char kOffscreenDocumentJs[] =
R"(chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
sendResponse({msg, sender, reply: 'offscreen reply'});
});
function sendMessageFromOffscreen() {
chrome.runtime.sendMessage('message from offscreen', (response) => {
chrome.test.sendScriptResult(response);
});
})";
static constexpr char kBackgroundJs[] =
R"(chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
sendResponse({msg, sender, reply: 'background reply'});
});
function sendMessageFromBackground() {
chrome.runtime.sendMessage('message from background', (response) => {
chrome.test.sendScriptResult(response);
});
})";
TestExtensionDir test_dir;
test_dir.WriteManifest(kManifest);
test_dir.WriteFile(FILE_PATH_LITERAL("offscreen.html"),
kOffscreenDocumentHtml);
test_dir.WriteFile(FILE_PATH_LITERAL("offscreen.js"), kOffscreenDocumentJs);
test_dir.WriteFile(FILE_PATH_LITERAL("background.js"), kBackgroundJs);
const Extension* extension = LoadExtension(test_dir.UnpackedPath());
ASSERT_TRUE(extension);
const GURL offscreen_url = extension->GetResourceURL("offscreen.html");
std::unique_ptr<OffscreenDocumentHost> offscreen_document =
CreateOffscreenDocument(*extension, offscreen_url);
{
// First, try sending a message from the service worker to the offscreen
// document.
std::string expected = content::JsReplace(
R"({
"msg": "message from background",
"reply": "offscreen reply",
"sender": {
"id": $1,
"url": $2
}
})",
extension->id(), extension->GetResourceURL("background.js"));
base::Value result = BackgroundScriptExecutor::ExecuteScript(
profile(), extension->id(), "sendMessageFromBackground();",
BackgroundScriptExecutor::ResultCapture::kSendScriptResult);
EXPECT_THAT(result, base::test::IsJson(expected));
}
{
// Next, send a message in the other direction, from the offscreen document
// to the service worker.
std::string expected = content::JsReplace(
R"({
"msg": "message from offscreen",
"reply": "background reply",
"sender": {
"id": $1,
"origin": $2,
"url": $3
}
})",
extension->id(), extension->origin(), offscreen_url);
content::WebContents* contents = offscreen_document->host_contents();
ScriptResultQueue result_queue;
content::ExecuteScriptAsync(contents, "sendMessageFromOffscreen();");
base::Value result = result_queue.GetNextResult();
EXPECT_THAT(result, base::test::IsJson(expected));
}
}
// Tests the cross-origin permissions of offscreen documents. While offscreen
// documents have limited API access, they *should* retain the ability to
// bypass CORS requirements if they have the corresponding host permission.
// This is because one of the primary use cases for offscreen documents is
// DOM parsing, which may be done via a fetch() + DOMParser.
IN_PROC_BROWSER_TEST_F(OffscreenDocumentBrowserTest,
CrossOriginFetchPermissions) {
ASSERT_TRUE(StartEmbeddedTestServer());
static constexpr char kManifest[] =
R"({
"name": "Offscreen Document Test",
"manifest_version": 3,
"version": "0.1",
"host_permissions": ["http://allowed.example/*"]
})";
TestExtensionDir test_dir;
test_dir.WriteManifest(kManifest);
test_dir.WriteFile(FILE_PATH_LITERAL("offscreen.html"),
"<html>Offscreen</html>");
const Extension* extension = LoadExtension(test_dir.UnpackedPath());
ASSERT_TRUE(extension);
const GURL offscreen_url = extension->GetResourceURL("offscreen.html");
std::unique_ptr<OffscreenDocumentHost> offscreen_document =
CreateOffscreenDocument(*extension, offscreen_url);
const GURL allowed_url = embedded_test_server()->GetURL(
"allowed.example", "/extensions/fetch1.html");
const GURL restricted_url = embedded_test_server()->GetURL(
"restricted.example", "/extensions/fetch2.html");
// Sanity check the permissions are as we expect them to be for the given
// URLs, independent of tab ID.
const int kTabId = extension_misc::kUnknownTabId;
EXPECT_EQ(PermissionsData::PageAccess::kAllowed,
extension->permissions_data()->GetPageAccess(allowed_url, kTabId,
nullptr));
EXPECT_EQ(PermissionsData::PageAccess::kDenied,
extension->permissions_data()->GetPageAccess(restricted_url, kTabId,
nullptr));
content::WebContents* contents = offscreen_document->host_contents();
static constexpr char kFetchScript[] =
R"((async () => {
let msg;
try {
let res = await fetch($1);
msg = await res.text();
} catch (e) {
msg = e.toString();
}
return msg;
})();)";
EXPECT_EQ("fetch1 - cat\n",
EvalJs(contents, content::JsReplace(kFetchScript, allowed_url)));
EXPECT_EQ("TypeError: Failed to fetch",
EvalJs(contents, content::JsReplace(kFetchScript, restricted_url)));
}
// Tests that content scripts matching iframes contained within an offscreen
// document execute.
IN_PROC_BROWSER_TEST_F(OffscreenDocumentBrowserTest,
ContentScriptsInNestedIframes) {
ASSERT_TRUE(StartEmbeddedTestServer());
// Load an extension that executes a content script on http://allowed.example.
static constexpr char kManifest[] =
R"({
"name": "Offscreen Document Test",
"manifest_version": 3,
"version": "0.1",
"content_scripts": [{
"matches": ["http://allowed.example/*"],
"all_frames": true,
"run_at": "document_end",
"js": ["content_script.js"]
}]
})";
static constexpr char kOffscreenHtml[] =
R"(<html>
<iframe id="allowed-frame" name="allowed-frame"></iframe>
<iframe id="restricted-frame" name="restricted-frame"></iframe>
</html>)";
static constexpr char kContentScriptJs[] =
R"(let d = document.createElement('div');
d.id = 'script-div';
d.textContent = 'injection';
document.body.appendChild(d);)";
TestExtensionDir test_dir;
test_dir.WriteManifest(kManifest);
test_dir.WriteFile(FILE_PATH_LITERAL("offscreen.html"), kOffscreenHtml);
test_dir.WriteFile(FILE_PATH_LITERAL("content_script.js"), kContentScriptJs);
const Extension* extension = LoadExtension(test_dir.UnpackedPath());
ASSERT_TRUE(extension);
const GURL offscreen_url = extension->GetResourceURL("offscreen.html");
std::unique_ptr<OffscreenDocumentHost> offscreen_document =
CreateOffscreenDocument(*extension, offscreen_url);
content::WebContents* contents = offscreen_document->host_contents();
const GURL allowed_url =
embedded_test_server()->GetURL("allowed.example", "/title1.html");
const GURL restricted_url =
embedded_test_server()->GetURL("restricted.example", "/title2.html");
// Returns the frame with the matching name within the offscreen document.
auto get_frame_with_name = [contents](const std::string& name) {
return content::FrameMatchingPredicate(
contents->GetPrimaryPage(),
base::BindRepeating(&content::FrameMatchesName, name));
};
// We annoyingly cannot use content::NavigateIframeToURL() because it
// internally uses eval(), which violates the offscreen document's CSP. So,
// we roll our own navigation helper.
auto navigate_frame = [contents](const std::string& frame_id,
const GURL& target_url) {
static constexpr char kNavigateScript[] =
R"({
let iframe = document.getElementById($1);
iframe.src = $2;
})";
content::TestNavigationObserver load_observer(contents);
content::ExecuteScriptAsyncWithoutUserGesture(
contents, content::JsReplace(kNavigateScript, frame_id, target_url));
load_observer.Wait();
};
// A helper function to retrieve the text content of the expected injected
// div, if the div exists.
auto get_script_div_in_frame = [](content::RenderFrameHost* frame) {
static constexpr char kGetScriptDiv[] =
R"(var d = document.getElementById('script-div');
d ? d.textContent : '<no div>';)";
return content::EvalJs(frame, kGetScriptDiv).ExtractString();
};
// Navigate a frame to a URL that matches an extension content script; the
// content script should inject.
{
navigate_frame("allowed-frame", allowed_url);
content::RenderFrameHost* allowed_frame =
get_frame_with_name("allowed-frame");
ASSERT_TRUE(allowed_frame);
EXPECT_EQ(allowed_url, allowed_frame->GetLastCommittedURL());
EXPECT_EQ("injection", get_script_div_in_frame(allowed_frame));
}
// Now, navigate a frame to a URL that does *not* match the script; the
// script shouldn't inject.
{
navigate_frame("restricted-frame", restricted_url);
content::RenderFrameHost* restricted_frame =
get_frame_with_name("restricted-frame");
ASSERT_TRUE(restricted_frame);
EXPECT_EQ(restricted_url, restricted_frame->GetLastCommittedURL());
EXPECT_EQ("<no div>", get_script_div_in_frame(restricted_frame));
}
}
// Tests attaching and detaching a devtools window to the offscreen document.
IN_PROC_BROWSER_TEST_F(OffscreenDocumentBrowserTest,
AttachingDevToolsInspector) {
ASSERT_TRUE(StartEmbeddedTestServer());
static constexpr char kManifest[] =
R"({
"name": "Offscreen Document Test",
"manifest_version": 3,
"version": "0.1"
})";
TestExtensionDir test_dir;
test_dir.WriteManifest(kManifest);
test_dir.WriteFile(FILE_PATH_LITERAL("offscreen.html"),
"<html>offscreen</html>");
const Extension* extension = LoadExtension(test_dir.UnpackedPath());
ASSERT_TRUE(extension);
const GURL offscreen_url = extension->GetResourceURL("offscreen.html");
std::unique_ptr<OffscreenDocumentHost> offscreen_document =
CreateOffscreenDocument(*extension, offscreen_url);
content::WebContents* contents = offscreen_document->host_contents();
DevToolsWindowTesting::OpenDevToolsWindowSync(contents, profile(),
/*is_docked=*/true);
DevToolsWindow* dev_tools_window =
DevToolsWindow::GetInstanceForInspectedWebContents(contents);
ASSERT_TRUE(dev_tools_window);
DevToolsWindowTesting::CloseDevToolsWindowSync(dev_tools_window);
EXPECT_FALSE(DevToolsWindow::GetInstanceForInspectedWebContents(contents));
}
// Tests that navigation is disallowed in offscreen documents.
IN_PROC_BROWSER_TEST_F(OffscreenDocumentBrowserTest, NavigationIsDisallowed) {
ASSERT_TRUE(StartEmbeddedTestServer());
static constexpr char kManifest[] =
R"({
"name": "Offscreen Document Test",
"manifest_version": 3,
"version": "0.1"
})";
TestExtensionDir test_dir;
test_dir.WriteManifest(kManifest);
test_dir.WriteFile(FILE_PATH_LITERAL("offscreen.html"),
"<html>offscreen</html>");
test_dir.WriteFile(FILE_PATH_LITERAL("other.html"),
"<html>other page</html>");
const Extension* extension = LoadExtension(test_dir.UnpackedPath());
ASSERT_TRUE(extension);
const GURL offscreen_url = extension->GetResourceURL("offscreen.html");
std::unique_ptr<OffscreenDocumentHost> offscreen_document =
CreateOffscreenDocument(*extension, offscreen_url);
content::WebContents* contents = offscreen_document->host_contents();
auto expect_navigation_failure = [contents, offscreen_url](const GURL& url) {
content::TestNavigationObserver navigation_observer(contents);
content::ExecuteScriptAsync(
contents, content::JsReplace("window.location.href = $1;", url));
navigation_observer.Wait();
EXPECT_FALSE(navigation_observer.last_navigation_succeeded());
EXPECT_EQ(offscreen_url,
contents->GetPrimaryMainFrame()->GetLastCommittedURL());
};
// Try to navigate the offscreen document to a web URL. The navigation
// should fail (it's canceled).
expect_navigation_failure(
embedded_test_server()->GetURL("example.com", "/title1.html"));
// Repeat with an extension resource. This should also fail - we don't allow
// offscreen documents to navigate themselves, even to another extension
// resource.
expect_navigation_failure(extension->GetResourceURL("other.html"));
}
// Tests calling window.close() in an offscreen document.
IN_PROC_BROWSER_TEST_F(OffscreenDocumentBrowserTest, CallWindowClose) {
static constexpr char kManifest[] =
R"({
"name": "Offscreen Document Test",
"manifest_version": 3,
"version": "0.1"
})";
TestExtensionDir test_dir;
test_dir.WriteManifest(kManifest);
test_dir.WriteFile(FILE_PATH_LITERAL("offscreen.html"),
"<html>offscreen</html>");
const Extension* extension = LoadExtension(test_dir.UnpackedPath());
ASSERT_TRUE(extension);
const GURL offscreen_url = extension->GetResourceURL("offscreen.html");
{
std::unique_ptr<OffscreenDocumentHost> offscreen_document =
CreateOffscreenDocument(*extension, offscreen_url);
// Create a simple handler for the window.close() call that deletes the
// document.
base::RunLoop run_loop;
auto close_handler = [&run_loop, &offscreen_document](ExtensionHost* host) {
ASSERT_EQ(offscreen_document.get(), host);
offscreen_document.reset();
run_loop.Quit();
};
offscreen_document->SetCloseHandler(
base::BindLambdaForTesting(close_handler));
content::ExecuteScriptAsync(offscreen_document->host_contents(),
"window.close();");
run_loop.Run();
// The close handler should have been invoked.
EXPECT_EQ(nullptr, offscreen_document);
}
{
std::unique_ptr<OffscreenDocumentHost> offscreen_document =
CreateOffscreenDocument(*extension, offscreen_url);
// Repeat the test, but don't actually close the document in response to
// the call (which simulates an asynchronous close). This allows the
// window to call close() multiple times. Even though it does so, we should
// only receive the signal from the OffscreenDocumentHost once.
size_t close_count = 0;
auto close_handler = [&close_count,
&offscreen_document](ExtensionHost* host) {
ASSERT_EQ(offscreen_document.get(), host);
++close_count;
};
offscreen_document->SetCloseHandler(
base::BindLambdaForTesting(close_handler));
content::WebContents* contents = offscreen_document->host_contents();
// WebContentsDelegate::CloseContents() isn't guaranteed to be called by the
// time an ExecuteScript() call returns. Since we're waiting on a callback
// to *not* be called, we can't use a RunLoop + quit closure. Instead,
// execute script in the renderer multiple times to ensure all the pipes
// are appropriately flushed.
for (int i = 0; i < 20; ++i)
ASSERT_TRUE(content::ExecJs(contents, "window.close();"));
// Even though `window.close()` was called 20 times, the close handler
// should only be invoked once.
EXPECT_EQ(1u, close_count);
}
}
} // namespace extensions