blob: ac7accfe83f0a4ac4572b089a0a3304541a9e02b [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 "content/browser/webauth/webauth_request_security_checker.h"
#include <string_view>
#include "base/json/json_reader.h"
#include "base/memory/raw_ptr.h"
#include "base/test/scoped_feature_list.h"
#include "base/values.h"
#include "content/browser/renderer_host/render_frame_host_impl.h"
#include "content/public/browser/web_contents.h"
#include "content/public/common/content_features.h"
#include "content/public/test/browser_task_environment.h"
#include "content/public/test/navigation_simulator.h"
#include "content/public/test/test_browser_context.h"
#include "content/public/test/test_renderer_host.h"
#include "content/public/test/test_web_contents_factory.h"
#include "services/network/public/cpp/permissions_policy/permissions_policy_declaration.h"
#include "services/network/public/mojom/permissions_policy/permissions_policy_feature.mojom-shared.h"
#include "testing/gtest/include/gtest/gtest.h"
#include "third_party/blink/public/mojom/webauthn/authenticator.mojom.h"
#include "url/gurl.h"
#include "url/origin.h"
#include "url/url_util.h"
namespace content {
namespace {
network::ParsedPermissionsPolicy CreatePolicyToAllowWebAuthn() {
return {network::ParsedPermissionsPolicyDeclaration(
network::mojom::PermissionsPolicyFeature::kPublicKeyCredentialsGet,
/*allowed_origins=*/{}, /*self_if_matches=*/std::nullopt,
/*matches_all_origins=*/true,
/*matches_opaque_src=*/false)};
}
// The default policy allows same-origin with ancestors, but this creates one
// with value 'none'.
network::ParsedPermissionsPolicy CreatePolicyToDenyWebAuthn() {
return {network::ParsedPermissionsPolicyDeclaration(
network::mojom::PermissionsPolicyFeature::kPublicKeyCredentialsGet,
/*allowed_origins=*/{}, /*self_if_matches=*/std::nullopt,
/*matches_all_origins=*/false,
/*matches_opaque_src=*/false)};
}
network::ParsedPermissionsPolicy CreatePolicyToAllowWebPayments() {
return {network::ParsedPermissionsPolicyDeclaration(
network::mojom::PermissionsPolicyFeature::kPayment,
/*allowed_origins=*/{},
/*self_if_matches=*/std::nullopt,
/*matches_all_origins=*/true, /*matches_opaque_src=*/false)};
}
struct TestCase {
TestCase(const std::string_view& url,
const network::ParsedPermissionsPolicy& policy,
WebAuthRequestSecurityChecker::RequestType request_type,
bool expected_is_cross_origin,
blink::mojom::AuthenticatorStatus expected_status)
: url(url),
policy(policy),
request_type(request_type),
expected_is_cross_origin(expected_is_cross_origin),
expected_status(expected_status) {}
~TestCase() = default;
const std::string_view url;
const network::ParsedPermissionsPolicy policy;
const WebAuthRequestSecurityChecker::RequestType request_type;
const bool expected_is_cross_origin;
const blink::mojom::AuthenticatorStatus expected_status;
};
std::ostream& operator<<(std::ostream& out, const TestCase& test_case) {
out << test_case.url << " ";
switch (test_case.request_type) {
case WebAuthRequestSecurityChecker::RequestType::kGetAssertion:
out << "Get Assertion";
break;
case WebAuthRequestSecurityChecker::RequestType::
kGetPaymentCredentialAssertion:
out << "Get Payment Credential Assertion";
break;
case WebAuthRequestSecurityChecker::RequestType::kMakePaymentCredential:
out << "Make Payment Credential";
break;
case WebAuthRequestSecurityChecker::RequestType::kMakeCredential:
out << "Make Credential";
break;
case WebAuthRequestSecurityChecker::RequestType::kReport:
out << "Report";
break;
}
return out;
}
class WebAuthRequestSecurityCheckerTest
: public testing::TestWithParam<TestCase> {
protected:
WebAuthRequestSecurityCheckerTest()
: web_contents_(web_contents_factory_.CreateWebContents(&context_)) {}
~WebAuthRequestSecurityCheckerTest() override = default;
content::WebContents* web_contents() const { return web_contents_; }
private:
// Must be first because ScopedFeatureList must be initialized before other
// threads are started.
base::test::ScopedFeatureList features_{
/*enable_feature=*/features::kSecurePaymentConfirmation};
content::BrowserTaskEnvironment task_environment_;
content::TestBrowserContext context_;
content::TestWebContentsFactory web_contents_factory_;
raw_ptr<content::WebContents>
web_contents_; // Owned by `web_contents_factory_`.
};
TEST_P(WebAuthRequestSecurityCheckerTest, ValidateAncestorOrigins) {
RenderFrameHost* main_frame =
NavigationSimulator::NavigateAndCommitFromBrowser(
web_contents(), GURL("https://same-origin.com"));
ASSERT_NE(nullptr, main_frame);
RenderFrameHostTester* tester = RenderFrameHostTester::For(main_frame);
RenderFrameHost* sub_frame =
tester->AppendChildWithPolicy("sub_frame", GetParam().policy);
ASSERT_NE(nullptr, sub_frame);
sub_frame = NavigationSimulator::NavigateAndCommitFromDocument(
GURL(GetParam().url), sub_frame);
scoped_refptr<WebAuthRequestSecurityChecker> checker =
static_cast<RenderFrameHostImpl*>(sub_frame)
->GetWebAuthRequestSecurityChecker();
bool actual_is_cross_origin = false;
blink::mojom::AuthenticatorStatus actual_status =
checker->ValidateAncestorOrigins(
url::Origin::Create(GURL(GetParam().url)), GetParam().request_type,
&actual_is_cross_origin);
EXPECT_EQ(GetParam().expected_status, actual_status);
EXPECT_EQ(GetParam().expected_is_cross_origin, actual_is_cross_origin);
}
INSTANTIATE_TEST_SUITE_P(
ProhibitCrossOrigin,
WebAuthRequestSecurityCheckerTest,
testing::Values(
TestCase("https://same-origin.com",
network::ParsedPermissionsPolicy(),
WebAuthRequestSecurityChecker::RequestType::kGetAssertion,
/*expected_is_cross_origin=*/false,
blink::mojom::AuthenticatorStatus::SUCCESS),
TestCase("https://cross-origin.com",
network::ParsedPermissionsPolicy(),
WebAuthRequestSecurityChecker::RequestType::kGetAssertion,
/*expected_is_cross_origin=*/true,
blink::mojom::AuthenticatorStatus::NOT_ALLOWED_ERROR),
TestCase("https://same-origin.com",
network::ParsedPermissionsPolicy(),
WebAuthRequestSecurityChecker::RequestType::kMakeCredential,
/*expected_is_cross_origin=*/false,
blink::mojom::AuthenticatorStatus::SUCCESS),
TestCase("https://cross-origin.com",
network::ParsedPermissionsPolicy(),
WebAuthRequestSecurityChecker::RequestType::kMakeCredential,
/*expected_is_cross_origin=*/true,
blink::mojom::AuthenticatorStatus::NOT_ALLOWED_ERROR),
TestCase(
"https://same-origin.com",
network::ParsedPermissionsPolicy(),
WebAuthRequestSecurityChecker::RequestType::kMakePaymentCredential,
/*expected_is_cross_origin=*/false,
blink::mojom::AuthenticatorStatus::SUCCESS),
TestCase(
"https://cross-origin.com",
network::ParsedPermissionsPolicy(),
WebAuthRequestSecurityChecker::RequestType::kMakePaymentCredential,
/*expected_is_cross_origin=*/true,
blink::mojom::AuthenticatorStatus::NOT_ALLOWED_ERROR)));
INSTANTIATE_TEST_SUITE_P(
AllowCrossOriginWebAuthn,
WebAuthRequestSecurityCheckerTest,
testing::Values(
TestCase("https://same-origin.com",
CreatePolicyToAllowWebAuthn(),
WebAuthRequestSecurityChecker::RequestType::kGetAssertion,
/*expected_is_cross_origin=*/false,
blink::mojom::AuthenticatorStatus::SUCCESS),
TestCase("https://cross-origin.com",
CreatePolicyToAllowWebAuthn(),
WebAuthRequestSecurityChecker::RequestType::kGetAssertion,
/*expected_is_cross_origin=*/true,
blink::mojom::AuthenticatorStatus::SUCCESS),
TestCase("https://same-origin.com",
CreatePolicyToAllowWebAuthn(),
WebAuthRequestSecurityChecker::RequestType::kMakeCredential,
/*expected_is_cross_origin=*/false,
blink::mojom::AuthenticatorStatus::SUCCESS),
TestCase("https://cross-origin.com",
CreatePolicyToAllowWebAuthn(),
WebAuthRequestSecurityChecker::RequestType::kMakeCredential,
/*expected_is_cross_origin=*/true,
blink::mojom::AuthenticatorStatus::NOT_ALLOWED_ERROR),
TestCase(
"https://same-origin.com",
CreatePolicyToAllowWebAuthn(),
WebAuthRequestSecurityChecker::RequestType::kMakePaymentCredential,
/*expected_is_cross_origin=*/false,
blink::mojom::AuthenticatorStatus::SUCCESS),
TestCase(
"https://cross-origin.com",
CreatePolicyToAllowWebAuthn(),
WebAuthRequestSecurityChecker::RequestType::kMakePaymentCredential,
/*expected_is_cross_origin=*/true,
blink::mojom::AuthenticatorStatus::NOT_ALLOWED_ERROR)));
INSTANTIATE_TEST_SUITE_P(
AllowCrossOriginPay,
WebAuthRequestSecurityCheckerTest,
testing::Values(
TestCase("https://same-origin.com",
CreatePolicyToAllowWebPayments(),
WebAuthRequestSecurityChecker::RequestType::kGetAssertion,
/*expected_is_cross_origin=*/false,
blink::mojom::AuthenticatorStatus::SUCCESS),
TestCase("https://cross-origin.com",
CreatePolicyToAllowWebPayments(),
WebAuthRequestSecurityChecker::RequestType::kGetAssertion,
/*expected_is_cross_origin=*/true,
blink::mojom::AuthenticatorStatus::NOT_ALLOWED_ERROR),
TestCase("https://same-origin.com",
CreatePolicyToAllowWebPayments(),
WebAuthRequestSecurityChecker::RequestType::kMakeCredential,
/*expected_is_cross_origin=*/false,
blink::mojom::AuthenticatorStatus::SUCCESS),
TestCase("https://cross-origin.com",
CreatePolicyToAllowWebPayments(),
WebAuthRequestSecurityChecker::RequestType::kMakeCredential,
/*expected_is_cross_origin=*/true,
blink::mojom::AuthenticatorStatus::NOT_ALLOWED_ERROR),
TestCase(
"https://same-origin.com",
CreatePolicyToAllowWebPayments(),
WebAuthRequestSecurityChecker::RequestType::kMakePaymentCredential,
/*expected_is_cross_origin=*/false,
blink::mojom::AuthenticatorStatus::SUCCESS),
TestCase(
"https://cross-origin.com",
CreatePolicyToAllowWebPayments(),
WebAuthRequestSecurityChecker::RequestType::kMakePaymentCredential,
/*expected_is_cross_origin=*/true,
blink::mojom::AuthenticatorStatus::SUCCESS)));
struct SingleFrameTestCase {
SingleFrameTestCase(const network::ParsedPermissionsPolicy& policy,
WebAuthRequestSecurityChecker::RequestType request_type,
blink::mojom::AuthenticatorStatus expected_status)
: policy(policy),
request_type(request_type),
expected_status(expected_status) {}
~SingleFrameTestCase() = default;
const network::ParsedPermissionsPolicy policy;
const WebAuthRequestSecurityChecker::RequestType request_type;
const blink::mojom::AuthenticatorStatus expected_status;
};
class WebAuthRequestSecurityCheckerSingleFrameTest
: public testing::TestWithParam<SingleFrameTestCase> {
protected:
WebAuthRequestSecurityCheckerSingleFrameTest()
: web_contents_(web_contents_factory_.CreateWebContents(&context_)) {}
~WebAuthRequestSecurityCheckerSingleFrameTest() override = default;
content::WebContents* web_contents() const { return web_contents_; }
private:
// Must be first because ScopedFeatureList must be initialized before other
// threads are started.
base::test::ScopedFeatureList features_{
/*enable_feature=*/features::kSecurePaymentConfirmation};
content::BrowserTaskEnvironment task_environment_;
content::TestBrowserContext context_;
content::TestWebContentsFactory web_contents_factory_;
raw_ptr<content::WebContents>
web_contents_; // Owned by `web_contents_factory_`.
};
TEST_P(WebAuthRequestSecurityCheckerSingleFrameTest,
ValidateAncestorOriginsOnRoot) {
auto navigation = NavigationSimulator::CreateBrowserInitiated(
GURL("https://same-origin.com"), web_contents());
navigation->SetPermissionsPolicyHeader(GetParam().policy);
navigation->Commit();
ASSERT_NE(nullptr, web_contents()->GetPrimaryMainFrame());
scoped_refptr<WebAuthRequestSecurityChecker> checker =
static_cast<RenderFrameHostImpl*>(web_contents()->GetPrimaryMainFrame())
->GetWebAuthRequestSecurityChecker();
bool actual_is_cross_origin = false;
blink::mojom::AuthenticatorStatus actual_status =
checker->ValidateAncestorOrigins(
url::Origin::Create(GURL("https://same-origin.com")),
GetParam().request_type, &actual_is_cross_origin);
EXPECT_EQ(GetParam().expected_status, actual_status);
EXPECT_EQ(false, actual_is_cross_origin);
}
INSTANTIATE_TEST_SUITE_P(
WebAuthnSingleFrame,
WebAuthRequestSecurityCheckerSingleFrameTest,
testing::Values(
SingleFrameTestCase(
CreatePolicyToAllowWebAuthn(),
WebAuthRequestSecurityChecker::RequestType::kGetAssertion,
blink::mojom::AuthenticatorStatus::SUCCESS),
SingleFrameTestCase(
CreatePolicyToDenyWebAuthn(),
WebAuthRequestSecurityChecker::RequestType::kGetAssertion,
blink::mojom::AuthenticatorStatus::NOT_ALLOWED_ERROR),
SingleFrameTestCase(
CreatePolicyToAllowWebAuthn(),
WebAuthRequestSecurityChecker::RequestType::kMakeCredential,
blink::mojom::AuthenticatorStatus::SUCCESS),
SingleFrameTestCase(
CreatePolicyToDenyWebAuthn(),
WebAuthRequestSecurityChecker::RequestType::kMakeCredential,
blink::mojom::AuthenticatorStatus::SUCCESS)));
class WebAuthRequestSecurityCheckerWellKnownJSONTest : public testing::Test {
protected:
blink::mojom::AuthenticatorStatus Test(std::string_view caller_origin_str,
std::string_view json) {
std::optional<base::Value> parsed =
base::JSONReader::Read(json, base::JSON_PARSE_RFC);
CHECK(parsed) << json;
GURL caller_origin_url(caller_origin_str);
CHECK(caller_origin_url.is_valid()) << caller_origin_str;
return WebAuthRequestSecurityChecker::RemoteValidation::
ValidateWellKnownJSON(url::Origin::Create(caller_origin_url), *parsed);
}
};
TEST_F(WebAuthRequestSecurityCheckerWellKnownJSONTest, Inputs) {
struct TestCase {
const char* json;
blink::mojom::AuthenticatorStatus expected;
};
constexpr blink::mojom::AuthenticatorStatus parse_error =
blink::mojom::AuthenticatorStatus::BAD_RELYING_PARTY_ID_JSON_PARSE_ERROR;
constexpr blink::mojom::AuthenticatorStatus ok =
blink::mojom::AuthenticatorStatus::SUCCESS;
constexpr blink::mojom::AuthenticatorStatus no_match =
blink::mojom::AuthenticatorStatus::BAD_RELYING_PARTY_ID_NO_JSON_MATCH;
constexpr blink::mojom::AuthenticatorStatus no_match_hit_limits = blink::
mojom::AuthenticatorStatus::BAD_RELYING_PARTY_ID_NO_JSON_MATCH_HIT_LIMITS;
static const TestCase kTestCases[] = {
{R"([])", parse_error},
{R"({})", parse_error},
{R"({"foo": "bar"})", parse_error},
{R"({"origins": "bar"})", parse_error},
{R"({"origins": []})", no_match},
{R"({"origins": [1]})", parse_error},
{R"({"origins": ["https://foo.com"]})", ok},
{R"({"origins": ["https://foo2.com"]})", no_match},
{R"({"origins": ["https://com"]})", no_match},
{R"({"origins": ["other://foo.com"]})", no_match},
{R"({"origins": [
"https://a.com",
"https://b.com",
"https://c.com",
"https://d.com",
"https://foo.com"
]})",
ok},
// Too many eTLD+1 labels.
{R"({"origins": [
"https://a.com",
"https://b.com",
"https://c.com",
"https://d.com",
"https://e.com",
"https://foo.com"
]})",
no_match_hit_limits},
// Too many eTLD+1 labels, but foo.com isn't at the end so will be
// processed.
{R"({"origins": [
"https://a.com",
"https://b.com",
"https://c.com",
"https://d.com",
"https://foo.com",
"https://e.com"
]})",
ok},
{R"({"origins": [
"https://foo.co.uk",
"https://foo.de",
"https://foo.in",
"https://foo.net",
"https://foo.org",
"https://foo.com"
]})",
ok},
};
for (const auto& test : kTestCases) {
SCOPED_TRACE(test.json);
EXPECT_EQ(test.expected, Test("https://foo.com", test.json));
}
}
} // namespace
} // namespace content