blob: 047c2b332738ca7855fb9a17b46ddf730ebb411d [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 "content/browser/interest_group/interest_group_permissions_checker.h"
#include <string>
#include "base/functional/callback.h"
#include "base/run_loop.h"
#include "base/test/task_environment.h"
#include "base/time/time.h"
#include "content/browser/interest_group/interest_group_permissions_cache.h"
#include "content/services/auction_worklet/worklet_test_util.h"
#include "net/base/network_isolation_key.h"
#include "services/data_decoder/public/cpp/test_support/in_process_data_decoder.h"
#include "services/network/public/cpp/resource_request.h"
#include "services/network/test/test_url_loader_factory.h"
#include "testing/gtest/include/gtest/gtest.h"
#include "url/gurl.h"
#include "url/origin.h"
namespace content {
namespace {
// Very short time used by some tests that want to wait until just after a
// timer triggers.
constexpr base::TimeDelta kTinyTime = base::Microseconds(1);
// Response body that allows everything.
const char kAllowAllResponse[] = R"({
"joinAdInterestGroup" : true,
"leaveAdInterestGroup" : true
})";
// Response body that allows nothing.
const char kAllowNoneResponse[] = R"({
"joinAdInterestGroup" : false,
"leaveAdInterestGroup" : false
})";
// Single-use helper class to manage a PermissionsCheckCallback and track its
// result.
class BoolCallback {
public:
BoolCallback() = default;
BoolCallback(const BoolCallback&) = delete;
BoolCallback& operator=(const BoolCallback&) = delete;
~BoolCallback() = default;
InterestGroupPermissionsChecker::PermissionsCheckCallback callback() {
return base::BindOnce(&BoolCallback::CallbackInvoked,
base::Unretained(this));
}
// Waits for the callback to be invoked and returns the value passed to it.
bool GetResult() {
run_loop_.Run();
return result_;
}
bool has_result() { return run_loop_.AnyQuitCalled(); }
private:
void CallbackInvoked(bool result) {
EXPECT_FALSE(run_loop_.AnyQuitCalled());
result_ = result;
run_loop_.Quit();
}
bool result_;
base::RunLoop run_loop_;
};
class InterestGroupPermissionsCheckerTestBase {
protected:
// Frame origin used in most tests.
const url::Origin kFrameOrigin =
url::Origin::Create(GURL("https://frame.test"));
// Cross origin group, used by most tests.
const url::Origin kGroupOrigin =
url::Origin::Create(GURL("https://group.test"));
// NetworkIsolationKey used in most tests.
const net::NetworkIsolationKey kNetworkIsolationKey =
net::NetworkIsolationKey(kFrameOrigin, kFrameOrigin);
// .well-known URL when using `kFrameOrigin` and `kGroupOrigin`.
const GURL validation_url_ = GURL(
"https://group.test/.well-known/interest-group/permissions/"
"?origin=https%3A%2F%2Fframe.test");
base::test::TaskEnvironment task_environment_ = base::test::TaskEnvironment(
base::test::TaskEnvironment::TimeSource::MOCK_TIME);
data_decoder::test::InProcessDataDecoder in_process_data_decoder_;
BoolCallback bool_callback_;
network::TestURLLoaderFactory url_loader_factory_;
InterestGroupPermissionsChecker interest_group_permissions_checker_;
};
// Some of these tests are paramaterized, some are not.
class InterestGroupPermissionsCheckerTest
: public InterestGroupPermissionsCheckerTestBase,
public testing::Test {};
class InterestGroupPermissionsCheckerParamaterizedTest
: public InterestGroupPermissionsCheckerTestBase,
public testing::TestWithParam<
InterestGroupPermissionsChecker::Operation> {
public:
InterestGroupPermissionsChecker::Operation GetOperation() const {
return GetParam();
}
};
INSTANTIATE_TEST_SUITE_P(
All,
InterestGroupPermissionsCheckerParamaterizedTest,
testing::Values(InterestGroupPermissionsChecker::Operation::kJoin,
InterestGroupPermissionsChecker::Operation::kLeave));
// Same origin operations should be allowed without a .well-known request.
TEST_P(InterestGroupPermissionsCheckerParamaterizedTest, SameOrigin) {
interest_group_permissions_checker_.CheckPermissions(
GetOperation(), kFrameOrigin, kFrameOrigin, kNetworkIsolationKey,
url_loader_factory_, bool_callback_.callback());
// The callback should be invoked synchronously in this case, so that a
// same-origin join followed by a running an auction should immediately
EXPECT_TRUE(bool_callback_.has_result());
EXPECT_TRUE(bool_callback_.GetResult());
EXPECT_EQ(0u, url_loader_factory_.total_requests());
}
// Check a number of parameters set on the ResourceRequest that aren't worth the
// effort of writing integration tests for individually.
TEST_P(InterestGroupPermissionsCheckerParamaterizedTest, RequestParameters) {
interest_group_permissions_checker_.CheckPermissions(
GetOperation(), kFrameOrigin, kGroupOrigin, kNetworkIsolationKey,
url_loader_factory_, bool_callback_.callback());
ASSERT_EQ(1u, url_loader_factory_.pending_requests()->size());
const auto& request = (*url_loader_factory_.pending_requests())[0].request;
EXPECT_EQ(network::mojom::CredentialsMode::kOmit, request.credentials_mode);
EXPECT_EQ(network::mojom::RequestMode::kCors, request.mode);
EXPECT_EQ(kFrameOrigin, request.request_initiator);
std::string accept;
ASSERT_TRUE(request.headers.GetHeader("Accept", &accept));
EXPECT_EQ(accept, "application/json");
}
TEST_P(InterestGroupPermissionsCheckerParamaterizedTest, HttpError) {
url_loader_factory_.AddResponse(validation_url_.spec(), kAllowAllResponse,
net::HTTP_NOT_FOUND);
interest_group_permissions_checker_.CheckPermissions(
GetOperation(), kFrameOrigin, kGroupOrigin, kNetworkIsolationKey,
url_loader_factory_, bool_callback_.callback());
EXPECT_FALSE(bool_callback_.GetResult());
EXPECT_EQ(1u, url_loader_factory_.total_requests());
}
TEST_P(InterestGroupPermissionsCheckerParamaterizedTest, WrongMimeType) {
auction_worklet::AddJavascriptResponse(&url_loader_factory_, validation_url_,
kAllowAllResponse);
interest_group_permissions_checker_.CheckPermissions(
GetOperation(), kFrameOrigin, kGroupOrigin, kNetworkIsolationKey,
url_loader_factory_, bool_callback_.callback());
EXPECT_FALSE(bool_callback_.GetResult());
EXPECT_EQ(1u, url_loader_factory_.total_requests());
}
// Test different response bodies, some using valid JSON, some not.
TEST_P(InterestGroupPermissionsCheckerParamaterizedTest, ResponseBodyHandling) {
struct {
const char* response_body;
std::set<InterestGroupPermissionsChecker::Operation> allowed_operations;
} kTestCases[] = {
// Not JSON.
{"Look Mom, I'm on TV!", {}},
// Not JSON dictionaries.
{"42", {}},
{"\"42\"", {}},
{"[]", {}},
{"[42]", {}},
// JSON dictionaries with unexpected keys.
{R"({"join": true, "leave":true})", {}},
// Unexpected capitalization.
{R"({"JoinAdInterestGroup": true, "leaveadinterestgroup":true})", {}},
// Empty dictionary allows nothing.
{"{}", {}},
{kAllowAllResponse,
{InterestGroupPermissionsChecker::Operation::kJoin,
InterestGroupPermissionsChecker::Operation::kLeave}},
{kAllowNoneResponse, {}},
// One operation allowed, other not present.
{R"({"joinAdInterestGroup" : true})",
{InterestGroupPermissionsChecker::Operation::kJoin}},
{R"({"leaveAdInterestGroup" : true})",
{InterestGroupPermissionsChecker::Operation::kLeave}},
// One operation allowed, other false.
{R"({"joinAdInterestGroup" : true, "leaveAdInterestGroup" : false})",
{InterestGroupPermissionsChecker::Operation::kJoin}},
{R"({"joinAdInterestGroup" : false, "leaveAdInterestGroup" : true})",
{InterestGroupPermissionsChecker::Operation::kLeave}},
// One operation allowed, other not present, extra value.
{R"({"joinAdInterestGroup" : true, "addMilk" : false})",
{InterestGroupPermissionsChecker::Operation::kJoin}},
{R"({"leaveAdInterestGroup" : true, "addMilk" : false})",
{InterestGroupPermissionsChecker::Operation::kLeave}},
};
for (const auto& test_case : kTestCases) {
SCOPED_TRACE(test_case.response_body);
// Since all requests use the same arguments, need to clear the cache
// between calls.
interest_group_permissions_checker_.ClearCache();
BoolCallback bool_callback;
auction_worklet::AddJsonResponse(&url_loader_factory_, validation_url_,
test_case.response_body);
interest_group_permissions_checker_.CheckPermissions(
GetOperation(), kFrameOrigin, kGroupOrigin, kNetworkIsolationKey,
url_loader_factory_, bool_callback.callback());
EXPECT_EQ(test_case.allowed_operations.count(GetOperation()) > 0u,
bool_callback.GetResult());
}
}
// Test the case where requests are merged. Both requests use the same
// Operation.
TEST_P(InterestGroupPermissionsCheckerParamaterizedTest, SameOperationsMerged) {
interest_group_permissions_checker_.CheckPermissions(
GetOperation(), kFrameOrigin, kGroupOrigin, kNetworkIsolationKey,
url_loader_factory_, bool_callback_.callback());
BoolCallback bool_callback2;
interest_group_permissions_checker_.CheckPermissions(
GetOperation(), kFrameOrigin, kGroupOrigin, kNetworkIsolationKey,
url_loader_factory_, bool_callback2.callback());
auction_worklet::AddJsonResponse(&url_loader_factory_, validation_url_,
kAllowAllResponse);
EXPECT_TRUE(bool_callback_.GetResult());
EXPECT_TRUE(bool_callback2.GetResult());
// There should only have been one network request.
EXPECT_EQ(1u, url_loader_factory_.total_requests());
}
// Test case where requests are merged. Requests use different Operations.
TEST_F(InterestGroupPermissionsCheckerTest, DifferentOperationsMerged) {
interest_group_permissions_checker_.CheckPermissions(
InterestGroupPermissionsChecker::Operation::kJoin, kFrameOrigin,
kGroupOrigin, kNetworkIsolationKey, url_loader_factory_,
bool_callback_.callback());
BoolCallback bool_callback2;
interest_group_permissions_checker_.CheckPermissions(
InterestGroupPermissionsChecker::Operation::kLeave, kFrameOrigin,
kGroupOrigin, kNetworkIsolationKey, url_loader_factory_,
bool_callback2.callback());
auction_worklet::AddJsonResponse(&url_loader_factory_, validation_url_,
kAllowAllResponse);
EXPECT_TRUE(bool_callback_.GetResult());
EXPECT_TRUE(bool_callback2.GetResult());
// There should only have been one network request.
EXPECT_EQ(1u, url_loader_factory_.total_requests());
}
// Test case where requests are merged, with different Operations and different
// permissions.
TEST_F(InterestGroupPermissionsCheckerTest,
DifferentOperationsMergedDifferentResults) {
interest_group_permissions_checker_.CheckPermissions(
InterestGroupPermissionsChecker::Operation::kJoin, kFrameOrigin,
kGroupOrigin, kNetworkIsolationKey, url_loader_factory_,
bool_callback_.callback());
BoolCallback bool_callback2;
interest_group_permissions_checker_.CheckPermissions(
InterestGroupPermissionsChecker::Operation::kLeave, kFrameOrigin,
kGroupOrigin, kNetworkIsolationKey, url_loader_factory_,
bool_callback2.callback());
auction_worklet::AddJsonResponse(&url_loader_factory_, validation_url_,
R"({"joinAdInterestGroup" : true})");
EXPECT_TRUE(bool_callback_.GetResult());
EXPECT_FALSE(bool_callback2.GetResult());
// There should only have been one network request.
EXPECT_EQ(1u, url_loader_factory_.total_requests());
}
// Test that permission checks with different frame origins can't be merged, and
// are cached separately.
TEST_P(InterestGroupPermissionsCheckerParamaterizedTest, DifferentFrameOrigin) {
// The only way two permissions checks from different frame origins can share
// a NetworkIsolationKey is if they are same-site. So use an origin that's
// same-site to kFrameOrigin, and DCHECK that they have the same
// NetworkIsolationKey.
const url::Origin kOtherFrameOrigin =
url::Origin::Create(GURL("https://other.frame.test"));
DCHECK(net::NetworkIsolationKey(kOtherFrameOrigin, kOtherFrameOrigin) ==
kNetworkIsolationKey);
const GURL kOtherValidationUrl(
"https://group.test/.well-known/interest-group/permissions/"
"?origin=https%3A%2F%2Fother.frame.test");
interest_group_permissions_checker_.CheckPermissions(
GetOperation(), kFrameOrigin, kGroupOrigin, kNetworkIsolationKey,
url_loader_factory_, bool_callback_.callback());
BoolCallback bool_callback2;
interest_group_permissions_checker_.CheckPermissions(
GetOperation(), kOtherFrameOrigin, kGroupOrigin, kNetworkIsolationKey,
url_loader_factory_, bool_callback2.callback());
auction_worklet::AddJsonResponse(&url_loader_factory_, validation_url_,
kAllowAllResponse);
auction_worklet::AddJsonResponse(&url_loader_factory_, kOtherValidationUrl,
kAllowNoneResponse);
EXPECT_TRUE(bool_callback_.GetResult());
EXPECT_FALSE(bool_callback2.GetResult());
// There should have been one network request for each frame owner.
EXPECT_EQ(2u, url_loader_factory_.total_requests());
// Repeat checks. Results should be the same, but with no new network
// requests.
BoolCallback bool_callback3;
interest_group_permissions_checker_.CheckPermissions(
GetOperation(), kFrameOrigin, kGroupOrigin, kNetworkIsolationKey,
url_loader_factory_, bool_callback3.callback());
BoolCallback bool_callback4;
interest_group_permissions_checker_.CheckPermissions(
GetOperation(), kOtherFrameOrigin, kGroupOrigin, kNetworkIsolationKey,
url_loader_factory_, bool_callback4.callback());
EXPECT_TRUE(bool_callback3.GetResult());
EXPECT_FALSE(bool_callback4.GetResult());
EXPECT_EQ(2u, url_loader_factory_.total_requests());
}
// Test that permission checks with different interest group owners can't be
// merged, and are cached separately.
TEST_P(InterestGroupPermissionsCheckerParamaterizedTest, DifferentOwner) {
const url::Origin kOtherGroupOrigin =
url::Origin::Create(GURL("https://group2.test"));
const GURL kOtherValidationUrl(
"https://group2.test/.well-known/interest-group/permissions/"
"?origin=https%3A%2F%2Fframe.test");
interest_group_permissions_checker_.CheckPermissions(
GetOperation(), kFrameOrigin, kGroupOrigin, kNetworkIsolationKey,
url_loader_factory_, bool_callback_.callback());
BoolCallback bool_callback2;
interest_group_permissions_checker_.CheckPermissions(
GetOperation(), kFrameOrigin, kOtherGroupOrigin, kNetworkIsolationKey,
url_loader_factory_, bool_callback2.callback());
auction_worklet::AddJsonResponse(&url_loader_factory_, validation_url_,
kAllowAllResponse);
auction_worklet::AddJsonResponse(&url_loader_factory_, kOtherValidationUrl,
kAllowNoneResponse);
EXPECT_TRUE(bool_callback_.GetResult());
EXPECT_FALSE(bool_callback2.GetResult());
// There should have been one network request for each origin.
EXPECT_EQ(2u, url_loader_factory_.total_requests());
// Repeat checks. Results should be the same, but with no new network
// requests.
BoolCallback bool_callback3;
interest_group_permissions_checker_.CheckPermissions(
GetOperation(), kFrameOrigin, kGroupOrigin, kNetworkIsolationKey,
url_loader_factory_, bool_callback3.callback());
BoolCallback bool_callback4;
interest_group_permissions_checker_.CheckPermissions(
GetOperation(), kFrameOrigin, kOtherGroupOrigin, kNetworkIsolationKey,
url_loader_factory_, bool_callback4.callback());
EXPECT_TRUE(bool_callback3.GetResult());
EXPECT_FALSE(bool_callback4.GetResult());
EXPECT_EQ(2u, url_loader_factory_.total_requests());
}
// Test that permission checks with different NetworkIsolationKeys can't be
// merged, and are cached separately.
TEST_P(InterestGroupPermissionsCheckerParamaterizedTest,
DifferentNetworkIsolationKey) {
const net::NetworkIsolationKey kOtherNetworkIsolationKey(
url::Origin::Create(GURL("https://top-frame.test")), kFrameOrigin);
interest_group_permissions_checker_.CheckPermissions(
GetOperation(), kFrameOrigin, kGroupOrigin, kNetworkIsolationKey,
url_loader_factory_, bool_callback_.callback());
BoolCallback bool_callback2;
interest_group_permissions_checker_.CheckPermissions(
GetOperation(), kFrameOrigin, kGroupOrigin, kOtherNetworkIsolationKey,
url_loader_factory_, bool_callback2.callback());
// There should be two pending network requests.
ASSERT_EQ(2u, url_loader_factory_.pending_requests()->size());
// Make the first response grant permissions and the second refuse
// them. Since there's a single URLLoaderFactory pipe, the requests should be
// in the order of the CheckPermissions calls above.
for (int i = 0; i < 2; ++i) {
auto& pending_request = (*url_loader_factory_.pending_requests())[i];
EXPECT_EQ(validation_url_, pending_request.request.url);
auto head = network::mojom::URLResponseHead::New();
head->mime_type = "application/json";
head->headers =
net::HttpResponseHeaders::TryToCreate("HTTP/1.1 200 OK\r\n\r\n");
ASSERT_TRUE(head->headers);
mojo::ScopedDataPipeProducerHandle producer_handle;
std::string response_body = i == 0 ? kAllowAllResponse : kAllowNoneResponse;
mojo::ScopedDataPipeConsumerHandle body;
ASSERT_EQ(mojo::CreateDataPipe(response_body.size(), producer_handle, body),
MOJO_RESULT_OK);
uint32_t bytes_written = response_body.size();
ASSERT_EQ(MOJO_RESULT_OK,
producer_handle->WriteData(response_body.data(), &bytes_written,
MOJO_WRITE_DATA_FLAG_ALL_OR_NONE));
pending_request.client->OnReceiveResponse(std::move(head), std::move(body),
absl::nullopt);
auto status = network::URLLoaderCompletionStatus();
status.decoded_body_length = response_body.size();
pending_request.client->OnComplete(status);
}
EXPECT_TRUE(bool_callback_.GetResult());
EXPECT_FALSE(bool_callback2.GetResult());
// Repeat checks. Results should be the same, but with no new network
// requests.
BoolCallback bool_callback3;
interest_group_permissions_checker_.CheckPermissions(
GetOperation(), kFrameOrigin, kGroupOrigin, kNetworkIsolationKey,
url_loader_factory_, bool_callback3.callback());
BoolCallback bool_callback4;
interest_group_permissions_checker_.CheckPermissions(
GetOperation(), kFrameOrigin, kGroupOrigin, kOtherNetworkIsolationKey,
url_loader_factory_, bool_callback4.callback());
EXPECT_TRUE(bool_callback3.GetResult());
EXPECT_FALSE(bool_callback4.GetResult());
EXPECT_EQ(2u, url_loader_factory_.total_requests());
}
// Check the case the same parameters are repeatedly fed into
// CheckPermissions(), both before and after a cache entry expires.
TEST_P(InterestGroupPermissionsCheckerParamaterizedTest, CacheExpires) {
// Set up a response that denies permissions, and send a request. Permissions
// should be denied.
auction_worklet::AddJsonResponse(&url_loader_factory_, validation_url_,
kAllowNoneResponse);
interest_group_permissions_checker_.CheckPermissions(
InterestGroupPermissionsChecker::Operation::kJoin, kFrameOrigin,
kGroupOrigin, kNetworkIsolationKey, url_loader_factory_,
bool_callback_.callback());
EXPECT_FALSE(bool_callback_.GetResult());
EXPECT_EQ(1u, url_loader_factory_.total_requests());
// Make future responses allow permissions.
auction_worklet::AddJsonResponse(&url_loader_factory_, validation_url_,
kAllowAllResponse);
// Wait until just before the cache entry expired. The original cached
// response should be returned.
task_environment_.FastForwardBy(
InterestGroupPermissionsCache::kCacheDuration);
BoolCallback bool_callback2;
interest_group_permissions_checker_.CheckPermissions(
InterestGroupPermissionsChecker::Operation::kJoin, kFrameOrigin,
kGroupOrigin, kNetworkIsolationKey, url_loader_factory_,
bool_callback2.callback());
EXPECT_FALSE(bool_callback2.GetResult());
EXPECT_EQ(1u, url_loader_factory_.total_requests());
// Wait until the cache entry expires and check permissions again. The result
// should change, and there should be a new network request.
task_environment_.FastForwardBy(kTinyTime);
BoolCallback bool_callback3;
interest_group_permissions_checker_.CheckPermissions(
InterestGroupPermissionsChecker::Operation::kJoin, kFrameOrigin,
kGroupOrigin, kNetworkIsolationKey, url_loader_factory_,
bool_callback3.callback());
EXPECT_TRUE(bool_callback3.GetResult());
EXPECT_EQ(2u, url_loader_factory_.total_requests());
}
TEST_P(InterestGroupPermissionsCheckerParamaterizedTest, NonDefaultPorts) {
const url::Origin kFrameOrigin =
url::Origin::Create(GURL("https://frame.test:123"));
const url::Origin kGroupOrigin =
url::Origin::Create(GURL("https://group.test:456"));
const GURL kValidationUrl = GURL(
"https://group.test:456/.well-known/interest-group/permissions/"
"?origin=https%3A%2F%2Fframe.test%3A123");
auction_worklet::AddJavascriptResponse(&url_loader_factory_, kValidationUrl,
kAllowAllResponse);
interest_group_permissions_checker_.CheckPermissions(
GetOperation(), kFrameOrigin, kGroupOrigin,
net::NetworkIsolationKey(kFrameOrigin, kFrameOrigin), url_loader_factory_,
bool_callback_.callback());
EXPECT_FALSE(bool_callback_.GetResult());
EXPECT_EQ(1u, url_loader_factory_.total_requests());
}
TEST_P(InterestGroupPermissionsCheckerParamaterizedTest, HttpTimeout) {
const base::TimeDelta tiny_time = base::Milliseconds(1);
interest_group_permissions_checker_.CheckPermissions(
GetOperation(), kFrameOrigin, kGroupOrigin, kNetworkIsolationKey,
url_loader_factory_, bool_callback_.callback());
EXPECT_FALSE(bool_callback_.has_result());
task_environment_.FastForwardBy(
InterestGroupPermissionsChecker::kRequestTimeout - tiny_time);
EXPECT_FALSE(bool_callback_.has_result());
EXPECT_EQ(1u, url_loader_factory_.total_requests());
task_environment_.FastForwardBy(tiny_time);
EXPECT_TRUE(bool_callback_.has_result());
EXPECT_FALSE(bool_callback_.GetResult());
}
TEST_P(InterestGroupPermissionsCheckerParamaterizedTest, MaxSize) {
std::string response = kAllowAllResponse;
response += std::string(
InterestGroupPermissionsChecker::kMaxBodySize - response.size(), ' ');
auction_worklet::AddJsonResponse(&url_loader_factory_, validation_url_,
response);
interest_group_permissions_checker_.CheckPermissions(
GetOperation(), kFrameOrigin, kGroupOrigin, kNetworkIsolationKey,
url_loader_factory_, bool_callback_.callback());
EXPECT_TRUE(bool_callback_.GetResult());
EXPECT_EQ(1u, url_loader_factory_.total_requests());
}
TEST_P(InterestGroupPermissionsCheckerParamaterizedTest, MaxSizeExceeded) {
std::string response = kAllowAllResponse;
response += std::string(
InterestGroupPermissionsChecker::kMaxBodySize - response.size() + 1, ' ');
auction_worklet::AddJsonResponse(&url_loader_factory_, validation_url_,
response);
interest_group_permissions_checker_.CheckPermissions(
GetOperation(), kFrameOrigin, kGroupOrigin, kNetworkIsolationKey,
url_loader_factory_, bool_callback_.callback());
EXPECT_FALSE(bool_callback_.GetResult());
EXPECT_EQ(1u, url_loader_factory_.total_requests());
}
} // namespace
} // namespace content