blob: d941568af2c26eba71cdff264ec40bfa1f12f3cb [file] [log] [blame]
// Copyright 2021 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#include "base/memory/weak_ptr.h"
#include "base/run_loop.h"
#include "base/strings/strcat.h"
#include "base/strings/stringprintf.h"
#include "base/strings/utf_string_conversions.h"
#include "base/test/bind.h"
#include "base/test/scoped_feature_list.h"
#include "base/threading/thread_restrictions.h"
#include "components/network_session_configurator/common/network_switches.h"
#include "content/browser/loader/navigation_early_hints_manager.h"
#include "content/browser/renderer_host/render_frame_host_impl.h"
#include "content/browser/web_contents/web_contents_impl.h"
#include "content/public/common/content_features.h"
#include "content/public/test/browser_test.h"
#include "content/public/test/content_browser_test.h"
#include "content/public/test/content_browser_test_utils.h"
#include "content/public/test/content_mock_cert_verifier.h"
#include "content/public/test/fenced_frame_test_util.h"
#include "content/public/test/prerender_test_util.h"
#include "content/public/test/test_navigation_observer.h"
#include "content/public/test/url_loader_interceptor.h"
#include "content/shell/browser/shell.h"
#include "content/test/content_browser_test_utils_internal.h"
#include "net/base/features.h"
#include "net/dns/mock_host_resolver.h"
#include "net/http/http_status_code.h"
#include "net/test/cert_test_util.h"
#include "net/test/embedded_test_server/embedded_test_server.h"
#include "net/test/embedded_test_server/embedded_test_server_connection_listener.h"
#include "net/test/embedded_test_server/http_request.h"
#include "net/test/embedded_test_server/http_response.h"
#include "net/test/quic_simple_test_server.h"
#include "net/test/test_data_directory.h"
#include "services/network/public/cpp/network_switches.h"
#include "services/network/public/mojom/early_hints.mojom.h"
#include "services/network/public/mojom/link_header.mojom.h"
namespace content {
using PreloadedResources = NavigationEarlyHintsManager::PreloadedResources;
namespace {
struct HeaderField {
HeaderField(const std::string& name, const std::string& value)
: name(name), value(value) {}
std::string name;
std::string value;
};
struct ResponseEntry {
ResponseEntry(const std::string& path, net::HttpStatusCode status_code)
: path(path) {
headers[":path"] = path;
headers[":status"] = base::StringPrintf("%d", status_code);
}
void AddEarlyHints(const std::vector<HeaderField>& header_fields) {
spdy::Http2HeaderBlock hints_headers;
for (const auto& header : header_fields)
hints_headers.AppendValueOrAddHeader(header.name, header.value);
early_hints.push_back(std::move(hints_headers));
}
std::string path;
spdy::Http2HeaderBlock headers;
std::string body;
std::vector<spdy::Http2HeaderBlock> early_hints;
};
const char kPageWithHintedScriptPath[] = "/page_with_hinted_js.html";
const char kPageWithHintedScriptBody[] = "<script src=\"/hinted.js\"></script>";
const char kPageWithHintedCorsScriptPath[] = "/page_with_hinted_cors_js.html";
const char kPageWithHintedCorsScriptBody[] =
"<script src=\"/hinted.js\" crossorigin></script>";
const char kPageWithIframePath[] = "/page_with_iframe.html";
const char kPageWithIframeBody[] =
"<iframe src=\"page_with_hinted_js.html\"></iframe>";
const char kPageWithHintedModuleScriptPath[] =
"/page_with_hinted_module_js.html";
const char kPageWithHintedModuleScriptBody[] =
"<script src=\"/hinted.js\" type=\"module\"></script>";
const char kHintedScriptPath[] = "/hinted.js";
const char kHintedScriptBody[] = "document.title = 'Done';";
const char kHintedStylesheetPath[] = "/hinted.css";
const char kHintedStylesheetBody[] = "/*empty*/";
const char kEmptyPagePath[] = "/empty.html";
const char kEmptyPageBody[] = "<html></html>";
const char kRedirectedPagePath[] = "/redirected.html";
const char kRedirectedPageBody[] = "<script src=\"/hinted.js\"></script>";
// Listens to sockets on an EmbeddedTestServer for preconnect tests. Created
// on the UI thread. EmbeddedTestServerConnectionListener methods are called
// from a different thread than the UI thread.
class PreconnectListener
: public net::test_server::EmbeddedTestServerConnectionListener {
public:
PreconnectListener()
: task_runner_(base::ThreadTaskRunnerHandle::Get()),
weak_ptr_factory_(this) {
DCHECK_CURRENTLY_ON(content::BrowserThread::UI);
}
~PreconnectListener() override = default;
// net::test_server::EmbeddedTestServerConnectionListener implementation:
std::unique_ptr<net::StreamSocket> AcceptedSocket(
std::unique_ptr<net::StreamSocket> connection) override {
task_runner_->PostTask(
FROM_HERE, base::BindOnce(&PreconnectListener::AcceptedSocketOnUIThread,
weak_ptr_factory_.GetWeakPtr()));
return connection;
}
void ReadFromSocket(const net::StreamSocket& connection, int rv) override {}
size_t num_accepted_sockets() {
DCHECK_CURRENTLY_ON(content::BrowserThread::UI);
return num_accepted_sockets_;
}
private:
void AcceptedSocketOnUIThread() {
DCHECK_CURRENTLY_ON(content::BrowserThread::UI);
++num_accepted_sockets_;
}
scoped_refptr<base::SequencedTaskRunner> task_runner_;
size_t num_accepted_sockets_ = 0;
base::WeakPtrFactory<PreconnectListener> weak_ptr_factory_;
};
} // namespace
// Most tests use EmbeddedTestServer but this uses QuicSimpleTestServer because
// Early Hints are only plumbed over HTTP/2 or HTTP/3 (QUIC).
class NavigationEarlyHintsTest : public ContentBrowserTest {
public:
NavigationEarlyHintsTest() {
feature_list_.InitWithFeatures(
{features::kEarlyHintsPreloadForNavigation,
net::features::kSplitCacheByNetworkIsolationKey},
{net::features::kForceIsolationInfoFrameOriginToTopLevelFrame});
}
~NavigationEarlyHintsTest() override = default;
void SetUpOnMainThread() override {
ContentBrowserTest::SetUpOnMainThread();
ConfigureMockCertVerifier();
host_resolver()->AddRule("*", "127.0.0.1");
cross_origin_server_.RegisterRequestHandler(
base::BindRepeating(&NavigationEarlyHintsTest::HandleCrossOriginRequest,
base::Unretained(this)));
preconnect_listener_ = std::make_unique<PreconnectListener>();
cross_origin_server().SetConnectionListener(preconnect_listener_.get());
ASSERT_TRUE(cross_origin_server_.Start());
}
void SetUpCommandLine(base::CommandLine* command_line) override {
command_line->AppendSwitchASCII(switches::kOriginToForceQuicOn, "*");
mock_cert_verifier_.SetUpCommandLine(command_line);
ASSERT_TRUE(net::QuicSimpleTestServer::Start());
ContentBrowserTest::SetUpCommandLine(command_line);
}
void TearDown() override {
base::ScopedAllowBaseSyncPrimitivesForTesting allow_wait;
net::QuicSimpleTestServer::Shutdown();
ContentBrowserTest::TearDown();
}
net::test_server::EmbeddedTestServer& cross_origin_server() {
return cross_origin_server_;
}
protected:
base::test::ScopedFeatureList& feature_list() { return feature_list_; }
PreconnectListener& preconnect_listener() { return *preconnect_listener_; }
void SetUpInProcessBrowserTestFixture() override {
mock_cert_verifier_.SetUpInProcessBrowserTestFixture();
}
void TearDownInProcessBrowserTestFixture() override {
mock_cert_verifier_.TearDownInProcessBrowserTestFixture();
}
void ConfigureMockCertVerifier() {
auto test_cert =
net::ImportCertFromFile(net::GetTestCertsDirectory(), "quic-chain.pem");
net::CertVerifyResult verify_result;
verify_result.verified_cert = test_cert;
mock_cert_verifier_.mock_cert_verifier()->AddResultForCert(
test_cert, verify_result, net::OK);
mock_cert_verifier_.mock_cert_verifier()->set_default_result(net::OK);
}
HeaderField CreatePreloadLinkForScript() {
return HeaderField(
"link",
base::StringPrintf("<%s>; rel=preload; as=script", kHintedScriptPath));
}
HeaderField CreatePreloadLinkForCorsScript() {
return HeaderField(
"link", base::StringPrintf("<%s>; rel=preload; as=script; crossorigin",
kHintedScriptPath));
}
HeaderField CreateModulePreloadLink() {
return HeaderField("link", base::StringPrintf("<%s>; rel=modulepreload",
kHintedScriptPath));
}
HeaderField CreatePreloadLinkForStylesheet() {
return HeaderField("link", base::StringPrintf("<%s>; rel=preload; as=style",
kHintedStylesheetPath));
}
void RegisterResponse(const ResponseEntry& entry) {
net::QuicSimpleTestServer::AddResponseWithEarlyHints(
entry.path, entry.headers, entry.body, entry.early_hints);
}
void RegisterHintedScriptResource() {
ResponseEntry hinted_script_entry(kHintedScriptPath, net::HTTP_OK);
hinted_script_entry.headers["content-type"] = "application/javascript";
hinted_script_entry.headers["cache-control"] = "max-age=3600";
hinted_script_entry.body = kHintedScriptBody;
RegisterResponse(hinted_script_entry);
}
void RegisterHintedStylesheetResource() {
ResponseEntry hinted_script_entry(kHintedStylesheetPath, net::HTTP_OK);
hinted_script_entry.headers["content-type"] = "text/css";
hinted_script_entry.body = kHintedStylesheetBody;
RegisterResponse(hinted_script_entry);
}
void RegisterRedirectedPage() {
ResponseEntry entry(kRedirectedPagePath, net::HTTP_OK);
entry.body = kRedirectedPageBody;
RegisterResponse(entry);
}
ResponseEntry CreatePageEntryWithHintedScript(
net::HttpStatusCode status_code) {
RegisterHintedScriptResource();
ResponseEntry entry(kPageWithHintedScriptPath, status_code);
entry.body = kPageWithHintedScriptBody;
HeaderField link_header = CreatePreloadLinkForScript();
entry.AddEarlyHints({std::move(link_header)});
return entry;
}
ResponseEntry CreateEmptyPageEntryWithHintedScript() {
RegisterHintedScriptResource();
ResponseEntry entry(kEmptyPagePath, net::HTTP_OK);
entry.body = kEmptyPageBody;
HeaderField link_header = CreatePreloadLinkForScript();
entry.AddEarlyHints({std::move(link_header)});
return entry;
}
ResponseEntry CreatePageEntryWithHintedCorsScript(
net::HttpStatusCode status_code) {
RegisterHintedScriptResource();
ResponseEntry entry(kPageWithHintedCorsScriptPath, status_code);
entry.body = kPageWithHintedCorsScriptBody;
HeaderField link_header = CreatePreloadLinkForCorsScript();
entry.AddEarlyHints({std::move(link_header)});
return entry;
}
ResponseEntry CreatePageEntryWithHintedModuleScript(
net::HttpStatusCode status_code) {
RegisterHintedScriptResource();
ResponseEntry entry(kPageWithHintedModuleScriptPath, status_code);
entry.body = kPageWithHintedModuleScriptBody;
HeaderField link_header = CreateModulePreloadLink();
entry.AddEarlyHints({std::move(link_header)});
return entry;
}
bool NavigateToURLAndWaitTitle(const GURL& url, const std::string& title) {
return NavigateToURLAndWaitTitleWithCommitURL(url, url, title);
}
bool NavigateToURLAndWaitTitleWithCommitURL(const GURL& url,
const GURL& expected_commit_url,
const std::string& title) {
std::u16string title16 = base::ASCIIToUTF16(title);
TitleWatcher title_watcher(shell()->web_contents(), title16);
if (!NavigateToURL(shell(), url, expected_commit_url))
return false;
return title16 == title_watcher.WaitAndGetTitle();
}
NavigationEarlyHintsManager* GetEarlyHintsManager(RenderFrameHostImpl* rfh) {
return rfh->early_hints_manager();
}
PreloadedResources WaitForPreloadedResources() {
return WaitForPreloadedResources(static_cast<RenderFrameHostImpl*>(
shell()->web_contents()->GetPrimaryMainFrame()));
}
PreloadedResources WaitForPreloadedResources(RenderFrameHostImpl* rfh) {
base::RunLoop loop;
PreloadedResources result;
if (!GetEarlyHintsManager(rfh))
return result;
GetEarlyHintsManager(rfh)->WaitForPreloadsFinishedForTesting(
base::BindLambdaForTesting([&](PreloadedResources preloaded_resources) {
result = preloaded_resources;
loop.Quit();
}));
loop.Run();
return result;
}
enum class FetchResult {
kFetched,
kBlocked,
};
FetchResult FetchScriptOnDocument(ToRenderFrameHost target, GURL src) {
EvalJsResult result = EvalJs(target, JsReplace(R"(
new Promise(resolve => {
const script = document.createElement("script");
script.src = $1;
script.onerror = () => resolve("blocked");
script.onload = () => resolve("fetched");
document.body.appendChild(script);
});
)",
src));
return result.ExtractString() == "fetched" ? FetchResult::kFetched
: FetchResult::kBlocked;
}
private:
std::unique_ptr<net::test_server::HttpResponse> HandleCrossOriginRequest(
const net::test_server::HttpRequest& request) {
GURL relative_url = request.base_url.Resolve(request.relative_url);
if (relative_url.path() == kEmptyPagePath) {
auto response = std::make_unique<net::test_server::BasicHttpResponse>();
response->set_code(net::HTTP_OK);
response->set_content_type("text/html");
response->set_content("");
return std::move(response);
}
if (relative_url.path() == kRedirectedPagePath) {
auto response = std::make_unique<net::test_server::BasicHttpResponse>();
response->set_code(net::HTTP_OK);
response->set_content_type("text/html");
response->set_content(kRedirectedPageBody);
return std::move(response);
}
if (relative_url.path() != kHintedScriptPath)
return nullptr;
auto response = std::make_unique<net::test_server::BasicHttpResponse>();
response->set_code(net::HTTP_OK);
response->set_content_type("application/javascript");
response->set_content(kHintedScriptBody);
std::string query = relative_url.query();
if (query == "corp-cross-origin") {
response->AddCustomHeader("Cross-Origin-Resource-Policy", "cross-origin");
} else if (query == "corp-same-origin") {
response->AddCustomHeader("Cross-Origin-Resource-Policy", "same-origin");
}
return std::move(response);
}
base::test::ScopedFeatureList feature_list_;
ContentMockCertVerifier mock_cert_verifier_;
// For tests that fetch resources from a cross origin server.
net::EmbeddedTestServer cross_origin_server_;
std::unique_ptr<PreconnectListener> preconnect_listener_;
};
IN_PROC_BROWSER_TEST_F(NavigationEarlyHintsTest, Basic) {
ResponseEntry entry = CreatePageEntryWithHintedScript(net::HTTP_OK);
RegisterResponse(entry);
EXPECT_TRUE(NavigateToURLAndWaitTitle(
net::QuicSimpleTestServer::GetFileURL(kPageWithHintedScriptPath),
"Done"));
PreloadedResources preloads = WaitForPreloadedResources();
EXPECT_EQ(preloads.size(), 1UL);
GURL preloaded_url = net::QuicSimpleTestServer::GetFileURL(kHintedScriptPath);
auto it = preloads.find(preloaded_url);
ASSERT_TRUE(it != preloads.end());
ASSERT_FALSE(it->second.was_canceled);
ASSERT_TRUE(it->second.error_code.has_value());
EXPECT_EQ(it->second.error_code.value(), net::OK);
}
IN_PROC_BROWSER_TEST_F(NavigationEarlyHintsTest, CorsAttribute) {
ResponseEntry entry = CreatePageEntryWithHintedCorsScript(net::HTTP_OK);
RegisterResponse(entry);
EXPECT_TRUE(NavigateToURLAndWaitTitle(
net::QuicSimpleTestServer::GetFileURL(kPageWithHintedCorsScriptPath),
"Done"));
PreloadedResources preloads = WaitForPreloadedResources();
EXPECT_EQ(preloads.size(), 1UL);
GURL preloaded_url = net::QuicSimpleTestServer::GetFileURL(kHintedScriptPath);
auto it = preloads.find(preloaded_url);
ASSERT_TRUE(it != preloads.end());
ASSERT_FALSE(it->second.was_canceled);
ASSERT_TRUE(it->second.error_code.has_value());
EXPECT_EQ(it->second.error_code.value(), net::OK);
}
IN_PROC_BROWSER_TEST_F(NavigationEarlyHintsTest, ModulePreload) {
ResponseEntry entry = CreatePageEntryWithHintedModuleScript(net::HTTP_OK);
RegisterResponse(entry);
EXPECT_TRUE(NavigateToURLAndWaitTitle(
net::QuicSimpleTestServer::GetFileURL(kPageWithHintedModuleScriptPath),
"Done"));
PreloadedResources preloads = WaitForPreloadedResources();
EXPECT_EQ(preloads.size(), 1UL);
GURL preloaded_url = net::QuicSimpleTestServer::GetFileURL(kHintedScriptPath);
auto it = preloads.find(preloaded_url);
ASSERT_TRUE(it != preloads.end());
ASSERT_FALSE(it->second.was_canceled);
ASSERT_TRUE(it->second.error_code.has_value());
EXPECT_EQ(it->second.error_code.value(), net::OK);
}
IN_PROC_BROWSER_TEST_F(NavigationEarlyHintsTest, DisallowPreloadFromIframe) {
ResponseEntry page_entry(kPageWithIframePath, net::HTTP_OK);
page_entry.body = kPageWithIframeBody;
RegisterResponse(page_entry);
ResponseEntry iframe_entry = CreatePageEntryWithHintedScript(net::HTTP_OK);
RegisterResponse(iframe_entry);
EXPECT_TRUE(NavigateToURL(
shell(), net::QuicSimpleTestServer::GetFileURL(kPageWithIframePath)));
// Find RenderFrameHost for the iframe.
std::vector<RenderFrameHost*> all_frames =
CollectAllRenderFrameHosts(shell()->web_contents());
ASSERT_EQ(all_frames.size(), 2UL);
ASSERT_EQ(all_frames[0], all_frames[1]->GetParent());
RenderFrameHostImpl* iframe_host =
static_cast<RenderFrameHostImpl*>(all_frames[1]);
EXPECT_TRUE(WaitForLoadStop(WebContents::FromRenderFrameHost(iframe_host)));
ASSERT_EQ(iframe_host->GetLastCommittedURL(),
net::QuicSimpleTestServer::GetFileURL(kPageWithHintedScriptPath));
// NavigationEarlyHintsManager should not be created for subframes. If it were
// created it should have been created before navigation commit.
EXPECT_EQ(iframe_host->early_hints_manager(), nullptr);
}
IN_PROC_BROWSER_TEST_F(NavigationEarlyHintsTest, NavigationServerError) {
ResponseEntry entry =
CreatePageEntryWithHintedScript(net::HTTP_INTERNAL_SERVER_ERROR);
entry.body = "Internal Server Error";
RegisterResponse(entry);
EXPECT_TRUE(NavigateToURL(shell(), net::QuicSimpleTestServer::GetFileURL(
kPageWithHintedScriptPath)));
PreloadedResources preloads = WaitForPreloadedResources();
EXPECT_EQ(preloads.size(), 1UL);
GURL preloaded_url = net::QuicSimpleTestServer::GetFileURL(kHintedScriptPath);
auto it = preloads.find(preloaded_url);
ASSERT_NE(it, preloads.end());
ASSERT_FALSE(it->second.was_canceled);
ASSERT_TRUE(it->second.error_code.has_value());
EXPECT_EQ(it->second.error_code.value(), net::OK);
}
IN_PROC_BROWSER_TEST_F(NavigationEarlyHintsTest, RedirectSameOrigin) {
RegisterRedirectedPage();
ResponseEntry entry = CreatePageEntryWithHintedScript(net::HTTP_FOUND);
entry.headers["location"] = kRedirectedPagePath;
entry.body = "";
RegisterResponse(entry);
EXPECT_TRUE(NavigateToURLAndWaitTitleWithCommitURL(
net::QuicSimpleTestServer::GetFileURL(kPageWithHintedScriptPath),
net::QuicSimpleTestServer::GetFileURL(kRedirectedPagePath), "Done"));
PreloadedResources preloads = WaitForPreloadedResources();
EXPECT_EQ(preloads.size(), 1UL);
GURL preloaded_url = net::QuicSimpleTestServer::GetFileURL(kHintedScriptPath);
auto it = preloads.find(preloaded_url);
ASSERT_TRUE(it != preloads.end());
ASSERT_FALSE(it->second.was_canceled);
ASSERT_TRUE(it->second.error_code.has_value());
EXPECT_EQ(it->second.error_code.value(), net::OK);
}
IN_PROC_BROWSER_TEST_F(NavigationEarlyHintsTest, RedirectCrossOrigin) {
const GURL kRedirectedUrl = cross_origin_server().GetURL(kRedirectedPagePath);
ResponseEntry entry = CreatePageEntryWithHintedScript(net::HTTP_FOUND);
entry.headers["location"] = kRedirectedUrl.spec();
entry.body = "";
RegisterResponse(entry);
EXPECT_TRUE(NavigateToURLAndWaitTitleWithCommitURL(
net::QuicSimpleTestServer::GetFileURL(kPageWithHintedScriptPath),
kRedirectedUrl, "Done"));
PreloadedResources preloads = WaitForPreloadedResources();
EXPECT_TRUE(preloads.empty());
}
IN_PROC_BROWSER_TEST_F(NavigationEarlyHintsTest, InvalidPreloadLink) {
const std::string kPath = "/hinted.html";
RegisterHintedScriptResource();
ResponseEntry entry(kPath, net::HTTP_OK);
entry.body = "body";
entry.AddEarlyHints(
{HeaderField("link", base::StringPrintf("<%s>; rel=preload; as=invalid",
kHintedScriptPath))});
RegisterResponse(entry);
EXPECT_TRUE(
NavigateToURL(shell(), net::QuicSimpleTestServer::GetFileURL(kPath)));
PreloadedResources preloads = WaitForPreloadedResources();
EXPECT_TRUE(preloads.empty());
}
IN_PROC_BROWSER_TEST_F(NavigationEarlyHintsTest, MultipleEarlyHints) {
RegisterHintedScriptResource();
RegisterHintedStylesheetResource();
ResponseEntry entry(kPageWithHintedScriptPath, net::HTTP_OK);
entry.body = kPageWithHintedScriptBody;
// Set two Early Hints responses which contain duplicate preload link headers.
// The second response should be ignored.
HeaderField script_link_header = CreatePreloadLinkForScript();
HeaderField stylesheet_link_header = CreatePreloadLinkForStylesheet();
entry.AddEarlyHints({script_link_header});
entry.AddEarlyHints({script_link_header, stylesheet_link_header});
RegisterResponse(entry);
EXPECT_TRUE(NavigateToURLAndWaitTitle(
net::QuicSimpleTestServer::GetFileURL(kPageWithHintedScriptPath),
"Done"));
PreloadedResources preloads = WaitForPreloadedResources();
EXPECT_EQ(preloads.size(), 1UL);
GURL script_url = net::QuicSimpleTestServer::GetFileURL(kHintedScriptPath);
GURL stylesheet_url =
net::QuicSimpleTestServer::GetFileURL(kHintedStylesheetPath);
EXPECT_TRUE(preloads.contains(script_url));
EXPECT_FALSE(preloads.contains(stylesheet_url));
}
const char kPageWithCrossOriginScriptPage[] =
"/page_with_cross_origin_script.html";
IN_PROC_BROWSER_TEST_F(NavigationEarlyHintsTest, CORP_Pass) {
// The server response's is a script with
// `Cross-Origin-Resource-Policy: cross-origin`.
const GURL kCrossOriginScriptUrl =
cross_origin_server().GetURL("/hinted.js?corp-cross-origin");
ResponseEntry page_entry(kPageWithCrossOriginScriptPage, net::HTTP_OK);
HeaderField link_header = HeaderField(
"link", base::StringPrintf("<%s>; rel=preload; as=script",
kCrossOriginScriptUrl.spec().c_str()));
page_entry.AddEarlyHints({std::move(link_header)});
RegisterResponse(page_entry);
EXPECT_TRUE(NavigateToURL(shell(), net::QuicSimpleTestServer::GetFileURL(
kPageWithCrossOriginScriptPage)));
EXPECT_TRUE(WaitForLoadStop(shell()->web_contents()));
EXPECT_EQ(FetchScriptOnDocument(shell(), kCrossOriginScriptUrl),
FetchResult::kFetched);
PreloadedResources preloads = WaitForPreloadedResources();
EXPECT_EQ(preloads.size(), 1UL);
auto it = preloads.find(kCrossOriginScriptUrl);
ASSERT_TRUE(it != preloads.end());
ASSERT_FALSE(it->second.was_canceled);
ASSERT_TRUE(it->second.error_code.has_value());
EXPECT_EQ(it->second.error_code.value(), net::OK);
}
IN_PROC_BROWSER_TEST_F(NavigationEarlyHintsTest, CORP_Blocked) {
// The server response's is a script with
// `Cross-Origin-Resource-Policy: same-origin`.
const GURL kCrossOriginScriptUrl =
cross_origin_server().GetURL("/hinted.js?corp-same-origin");
ResponseEntry page_entry(kPageWithCrossOriginScriptPage, net::HTTP_OK);
HeaderField link_header = HeaderField(
"link", base::StringPrintf("<%s>; rel=preload; as=script",
kCrossOriginScriptUrl.spec().c_str()));
page_entry.AddEarlyHints({std::move(link_header)});
RegisterResponse(page_entry);
EXPECT_TRUE(NavigateToURL(shell(), net::QuicSimpleTestServer::GetFileURL(
kPageWithCrossOriginScriptPage)));
EXPECT_TRUE(WaitForLoadStop(shell()->web_contents()));
// The script fetch should be blocked.
EXPECT_EQ(FetchScriptOnDocument(shell(), kCrossOriginScriptUrl),
FetchResult::kBlocked);
}
IN_PROC_BROWSER_TEST_F(NavigationEarlyHintsTest, COEP_Pass) {
// The server sends `Cross-Origin-Resource-Policy: cross-origin`.
const GURL kCrossOriginScriptUrl =
cross_origin_server().GetURL("/hinted.js?corp-cross-origin");
ResponseEntry page_entry(kPageWithCrossOriginScriptPage, net::HTTP_OK);
page_entry.headers["cross-origin-embedder-policy"] = "require-corp";
HeaderField link_header = HeaderField(
"link", base::StringPrintf("<%s>; rel=preload; as=script",
kCrossOriginScriptUrl.spec().c_str()));
HeaderField coep =
HeaderField("cross-origin-embedder-policy", "require-corp");
page_entry.AddEarlyHints({std::move(link_header), std::move(coep)});
RegisterResponse(page_entry);
EXPECT_TRUE(NavigateToURL(shell(), net::QuicSimpleTestServer::GetFileURL(
kPageWithCrossOriginScriptPage)));
EXPECT_TRUE(WaitForLoadStop(shell()->web_contents()));
EXPECT_EQ(FetchScriptOnDocument(shell(), kCrossOriginScriptUrl),
FetchResult::kFetched);
}
IN_PROC_BROWSER_TEST_F(NavigationEarlyHintsTest, COEP_Block) {
// The server does not send `Cross-Origin-Resource-Policy` header.
const GURL kCrossOriginScriptUrl = cross_origin_server().GetURL("/hinted.js");
ResponseEntry page_entry(kPageWithCrossOriginScriptPage, net::HTTP_OK);
page_entry.headers["cross-origin-embedder-policy"] = "require-corp";
HeaderField link_header = HeaderField(
"link", base::StringPrintf("<%s>; rel=preload; as=script",
kCrossOriginScriptUrl.spec().c_str()));
HeaderField coep =
HeaderField("cross-origin-embedder-policy", "require-corp");
page_entry.AddEarlyHints({std::move(link_header), std::move(coep)});
RegisterResponse(page_entry);
EXPECT_TRUE(NavigateToURL(shell(), net::QuicSimpleTestServer::GetFileURL(
kPageWithCrossOriginScriptPage)));
EXPECT_TRUE(WaitForLoadStop(shell()->web_contents()));
EXPECT_EQ(FetchScriptOnDocument(shell(), kCrossOriginScriptUrl),
FetchResult::kBlocked);
}
// Test that network isolation key is set correctly for Early Hints preload.
IN_PROC_BROWSER_TEST_F(NavigationEarlyHintsTest, NetworkIsolationKey) {
const GURL kHintedScriptUrl =
net::QuicSimpleTestServer::GetFileURL(kHintedScriptPath);
ResponseEntry entry = CreateEmptyPageEntryWithHintedScript();
RegisterResponse(entry);
absl::optional<bool> is_cached;
URLLoaderInterceptor interceptor(
base::BindLambdaForTesting(
[&](URLLoaderInterceptor::RequestParams* params) { return false; }),
base::BindLambdaForTesting(
[&](const GURL& request_url,
const network::URLLoaderCompletionStatus& status) {
if (request_url != kHintedScriptUrl)
return;
is_cached = status.exists_in_cache;
}),
base::NullCallback());
ASSERT_TRUE(NavigateToURL(
shell(), net::QuicSimpleTestServer::GetFileURL(kEmptyPagePath)));
// Make sure the hinted resource is preloaded.
PreloadedResources preloads = WaitForPreloadedResources();
auto it = preloads.find(kHintedScriptUrl);
ASSERT_TRUE(it != preloads.end());
ASSERT_FALSE(it->second.was_canceled);
ASSERT_EQ(it->second.error_code.value(), net::OK);
ASSERT_FALSE(is_cached.value());
is_cached = absl::nullopt;
// Fetch the hinted resource from the main frame. It should come from the
// cache.
FetchScriptOnDocument(shell(), kHintedScriptUrl);
ASSERT_TRUE(is_cached.value());
// Reset `is_cached` to make sure it is set true or false.
is_cached = absl::nullopt;
// Create an iframe with a different origin and fetch the hinted resource from
// the iframe. It should not come from the cache.
auto* web_contents = static_cast<WebContentsImpl*>(shell()->web_contents());
RenderFrameHost* iframe =
CreateSubframe(web_contents, /*frame_id=*/"",
cross_origin_server().GetURL("/empty.html"),
/*wait_for_navigation=*/true);
FetchScriptOnDocument(iframe, kHintedScriptUrl);
ASSERT_FALSE(is_cached.value());
}
class NavigationEarlyHintsPreconnectTest
: public NavigationEarlyHintsTest,
public testing::WithParamInterface<bool> {
public:
NavigationEarlyHintsPreconnectTest() {
if (!IsPreconnectEnabled()) {
scoped_feature_list_.InitAndEnableFeatureWithParameters(
features::kEarlyHintsPreloadForNavigation,
{{"enable_preconnect", "false"}});
}
}
~NavigationEarlyHintsPreconnectTest() override = default;
protected:
bool IsPreconnectEnabled() { return GetParam(); }
base::test::ScopedFeatureList scoped_feature_list_;
};
INSTANTIATE_TEST_SUITE_P(/* no prefix */,
NavigationEarlyHintsPreconnectTest,
testing::Bool());
IN_PROC_BROWSER_TEST_P(NavigationEarlyHintsPreconnectTest, SimplePreconnect) {
const char kPageWithPreconnect[] = "/page_with_preconnect.html";
const GURL kPreconnectUrl = cross_origin_server().GetURL("/");
ResponseEntry page_entry(kPageWithPreconnect, net::HTTP_OK);
HeaderField link_header =
HeaderField("link", base::StringPrintf("<%s>; rel=preconnect",
kPreconnectUrl.spec().c_str()));
page_entry.AddEarlyHints({std::move(link_header)});
RegisterResponse(page_entry);
ASSERT_TRUE(NavigateToURL(
shell(), net::QuicSimpleTestServer::GetFileURL(kPageWithPreconnect)));
ASSERT_TRUE(WaitForLoadStop(shell()->web_contents()));
if (IsPreconnectEnabled()) {
EXPECT_EQ(preconnect_listener().num_accepted_sockets(), 1UL);
} else {
EXPECT_EQ(preconnect_listener().num_accepted_sockets(), 0UL);
}
EXPECT_TRUE(
GetEarlyHintsManager(static_cast<RenderFrameHostImpl*>(
shell()->web_contents()->GetPrimaryMainFrame()))
->WasResourceHintsReceived());
}
IN_PROC_BROWSER_TEST_F(NavigationEarlyHintsTest, InvalidHeader_NewLine) {
const std::string kPath = "/header-contains-newline.html";
ResponseEntry entry(kPath, net::HTTP_OK);
entry.AddEarlyHints({HeaderField("invalid-header", "foo\r\nbar")});
RegisterResponse(entry);
EXPECT_FALSE(
NavigateToURL(shell(), net::QuicSimpleTestServer::GetFileURL(kPath)));
}
class NavigationEarlyHintsAddressSpaceTest : public NavigationEarlyHintsTest {
public:
NavigationEarlyHintsAddressSpaceTest() = default;
~NavigationEarlyHintsAddressSpaceTest() override = default;
void SetUpCommandLine(base::CommandLine* command_line) override {
NavigationEarlyHintsTest::SetUpCommandLine(command_line);
private_server_.AddDefaultHandlers(GetTestDataFilePath());
ASSERT_TRUE(private_server_.Start());
// Treat the main test server as public for IPAddressSpace tests.
command_line->AppendSwitchASCII(
network::switches::kIpAddressSpaceOverrides,
base::StringPrintf("127.0.0.1:%d=public",
net::QuicSimpleTestServer::GetPort()));
}
net::test_server::EmbeddedTestServer& private_server() {
return private_server_;
}
private:
// For tests that trigger private network requests.
net::EmbeddedTestServer private_server_;
};
// Tests that Early Hints preload is blocked when hints comes from the public
// network but a hinted resource is located in a private network.
IN_PROC_BROWSER_TEST_F(NavigationEarlyHintsAddressSpaceTest,
PublicToPrivateRequestBlocked) {
const GURL kPrivateResourceUrl = private_server().GetURL("/blank.jpg");
ResponseEntry page_entry(kEmptyPagePath, net::HTTP_OK);
HeaderField link_header = HeaderField(
"link", base::StringPrintf("<%s>; rel=preload; as=image",
kPrivateResourceUrl.spec().c_str()));
page_entry.AddEarlyHints({std::move(link_header)});
RegisterResponse(page_entry);
EXPECT_TRUE(NavigateToURL(
shell(), net::QuicSimpleTestServer::GetFileURL(kEmptyPagePath)));
EXPECT_TRUE(WaitForLoadStop(shell()->web_contents()));
PreloadedResources preloads = WaitForPreloadedResources();
EXPECT_EQ(preloads.size(), 1UL);
auto it = preloads.find(kPrivateResourceUrl);
ASSERT_TRUE(it != preloads.end());
ASSERT_FALSE(it->second.was_canceled);
ASSERT_TRUE(it->second.error_code.has_value());
EXPECT_EQ(it->second.error_code.value(), net::ERR_FAILED);
EXPECT_EQ(it->second.cors_error_status->cors_error,
network::mojom::CorsError::kInsecurePrivateNetwork);
}
enum class FieldTrialTestingMode {
kDisabled,
kForceDisabled,
kEnabled,
};
std::string FieldTrialTestingModeToString(
const testing::TestParamInfo<FieldTrialTestingMode>& info) {
switch (info.param) {
case FieldTrialTestingMode::kDisabled:
return "FieldTrialDisabled";
case FieldTrialTestingMode::kForceDisabled:
return "FieldTrialForceDisabled";
case FieldTrialTestingMode::kEnabled:
return "FieldTrialEnabled";
}
}
class NavigationEarlyHintsOriginTrialTest
: public NavigationEarlyHintsTest,
public testing::WithParamInterface<FieldTrialTestingMode> {
public:
NavigationEarlyHintsOriginTrialTest() = default;
~NavigationEarlyHintsOriginTrialTest() override = default;
void SetUpCommandLine(base::CommandLine* command_line) override {
NavigationEarlyHintsTest::SetUpCommandLine(command_line);
feature_list().Reset();
switch (GetFieldTrialTestingMode()) {
case FieldTrialTestingMode::kDisabled:
feature_list().InitWithFeatures(
{net::features::kSplitCacheByNetworkIsolationKey},
{features::kEarlyHintsPreloadForNavigation});
break;
case FieldTrialTestingMode::kForceDisabled:
feature_list().InitWithFeaturesAndParameters(
/*enabled_features=*/{{features::kEarlyHintsPreloadForNavigation,
{{"force_disable", "true"}}},
{net::features::
kSplitCacheByNetworkIsolationKey,
{}}},
/*disabled_features=*/{});
break;
case FieldTrialTestingMode::kEnabled:
feature_list().InitWithFeatures(
{net::features::kSplitCacheByNetworkIsolationKey,
features::kEarlyHintsPreloadForNavigation},
{});
break;
}
}
void SetUpOnMainThread() override {
NavigationEarlyHintsTest::SetUpOnMainThread();
url_loader_interceptor_ =
std::make_unique<URLLoaderInterceptor>(base::BindRepeating(
&NavigationEarlyHintsOriginTrialTest::InterceptRequest,
base::Unretained(this)));
}
void TearDownOnMainThread() override {
url_loader_interceptor_.reset();
NavigationEarlyHintsTest::TearDownOnMainThread();
}
protected:
FieldTrialTestingMode GetFieldTrialTestingMode() { return GetParam(); }
bool NavigateToTestPageAndCheckPreloadTriggered() {
EXPECT_TRUE(NavigateToURLAndWaitTitle(GetUrl("/"), "Done"));
PreloadedResources preloaded_resources = WaitForPreloadedResources();
return preloaded_resources.size() > 0;
}
private:
GURL GetUrl(const std::string& path) {
return GURL("https://example.test/").Resolve(path);
}
// URLLoaderInterceptor callback
bool InterceptRequest(URLLoaderInterceptor::RequestParams* params) {
if (params->url_request.url.path() == "/") {
// Send an Early Hints response.
auto link_header = network::mojom::LinkHeader::New(
GetUrl(kHintedScriptPath), network::mojom::LinkRelAttribute::kPreload,
network::mojom::LinkAsAttribute::kScript,
network::mojom::CrossOriginAttribute::kUnspecified,
/*mime_type=*/absl::nullopt);
auto hints = network::mojom::EarlyHints::New();
hints->headers = network::mojom::ParsedHeaders::New();
hints->headers->link_headers.push_back(std::move(link_header));
params->client->OnReceiveEarlyHints(std::move(hints));
// Send the final response.
std::string headers =
base::StrCat({"HTTP/1.1 200 OK\n", "Content-Type: text/html\n",
"Link: </hinted.js>; rel=preload; as=script\n", "\n"});
URLLoaderInterceptor::WriteResponse(headers, kPageWithHintedScriptBody,
params->client.get());
return true;
} else if (params->url_request.url.path() == "/hinted.js") {
std::string headers = base::StrCat(
{"HTTP/1.1 200 OK\n", "Content-Type: application/javascript\n",
"Cache-Control: max-age=3600\n", "\n"});
URLLoaderInterceptor::WriteResponse(headers, kHintedScriptBody,
params->client.get());
return true;
}
return false;
}
std::unique_ptr<URLLoaderInterceptor> url_loader_interceptor_;
};
class NavigationEarlyHintsPrerenderTest : public NavigationEarlyHintsTest {
public:
NavigationEarlyHintsPrerenderTest()
: prerender_helper_(base::BindRepeating(
&NavigationEarlyHintsPrerenderTest::web_contents,
base::Unretained(this))) {}
~NavigationEarlyHintsPrerenderTest() override = default;
test::PrerenderTestHelper* prerender_helper() { return &prerender_helper_; }
WebContents* web_contents() { return shell()->web_contents(); }
private:
test::PrerenderTestHelper prerender_helper_;
};
IN_PROC_BROWSER_TEST_F(NavigationEarlyHintsPrerenderTest,
AllowPreloadInPrerendering) {
EXPECT_TRUE(NavigateToURL(
shell(), net::QuicSimpleTestServer::GetFileURL("/title1.html")));
ResponseEntry entry = CreatePageEntryWithHintedScript(net::HTTP_OK);
RegisterResponse(entry);
// Loads a page in the prerender.
int host_id = prerender_helper()->AddPrerender(
net::QuicSimpleTestServer::GetFileURL(kPageWithHintedScriptPath));
RenderFrameHostImpl* prerender_rfh = static_cast<RenderFrameHostImpl*>(
prerender_helper()->GetPrerenderedMainFrameHost(host_id));
EXPECT_NE(prerender_rfh, nullptr);
EXPECT_NE(prerender_rfh->early_hints_manager(), nullptr);
PreloadedResources preloads = WaitForPreloadedResources(prerender_rfh);
EXPECT_EQ(preloads.size(), 1UL);
GURL script_url = net::QuicSimpleTestServer::GetFileURL(kHintedScriptPath);
EXPECT_TRUE(preloads.contains(script_url));
}
class NavigationEarlyHintsFencedFrameTest : public NavigationEarlyHintsTest {
public:
NavigationEarlyHintsFencedFrameTest() = default;
test::FencedFrameTestHelper& fenced_frame_test_helper() {
return fenced_frame_test_helper_;
}
ResponseEntry CreatePageEntryWithHintedScriptInFencedFrame(
net::HttpStatusCode status_code) {
RegisterHintedScriptResource();
ResponseEntry entry(kPageWithHintedScriptPath, status_code);
entry.headers["supports-loading-mode"] = "fenced-frame";
entry.body = kPageWithHintedScriptBody;
HeaderField link_header = CreatePreloadLinkForScript();
HeaderField fenced_frame_header =
HeaderField("supports-loading-mode", "fenced-frame");
entry.AddEarlyHints(
{std::move(link_header), std::move(fenced_frame_header)});
return entry;
}
private:
test::FencedFrameTestHelper fenced_frame_test_helper_;
};
IN_PROC_BROWSER_TEST_F(NavigationEarlyHintsFencedFrameTest,
DisallowPreloadInFencedFrame) {
EXPECT_TRUE(NavigateToURL(
shell(), net::QuicSimpleTestServer::GetFileURL("/title1.html")));
ResponseEntry entry =
CreatePageEntryWithHintedScriptInFencedFrame(net::HTTP_OK);
RegisterResponse(entry);
// Create a fenced frame.
RenderFrameHostImpl* fenced_frame_host = static_cast<RenderFrameHostImpl*>(
fenced_frame_test_helper().CreateFencedFrame(
shell()->web_contents()->GetPrimaryMainFrame(),
net::QuicSimpleTestServer::GetFileURL(kPageWithHintedScriptPath)));
EXPECT_NE(fenced_frame_host, nullptr);
EXPECT_EQ(fenced_frame_host->early_hints_manager(), nullptr);
}
class NavigationEarlyHintsPortalTest : public NavigationEarlyHintsTest {
public:
NavigationEarlyHintsPortalTest() {
scoped_feature_list_.InitWithFeatures(
/*enabled_features=*/{blink::features::kPortals,
blink::features::kPortalsCrossOrigin},
/*disabled_features=*/{});
}
private:
base::test::ScopedFeatureList scoped_feature_list_;
};
IN_PROC_BROWSER_TEST_F(NavigationEarlyHintsPortalTest,
DisallowPreloadInPortal) {
EXPECT_TRUE(NavigateToURL(
shell(), net::QuicSimpleTestServer::GetFileURL("/title1.html")));
ResponseEntry entry = CreatePageEntryWithHintedScript(net::HTTP_OK);
RegisterResponse(entry);
GURL portal_url(
net::QuicSimpleTestServer::GetFileURL(kPageWithHintedScriptPath));
WebContentsAddedObserver contents_observer;
TestNavigationObserver portal_nav_observer(portal_url);
portal_nav_observer.StartWatchingNewWebContents();
// Create a portal.
EXPECT_TRUE(
ExecJs(shell()->web_contents()->GetPrimaryMainFrame(),
JsReplace("{"
" let portal = document.createElement('portal');"
" portal.src = $1;"
" document.body.appendChild(portal);"
"}",
portal_url),
EXECUTE_SCRIPT_NO_USER_GESTURE));
WebContents* portal_web_contents = contents_observer.GetWebContents();
EXPECT_NE(portal_web_contents, nullptr);
portal_nav_observer.WaitForNavigationFinished();
EXPECT_EQ(static_cast<RenderFrameHostImpl*>(
portal_web_contents->GetPrimaryMainFrame())
->early_hints_manager(),
nullptr);
}
} // namespace content