blob: 1d00443749a7c5c884cfd19ea0858ef926a2b3cd [file] [log] [blame]
// Copyright 2020 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#include "services/network/web_bundle_url_loader_factory.h"
#include "base/callback_helpers.h"
#include "base/test/metrics/histogram_tester.h"
#include "base/test/task_environment.h"
#include "components/web_package/test_support/web_bundle_builder.h"
#include "mojo/public/cpp/bindings/remote.h"
#include "mojo/public/cpp/system/data_pipe_utils.h"
#include "net/traffic_annotation/network_traffic_annotation_test_helper.h"
#include "services/network/public/mojom/url_loader.mojom.h"
#include "services/network/public/mojom/url_loader_factory.mojom.h"
#include "services/network/test/mock_devtools_observer.h"
#include "services/network/test/test_url_loader_client.h"
#include "services/network/web_bundle_memory_quota_consumer.h"
#include "testing/gmock/include/gmock/gmock.h"
#include "testing/gtest/include/gtest/gtest.h"
namespace network {
namespace {
const char kInitiatorUrl[] = "https://example.com/";
const char kBundleUrl[] = "https://example.com/bundle.wbn";
const char kBundleRequestId[] = "bundle-devtools-request-id";
const char kResourceUrl[] = "https://example.com/";
const char kResourceUrl2[] = "https://example.com/another";
const char kResourceUrl3[] = "https://example.com/yetanother";
const char kResourceRequestId[] = "resource-1-devtools-request-id";
const char kResourceRequestId2[] = "resource-2-devtools-request-id";
const char kResourceRequestId3[] = "resource-3-devtools-request-id";
// Cross origin resources
const char kCrossOriginJsonUrl[] = "https://other.com/resource.json";
const char kCrossOriginJsUrl[] = "https://other.com/resource.js";
using ::testing::_;
using ::testing::ElementsAre;
using ::testing::Eq;
using ::testing::Optional;
using ::testing::Pointee;
std::vector<uint8_t> CreateSmallBundle() {
web_package::test::WebBundleBuilder builder(kResourceUrl,
"" /* manifest_url */);
builder.AddExchange(kResourceUrl,
{{":status", "200"}, {"content-type", "text/plain"}},
"body");
return builder.CreateBundle();
}
std::vector<uint8_t> CreateLargeBundle() {
web_package::test::WebBundleBuilder builder(kResourceUrl,
"" /* manifest_url */);
builder.AddExchange(kResourceUrl,
{{":status", "200"}, {"content-type", "text/plain"}},
"body");
builder.AddExchange(kResourceUrl2,
{{":status", "200"}, {"content-type", "text/plain"}},
std::string(10000, 'a'));
builder.AddExchange(kResourceUrl3,
{{":status", "200"}, {"content-type", "text/plain"}},
"body");
return builder.CreateBundle();
}
std::vector<uint8_t> CreateCrossOriginBundle() {
web_package::test::WebBundleBuilder builder(kCrossOriginJsonUrl,
"" /* manifest_url */);
builder.AddExchange(
kCrossOriginJsonUrl,
{{":status", "200"}, {"content-type", "application/json"}},
"{ secret: 1 }");
builder.AddExchange(kCrossOriginJsUrl,
{{":status", "200"}, {"content-type", "application/js"}},
"const not_secret = 1;");
return builder.CreateBundle();
}
class TestWebBundleHandle : public mojom::WebBundleHandle {
public:
explicit TestWebBundleHandle(
mojo::PendingReceiver<mojom::WebBundleHandle> receiver)
: receiver_(this, std::move(receiver)) {}
const absl::optional<std::pair<mojom::WebBundleErrorType, std::string>>&
last_bundle_error() const {
return last_bundle_error_;
}
void RunUntilBundleError() {
if (last_bundle_error_.has_value())
return;
base::RunLoop run_loop;
quit_closure_for_bundle_error_ = run_loop.QuitClosure();
run_loop.Run();
}
// mojom::WebBundleHandle
void Clone(mojo::PendingReceiver<mojom::WebBundleHandle> receiver) override {
NOTREACHED();
}
void OnWebBundleError(mojom::WebBundleErrorType type,
const std::string& message) override {
last_bundle_error_ = std::make_pair(type, message);
if (quit_closure_for_bundle_error_)
std::move(quit_closure_for_bundle_error_).Run();
}
void OnWebBundleLoadFinished(bool success) override {}
private:
mojo::Receiver<mojom::WebBundleHandle> receiver_;
absl::optional<std::pair<mojom::WebBundleErrorType, std::string>>
last_bundle_error_;
base::OnceClosure quit_closure_for_bundle_error_;
};
class MockMemoryQuotaConsumer : public WebBundleMemoryQuotaConsumer {
public:
MockMemoryQuotaConsumer() = default;
~MockMemoryQuotaConsumer() override = default;
bool AllocateMemory(uint64_t num_bytes) override { return true; }
};
class BadMessageTestHelper {
public:
BadMessageTestHelper()
: dummy_message_(0, 0, 0, 0, nullptr), context_(&dummy_message_) {
mojo::SetDefaultProcessErrorHandler(base::BindRepeating(
&BadMessageTestHelper::OnBadMessage, base::Unretained(this)));
}
BadMessageTestHelper(const BadMessageTestHelper&) = delete;
BadMessageTestHelper& operator=(const BadMessageTestHelper&) = delete;
~BadMessageTestHelper() {
mojo::SetDefaultProcessErrorHandler(base::NullCallback());
}
const std::vector<std::string>& bad_message_reports() const {
return bad_message_reports_;
}
private:
void OnBadMessage(const std::string& reason) {
bad_message_reports_.push_back(reason);
}
std::vector<std::string> bad_message_reports_;
mojo::Message dummy_message_;
mojo::internal::MessageDispatchContext context_;
};
} // namespace
class WebBundleURLLoaderFactoryTest : public ::testing::Test {
public:
void SetUp() override {
mojo::ScopedDataPipeConsumerHandle consumer;
ASSERT_EQ(CreateDataPipe(nullptr, bundle_data_destination_, consumer),
MOJO_RESULT_OK);
mojo::Remote<mojom::WebBundleHandle> handle;
handle_ = std::make_unique<TestWebBundleHandle>(
handle.BindNewPipeAndPassReceiver());
devtools_observer_ = std::make_unique<MockDevToolsObserver>();
factory_ = std::make_unique<WebBundleURLLoaderFactory>(
GURL(kBundleUrl), std::move(handle),
/*request_initiator_origin_lock=*/absl::nullopt,
std::make_unique<MockMemoryQuotaConsumer>(), devtools_observer_->Bind(),
kBundleRequestId);
factory_->SetBundleStream(std::move(consumer));
}
void WriteBundle(base::span<const uint8_t> data) {
mojo::BlockingCopyFromString(
std::string(reinterpret_cast<const char*>(data.data()), data.size()),
bundle_data_destination_);
}
void FinishWritingBundle() { bundle_data_destination_.reset(); }
struct StartRequestResult {
mojo::Remote<network::mojom::URLLoader> loader;
std::unique_ptr<network::TestURLLoaderClient> client;
};
network::ResourceRequest CreateRequest(
const GURL& url,
const std::string& devtools_request_id) {
network::ResourceRequest request;
request.url = url;
request.method = "GET";
request.request_initiator = url::Origin::Create(GURL(kInitiatorUrl));
request.web_bundle_token_params = ResourceRequest::WebBundleTokenParams();
request.web_bundle_token_params->bundle_url = GURL(kBundleUrl);
request.devtools_request_id = devtools_request_id;
return request;
}
StartRequestResult StartRequest(const ResourceRequest& request) {
StartRequestResult result;
result.client = std::make_unique<network::TestURLLoaderClient>();
factory_->StartSubresourceRequest(
result.loader.BindNewPipeAndPassReceiver(), request,
result.client->CreateRemote(),
mojo::Remote<mojom::TrustedHeaderClient>());
return result;
}
StartRequestResult StartRequest(const GURL& url,
const std::string& devtools_request_id) {
return StartRequest(CreateRequest(url, devtools_request_id));
}
void RunUntilBundleError() { handle_->RunUntilBundleError(); }
const absl::optional<std::pair<mojom::WebBundleErrorType, std::string>>&
last_bundle_error() const {
return handle_->last_bundle_error();
}
protected:
std::unique_ptr<MockDevToolsObserver> devtools_observer_;
std::unique_ptr<WebBundleURLLoaderFactory> factory_;
private:
std::unique_ptr<TestWebBundleHandle> handle_;
mojo::ScopedDataPipeProducerHandle bundle_data_destination_;
base::test::TaskEnvironment task_environment;
};
TEST_F(WebBundleURLLoaderFactoryTest, Basic) {
base::HistogramTester histogram_tester;
WriteBundle(CreateSmallBundle());
FinishWritingBundle();
EXPECT_CALL(*devtools_observer_,
OnSubresourceWebBundleMetadata(kBundleRequestId,
ElementsAre(GURL(kResourceUrl))));
EXPECT_CALL(*devtools_observer_,
OnSubresourceWebBundleInnerResponse(
kResourceRequestId, GURL(kResourceUrl),
Optional(std::string(kBundleRequestId))));
auto request = StartRequest(GURL(kResourceUrl), kResourceRequestId);
request.client->RunUntilComplete();
EXPECT_EQ(net::OK, request.client->completion_status().error_code);
EXPECT_FALSE(last_bundle_error().has_value());
EXPECT_EQ(request.client->response_head()->web_bundle_url, GURL(kBundleUrl));
std::string body;
EXPECT_TRUE(mojo::BlockingCopyToString(
request.client->response_body_release(), &body));
EXPECT_EQ("body", body);
histogram_tester.ExpectUniqueSample(
"SubresourceWebBundles.LoadResult",
WebBundleURLLoaderFactory::SubresourceWebBundleLoadResult::kSuccess, 1);
}
TEST_F(WebBundleURLLoaderFactoryTest, MetadataParseError) {
base::HistogramTester histogram_tester;
auto request = StartRequest(GURL(kResourceUrl), kResourceRequestId);
std::vector<uint8_t> bundle = CreateSmallBundle();
bundle[4] ^= 1; // Mutate magic bytes.
WriteBundle(bundle);
FinishWritingBundle();
EXPECT_CALL(*devtools_observer_,
OnSubresourceWebBundleMetadataError(kBundleRequestId,
Eq("Wrong magic bytes.")));
EXPECT_CALL(*devtools_observer_, OnSubresourceWebBundleInnerResponse(_, _, _))
.Times(0);
request.client->RunUntilComplete();
RunUntilBundleError();
EXPECT_EQ(net::ERR_INVALID_WEB_BUNDLE,
request.client->completion_status().error_code);
EXPECT_EQ(last_bundle_error()->first,
mojom::WebBundleErrorType::kMetadataParseError);
EXPECT_EQ(last_bundle_error()->second, "Wrong magic bytes.");
// Requests made after metadata parse error should also fail.
auto request2 = StartRequest(GURL(kResourceUrl), kResourceRequestId);
request2.client->RunUntilComplete();
EXPECT_EQ(net::ERR_INVALID_WEB_BUNDLE,
request2.client->completion_status().error_code);
histogram_tester.ExpectUniqueSample(
"SubresourceWebBundles.LoadResult",
WebBundleURLLoaderFactory::SubresourceWebBundleLoadResult::
kMetadataParseError,
1);
}
TEST_F(WebBundleURLLoaderFactoryTest, ResponseParseError) {
web_package::test::WebBundleBuilder builder(kResourceUrl,
"" /* manifest_url */);
// An invalid response.
builder.AddExchange(kResourceUrl, {{":status", "0"}}, "body");
WriteBundle(builder.CreateBundle());
FinishWritingBundle();
EXPECT_CALL(*devtools_observer_,
OnSubresourceWebBundleMetadata(kBundleRequestId,
ElementsAre(GURL(kResourceUrl))));
EXPECT_CALL(*devtools_observer_,
OnSubresourceWebBundleInnerResponseError(
kResourceRequestId, GURL(kResourceUrl),
Eq(":status must be 3 ASCII decimal digits."),
Optional(std::string(kBundleRequestId))));
auto request = StartRequest(GURL(kResourceUrl), kResourceRequestId);
request.client->RunUntilComplete();
RunUntilBundleError();
EXPECT_EQ(net::ERR_INVALID_WEB_BUNDLE,
request.client->completion_status().error_code);
EXPECT_EQ(last_bundle_error()->first,
mojom::WebBundleErrorType::kResponseParseError);
EXPECT_EQ(last_bundle_error()->second,
":status must be 3 ASCII decimal digits.");
}
TEST_F(WebBundleURLLoaderFactoryTest, ResourceNotFoundInBundle) {
WriteBundle(CreateSmallBundle());
FinishWritingBundle();
EXPECT_CALL(*devtools_observer_,
OnSubresourceWebBundleMetadata(kBundleRequestId,
ElementsAre(GURL(kResourceUrl))));
EXPECT_CALL(*devtools_observer_, OnSubresourceWebBundleInnerResponse(_, _, _))
.Times(0);
auto request = StartRequest(GURL("https://example.com/no-such-resource"),
kResourceRequestId);
request.client->RunUntilComplete();
RunUntilBundleError();
EXPECT_EQ(net::ERR_INVALID_WEB_BUNDLE,
request.client->completion_status().error_code);
EXPECT_EQ(last_bundle_error()->first,
mojom::WebBundleErrorType::kResourceNotFound);
EXPECT_EQ(
last_bundle_error()->second,
"https://example.com/no-such-resource is not found in the WebBundle.");
}
TEST_F(WebBundleURLLoaderFactoryTest, RedirectResponseIsNotAllowed) {
web_package::test::WebBundleBuilder builder(kResourceUrl,
"" /* manifest_url */);
builder.AddExchange(kResourceUrl,
{{":status", "301"}, {"location", kResourceUrl2}}, "");
builder.AddExchange(kResourceUrl2,
{{":status", "200"}, {"content-type", "text/plain"}},
"body");
WriteBundle(builder.CreateBundle());
FinishWritingBundle();
EXPECT_CALL(*devtools_observer_,
OnSubresourceWebBundleMetadata(
kBundleRequestId,
ElementsAre(GURL(kResourceUrl), GURL(kResourceUrl2))));
EXPECT_CALL(*devtools_observer_,
OnSubresourceWebBundleInnerResponse(
kResourceRequestId, GURL(kResourceUrl),
Optional(std::string(kBundleRequestId))));
auto request = StartRequest(GURL(kResourceUrl), kResourceRequestId);
request.client->RunUntilComplete();
RunUntilBundleError();
EXPECT_EQ(net::ERR_INVALID_WEB_BUNDLE,
request.client->completion_status().error_code);
EXPECT_EQ(last_bundle_error()->first,
mojom::WebBundleErrorType::kResponseParseError);
EXPECT_EQ(last_bundle_error()->second, "Invalid response code 301");
}
TEST_F(WebBundleURLLoaderFactoryTest, StartRequestBeforeReadingBundle) {
auto request = StartRequest(GURL(kResourceUrl), kResourceRequestId);
WriteBundle(CreateSmallBundle());
FinishWritingBundle();
request.client->RunUntilComplete();
EXPECT_EQ(net::OK, request.client->completion_status().error_code);
}
TEST_F(WebBundleURLLoaderFactoryTest, MultipleRequests) {
auto request1 = StartRequest(GURL(kResourceUrl), kResourceRequestId);
auto request2 = StartRequest(GURL(kResourceUrl2), kResourceRequestId2);
std::vector<uint8_t> bundle = CreateLargeBundle();
// Write the first 10kB of the bundle in which the bundle's metadata and the
// response for kResourceUrl are included.
ASSERT_GT(bundle.size(), 10000U);
WriteBundle(base::make_span(bundle).subspan(0, 10000));
request1.client->RunUntilComplete();
EXPECT_EQ(net::OK, request1.client->completion_status().error_code);
EXPECT_FALSE(request2.client->has_received_completion());
// Write the rest of the data.
WriteBundle(base::make_span(bundle).subspan(10000));
FinishWritingBundle();
request2.client->RunUntilComplete();
EXPECT_EQ(net::OK, request2.client->completion_status().error_code);
}
TEST_F(WebBundleURLLoaderFactoryTest, CancelRequest) {
auto request_to_complete1 =
StartRequest(GURL(kResourceUrl), kResourceRequestId);
auto request_to_complete2 =
StartRequest(GURL(kResourceUrl2), kResourceRequestId2);
auto request_to_cancel1 =
StartRequest(GURL(kResourceUrl), kResourceRequestId);
auto request_to_cancel2 =
StartRequest(GURL(kResourceUrl2), kResourceRequestId2);
auto request_to_cancel3 =
StartRequest(GURL(kResourceUrl3), kResourceRequestId3);
// Cancel request before getting metadata.
request_to_cancel1.loader.reset();
std::vector<uint8_t> bundle = CreateLargeBundle();
// Write the first 10kB of the bundle in which the bundle's metadata, response
// for kResourceUrl, and response header for kResourceUrl2 are included.
ASSERT_GT(bundle.size(), 10000U);
WriteBundle(base::make_span(bundle).subspan(0, 10000));
// This makes sure the bytes written above are consumed by WebBundle parser.
request_to_complete1.client->RunUntilComplete();
// Cancel request after reading response header, but before reading body.
request_to_cancel2.loader.reset();
// Cancel request after getting metadata, but before reading response header.
request_to_cancel3.loader.reset();
// Write the rest of the data.
WriteBundle(base::make_span(bundle).subspan(10000));
FinishWritingBundle();
request_to_complete2.client->RunUntilComplete();
EXPECT_EQ(net::OK,
request_to_complete2.client->completion_status().error_code);
}
TEST_F(WebBundleURLLoaderFactoryTest,
FactoryDestructionCancelsInflightRequests) {
auto request = StartRequest(GURL(kResourceUrl), kResourceRequestId);
factory_ = nullptr;
WriteBundle(CreateSmallBundle());
FinishWritingBundle();
request.client->RunUntilComplete();
EXPECT_EQ(net::ERR_FAILED, request.client->completion_status().error_code);
}
TEST_F(WebBundleURLLoaderFactoryTest, TruncatedBundle) {
std::vector<uint8_t> bundle = CreateSmallBundle();
// Truncate in the middle of responses section.
bundle.resize(bundle.size() - 10);
WriteBundle(std::move(bundle));
FinishWritingBundle();
auto request = StartRequest(GURL(kResourceUrl), kResourceRequestId);
request.client->RunUntilComplete();
RunUntilBundleError();
EXPECT_EQ(net::ERR_INVALID_WEB_BUNDLE,
request.client->completion_status().error_code);
EXPECT_EQ(last_bundle_error()->first,
mojom::WebBundleErrorType::kResponseParseError);
EXPECT_EQ(last_bundle_error()->second, "Error reading response header.");
}
TEST_F(WebBundleURLLoaderFactoryTest, CrossOiginJson) {
WriteBundle(CreateCrossOriginBundle());
FinishWritingBundle();
auto request = StartRequest(GURL(kCrossOriginJsonUrl), kResourceRequestId);
request.client->RunUntilComplete();
EXPECT_EQ(net::OK, request.client->completion_status().error_code);
EXPECT_FALSE(last_bundle_error().has_value());
std::string body;
ASSERT_TRUE(mojo::BlockingCopyToString(
request.client->response_body_release(), &body));
EXPECT_TRUE(body.empty())
<< "body should be empty because JSON is a CORB-protected resource";
}
TEST_F(WebBundleURLLoaderFactoryTest, CrossOriginJs) {
WriteBundle(CreateCrossOriginBundle());
FinishWritingBundle();
auto request = StartRequest(GURL(kCrossOriginJsUrl), kResourceRequestId);
request.client->RunUntilComplete();
EXPECT_EQ(net::OK, request.client->completion_status().error_code);
EXPECT_FALSE(last_bundle_error().has_value());
std::string body;
ASSERT_TRUE(mojo::BlockingCopyToString(
request.client->response_body_release(), &body));
EXPECT_EQ("const not_secret = 1;", body)
<< "body should be valid one because JS is not a CORB protected resource";
}
TEST_F(WebBundleURLLoaderFactoryTest, WrongBundleURL) {
BadMessageTestHelper bad_message_helper;
WriteBundle(CreateSmallBundle());
FinishWritingBundle();
network::ResourceRequest url_request =
CreateRequest(GURL(kResourceUrl), kResourceRequestId);
url_request.web_bundle_token_params->bundle_url =
GURL("https://modified-bundle-url.example.com/");
auto request = StartRequest(url_request);
request.client->RunUntilComplete();
EXPECT_EQ(net::ERR_INVALID_ARGUMENT,
request.client->completion_status().error_code);
EXPECT_THAT(bad_message_helper.bad_message_reports(),
::testing::ElementsAre(
"WebBundleURLLoaderFactory: Bundle URL does not match"));
}
} // namespace network