// Copyright 2024 The Chromium Authors // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. #include "net/device_bound_sessions/session_inclusion_rules.h" #include #include "base/strings/string_util.h" #include "net/base/registry_controlled_domains/registry_controlled_domain.h" #include "net/device_bound_sessions/proto/storage.pb.h" #include "testing/gtest/include/gtest/gtest.h" #include "url/gurl.h" #include "url/origin.h" namespace net::device_bound_sessions { namespace { using Result = SessionInclusionRules::InclusionResult; // These tests depend on the registry_controlled_domains code, so assert ahead // of time that the eTLD+1 is what we expect, for clarity and to avoid confusing // test failures. #define ASSERT_DOMAIN_AND_REGISTRY(origin, expected_domain_and_registry) \ { \ ASSERT_EQ( \ registry_controlled_domains::GetDomainAndRegistry( \ origin, registry_controlled_domains::INCLUDE_PRIVATE_REGISTRIES), \ expected_domain_and_registry) \ << "Unexpected domain and registry."; \ } struct EvaluateUrlTestCase { const char* url; Result expected_result; }; void CheckEvaluateUrlTestCases( const SessionInclusionRules& inclusion_rules, std::initializer_list test_cases) { for (const auto& test_case : test_cases) { SCOPED_TRACE(test_case.url); EXPECT_EQ(inclusion_rules.EvaluateRequestUrl(GURL(test_case.url)), test_case.expected_result); } } struct AddUrlRuleTestCase { Result rule_type; const char* host_pattern; const char* path_prefix; bool expected_is_added; }; void CheckAddUrlRuleTestCases( SessionInclusionRules& inclusion_rules, std::initializer_list test_cases) { for (const auto& test_case : test_cases) { SCOPED_TRACE(base::JoinString( {test_case.host_pattern, test_case.path_prefix}, ", ")); bool is_added = inclusion_rules.AddUrlRuleIfValid( test_case.rule_type, test_case.host_pattern, test_case.path_prefix); EXPECT_EQ(is_added, test_case.expected_is_added); } } TEST(SessionInclusionRulesTest, DefaultConstructorMatchesNothing) { SessionInclusionRules inclusion_rules; EXPECT_FALSE(inclusion_rules.may_include_site_for_testing()); EXPECT_EQ(Result::kExclude, inclusion_rules.EvaluateRequestUrl(GURL("https://origin.test"))); EXPECT_EQ(Result::kExclude, inclusion_rules.EvaluateRequestUrl(GURL())); } TEST(SessionInclusionRulesTest, DefaultIncludeOriginMayNotIncludeSite) { url::Origin subdomain_origin = url::Origin::Create(GURL("https://some.site.test")); ASSERT_DOMAIN_AND_REGISTRY(subdomain_origin, "site.test"); SessionInclusionRules inclusion_rules{subdomain_origin}; EXPECT_FALSE(inclusion_rules.may_include_site_for_testing()); CheckEvaluateUrlTestCases( inclusion_rules, {// URL not valid. {"", Result::kExclude}, // Origins match. {"https://some.site.test", Result::kInclude}, // Path is allowed. {"https://some.site.test/path", Result::kInclude}, // Not same scheme. {"http://some.site.test", Result::kExclude}, // Not same host (same-site subdomain). {"https://some.other.site.test", Result::kExclude}, // Not same host (superdomain). {"https://site.test", Result::kExclude}, // Unrelated site. {"https://unrelated.test", Result::kExclude}, // Not same port. {"https://some.site.test:8888", Result::kExclude}}); } TEST(SessionInclusionRulesTest, DefaultIncludeOriginThoughMayIncludeSite) { url::Origin root_site_origin = url::Origin::Create(GURL("https://site.test")); ASSERT_DOMAIN_AND_REGISTRY(root_site_origin, "site.test"); SessionInclusionRules inclusion_rules{root_site_origin}; EXPECT_TRUE(inclusion_rules.may_include_site_for_testing()); // All expectations are as above. Even though including the site is allowed, // because the origin's host is its root eTLD+1, it is still limited to a // default origin inclusion_rules because it did not set include_site. CheckEvaluateUrlTestCases(inclusion_rules, {// URL not valid. {"", Result::kExclude}, // Origins match. {"https://site.test", Result::kInclude}, // Path is allowed. {"https://site.test/path", Result::kInclude}, // Not same scheme. {"http://site.test", Result::kExclude}, // Not same host (same-site subdomain). {"https://other.site.test", Result::kExclude}, // Not same host (superdomain). {"https://test", Result::kExclude}, // Unrelated site. {"https://unrelated.test", Result::kExclude}, // Not same port. {"https://site.test:8888", Result::kExclude}}); } TEST(SessionInclusionRulesTest, IncludeSiteAttemptedButNotAllowed) { url::Origin subdomain_origin = url::Origin::Create(GURL("https://some.site.test")); ASSERT_DOMAIN_AND_REGISTRY(subdomain_origin, "site.test"); SessionInclusionRules inclusion_rules{subdomain_origin}; EXPECT_FALSE(inclusion_rules.may_include_site_for_testing()); // Only the origin is included. CheckEvaluateUrlTestCases(inclusion_rules, {{"https://some.site.test", Result::kInclude}, {"https://other.site.test", Result::kExclude}}); // This shouldn't do anything. inclusion_rules.SetIncludeSite(true); EXPECT_FALSE(inclusion_rules.may_include_site_for_testing()); // Still only the origin is included. CheckEvaluateUrlTestCases(inclusion_rules, {{"https://some.site.test", Result::kInclude}, {"https://other.site.test", Result::kExclude}}); } TEST(SessionInclusionRulesTest, IncludeSite) { url::Origin root_site_origin = url::Origin::Create(GURL("https://site.test")); ASSERT_DOMAIN_AND_REGISTRY(root_site_origin, "site.test"); SessionInclusionRules inclusion_rules{root_site_origin}; EXPECT_TRUE(inclusion_rules.may_include_site_for_testing()); inclusion_rules.SetIncludeSite(true); CheckEvaluateUrlTestCases( inclusion_rules, {// URL not valid. {"", Result::kExclude}, // Origins match. {"https://site.test", Result::kInclude}, // Path is allowed. {"https://site.test/path", Result::kInclude}, // Not same scheme (site is schemeful). {"http://site.test", Result::kExclude}, // Same-site subdomain is allowed. {"https://some.site.test", Result::kInclude}, {"https://some.other.site.test", Result::kInclude}, // Not same host (superdomain). {"https://test", Result::kExclude}, // Unrelated site. {"https://unrelated.test", Result::kExclude}, // Other port is allowed because whole site is included. {"https://site.test:8888", Result::kInclude}}); } TEST(SessionInclusionRulesTest, AddUrlRuleToOriginOnly) { url::Origin subdomain_origin = url::Origin::Create(GURL("https://some.site.test")); ASSERT_DOMAIN_AND_REGISTRY(subdomain_origin, "site.test"); SessionInclusionRules inclusion_rules{subdomain_origin}; EXPECT_FALSE(inclusion_rules.may_include_site_for_testing()); // Only the origin is allowed, since the setting origin is not the root // eTLD+1. The only acceptable rules are limited to the origin/same host. CheckAddUrlRuleTestCases( inclusion_rules, {// Host pattern equals origin's host. Path is valid. {Result::kExclude, "some.site.test", "/static", true}, // Add an opposite rule to check later. {Result::kInclude, "some.site.test", "/static/included", true}, // Path not valid. {Result::kExclude, "some.site.test", "NotAPath", false}, // Other host patterns not accepted. {Result::kExclude, "*.site.test", "/", false}, {Result::kExclude, "unrelated.test", "/", false}, {Result::kExclude, "site.test", "/", false}, {Result::kExclude, "other.site.test", "/", false}, {Result::kExclude, "https://some.site.test", "/", false}, {Result::kExclude, "some.site.test:443", "/", false}}); EXPECT_EQ(inclusion_rules.num_url_rules_for_testing(), 2u); CheckEvaluateUrlTestCases( inclusion_rules, {// Matches the rule. {"https://some.site.test/static", Result::kExclude}, // A path under the rule's path prefix is subject to the rule. {"https://some.site.test/static/some/thing", Result::kExclude}, // These do not match the rule, so are subject to the basic rules (the // origin). {"https://some.site.test/staticcccccccc", Result::kInclude}, {"https://other.site.test/static", Result::kExclude}, // The more recently added rule wins out. {"https://some.site.test/static/included", Result::kInclude}}); // Note that what matters is when the rule was added, not how specific the URL // path prefix is. Let's add another rule now to show that. EXPECT_TRUE(inclusion_rules.AddUrlRuleIfValid(Result::kExclude, "some.site.test", "/")); EXPECT_EQ(Result::kExclude, inclusion_rules.EvaluateRequestUrl(GURL( "https://some.site.test/static/included"))); } TEST(SessionInclusionRulesTest, AddUrlRuleToOriginThatMayIncludeSite) { url::Origin root_site_origin = url::Origin::Create(GURL("https://site.test")); ASSERT_DOMAIN_AND_REGISTRY(root_site_origin, "site.test"); SessionInclusionRules inclusion_rules{root_site_origin}; EXPECT_TRUE(inclusion_rules.may_include_site_for_testing()); // Without any rules yet, the basic rules is just the origin, because // include_site was not set. CheckEvaluateUrlTestCases(inclusion_rules, {{"https://site.test/static", Result::kInclude}, {"https://other.site.test", Result::kExclude}}); // Since the origin's host is the root eTLD+1, it is allowed to set rules that // affect URLs other than the setting origin (but still within the site). CheckAddUrlRuleTestCases(inclusion_rules, {{Result::kExclude, "excluded.site.test", "/", true}, {Result::kInclude, "included.site.test", "/", true}, {Result::kExclude, "site.test", "/static", true}, // Rules outside of the site are not allowed. {Result::kExclude, "unrelated.test", "/", false}}); EXPECT_EQ(inclusion_rules.num_url_rules_for_testing(), 3u); CheckEvaluateUrlTestCases(inclusion_rules, {// Path is excluded by rule. {"https://site.test/static", Result::kExclude}, // Rule excludes URL explicitly. {"https://excluded.site.test", Result::kExclude}, // Rule includes URL explicitly. {"https://included.site.test", Result::kInclude}, // Rule does not apply to wrong scheme. {"http://included.site.test", Result::kExclude}, // No rules applies to these URLs, so the basic // rules (origin) applies. {"https://other.site.test", Result::kExclude}, {"https://site.test/stuff", Result::kInclude}}); } TEST(SessionInclusionRulesTest, AddUrlRuleToRulesIncludingSite) { url::Origin root_site_origin = url::Origin::Create(GURL("https://site.test")); ASSERT_DOMAIN_AND_REGISTRY(root_site_origin, "site.test"); SessionInclusionRules inclusion_rules{root_site_origin}; EXPECT_TRUE(inclusion_rules.may_include_site_for_testing()); inclusion_rules.SetIncludeSite(true); // Without any rules yet, the basic rules is the site. CheckEvaluateUrlTestCases(inclusion_rules, {{"https://site.test/static", Result::kInclude}, {"https://other.site.test", Result::kInclude}}); // Since the origin's host is the root eTLD+1, it is allowed to set rules that // affect URLs other than the setting origin (but still within the site). CheckAddUrlRuleTestCases(inclusion_rules, {{Result::kExclude, "excluded.site.test", "/", true}, {Result::kInclude, "included.site.test", "/", true}, {Result::kExclude, "site.test", "/static", true}, // Rules outside of the site are not allowed. {Result::kExclude, "unrelated.test", "/", false}}); EXPECT_EQ(inclusion_rules.num_url_rules_for_testing(), 3u); CheckEvaluateUrlTestCases( inclusion_rules, {// Path is excluded by rule. {"https://site.test/static", Result::kExclude}, // Rule excludes URL explicitly. {"https://excluded.site.test", Result::kExclude}, // Rule includes URL explicitly. {"https://included.site.test", Result::kInclude}, // Rule does not apply to wrong scheme. {"http://included.site.test", Result::kExclude}, // No rule applies to these URLs, so the basic rules (site) applies. {"https://other.site.test", Result::kInclude}, {"https://site.test/stuff", Result::kInclude}}); // Note that the rules are independent of "include_site", so even if that is // "revoked" the rules still work the same way. inclusion_rules.SetIncludeSite(false); CheckEvaluateUrlTestCases(inclusion_rules, {// Path is excluded by rule. {"https://site.test/static", Result::kExclude}, // Rule excludes URL explicitly. {"https://excluded.site.test", Result::kExclude}, // Rule includes URL explicitly. {"https://included.site.test", Result::kInclude}, // No rules applies to these URLs, so the basic // rules (which is now the origin) applies. {"https://other.site.test", Result::kExclude}, {"https://site.test/stuff", Result::kInclude}}); } TEST(SessionInclusionRulesTest, UrlRuleParsing) { url::Origin root_site_origin = url::Origin::Create(GURL("https://site.test")); ASSERT_DOMAIN_AND_REGISTRY(root_site_origin, "site.test"); // Use the most permissive type of inclusion_rules, to hit the interesting // edge cases. SessionInclusionRules inclusion_rules{root_site_origin}; EXPECT_TRUE(inclusion_rules.may_include_site_for_testing()); CheckAddUrlRuleTestCases( inclusion_rules, {// Empty host pattern not permitted. {Result::kExclude, "", "/", false}, // Host pattern that is only whitespace is not permitted. {Result::kExclude, " ", "/", false}, // Forbidden characters in host_pattern. {Result::kExclude, "https://site.test", "/", false}, {Result::kExclude, "site.test:8888", "/", false}, {Result::kExclude, "site.test,other.test", "/", false}, // Non-IPv6-allowable characters within the brackets. {Result::kExclude, "[*.:abcd::3:4:ff]", "/", false}, {Result::kExclude, "[1:ab+cd::3:4:ff]", "/", false}, {Result::kExclude, "[[1:abcd::3:4:ff]]", "/", false}, // Internal wildcard characters are forbidden in the host pattern. {Result::kExclude, "sub.*.site.test", "/", false}, // Multiple wildcard characters are forbidden in the host pattern. {Result::kExclude, "*.sub.*.site.test", "/", false}, // Wildcard must be followed by a dot. {Result::kExclude, "*site.test", "/", false}, // Wildcard must be followed by a non-eTLD. {Result::kExclude, "*.com", "/", false}, // Other sites are not allowed. {Result::kExclude, "unrelated.site", "/", false}, // Other hosts with no registrable domain are not allowed. {Result::kExclude, "4.31.198.44", "/", false}, {Result::kExclude, "[1:abcd::3:4:ff]", "/", false}, {Result::kExclude, "co.uk", "/", false}, {Result::kExclude, "com", "/", false}}); } TEST(SessionInclusionRulesTest, UrlRuleParsingTopLevelDomain) { url::Origin tld_origin = url::Origin::Create(GURL("https://com")); ASSERT_DOMAIN_AND_REGISTRY(tld_origin, ""); SessionInclusionRules inclusion_rules{tld_origin}; EXPECT_FALSE(inclusion_rules.may_include_site_for_testing()); CheckAddUrlRuleTestCases( inclusion_rules, {// Exact host is allowed. {Result::kExclude, "com", "/", true}, // Wildcards are not permitted. {Result::kExclude, "*.com", "/", false}, // Other hosts with no registrable domain are not allowed. {Result::kExclude, "4.31.198.44", "/", false}, {Result::kExclude, "[1:abcd::3:4:ff]", "/", false}, {Result::kExclude, "co.uk", "/", false}}); } TEST(SessionInclusionRulesTest, UrlRuleParsingIPv4Address) { url::Origin ip_origin = url::Origin::Create(GURL("https://4.31.198.44")); ASSERT_DOMAIN_AND_REGISTRY(ip_origin, ""); SessionInclusionRules inclusion_rules{ip_origin}; EXPECT_FALSE(inclusion_rules.may_include_site_for_testing()); CheckAddUrlRuleTestCases( inclusion_rules, {// Exact host is allowed. {Result::kExclude, "4.31.198.44", "/", true}, // Wildcards are not permitted. {Result::kExclude, "*.31.198.44", "/", false}, {Result::kExclude, "*.4.31.198.44", "/", false}, // Other hosts with no registrable domain are not allowed. {Result::kExclude, "[1:abcd::3:4:ff]", "/", false}, {Result::kExclude, "co.uk", "/", false}, {Result::kExclude, "com", "/", false}}); } TEST(SessionInclusionRulesTest, UrlRuleParsingIPv6Address) { url::Origin ipv6_origin = url::Origin::Create(GURL("https://[1:abcd::3:4:ff]")); ASSERT_DOMAIN_AND_REGISTRY(ipv6_origin, ""); SessionInclusionRules inclusion_rules{ipv6_origin}; EXPECT_FALSE(inclusion_rules.may_include_site_for_testing()); CheckAddUrlRuleTestCases( inclusion_rules, {// Exact host is allowed. {Result::kExclude, "[1:abcd::3:4:ff]", "/", true}, // Wildcards are not permitted. {Result::kExclude, "*.[1:abcd::3:4:ff]", "/", false}, // Brackets mismatched. {Result::kExclude, "[1:abcd::3:4:ff", "/", false}, {Result::kExclude, "1:abcd::3:4:ff]", "/", false}, // Non-IPv6-allowable characters within the brackets. {Result::kExclude, "[*.:abcd::3:4:ff]", "/", false}, {Result::kExclude, "[1:ab+cd::3:4:ff]", "/", false}, {Result::kExclude, "[[1:abcd::3:4:ff]]", "/", false}, // Other hosts with no registrable domain are not allowed. {Result::kExclude, "4.31.198.44", "/", false}, {Result::kExclude, "co.uk", "/", false}, {Result::kExclude, "com", "/", false}}); } // This test is more to document the current behavior than anything else. We may // discover a need for more comprehensive support for port numbers in the // future, in which case: // TODO(chlily): Support port numbers in URL rules. TEST(SessionInclusionRulesTest, NonstandardPort) { url::Origin nonstandard_port_origin = url::Origin::Create(GURL("https://site.test:8888")); ASSERT_DOMAIN_AND_REGISTRY(nonstandard_port_origin, "site.test"); SessionInclusionRules inclusion_rules{nonstandard_port_origin}; EXPECT_TRUE(inclusion_rules.may_include_site_for_testing()); // Without any URL rules, the default origin rule allows only the same origin. CheckEvaluateUrlTestCases(inclusion_rules, {{"https://site.test", Result::kExclude}, {"https://site.test:8888", Result::kInclude}, {"https://other.site.test", Result::kExclude}}); // If we include_site, then same-site URLs regardless of port number are // included. inclusion_rules.SetIncludeSite(true); CheckEvaluateUrlTestCases(inclusion_rules, {{"https://site.test", Result::kInclude}, {"https://site.test:8888", Result::kInclude}, {"https://site.test:1234", Result::kInclude}, {"https://other.site.test", Result::kInclude}}); // However, adding URL rules to an inclusion_rules based on such an origin may // lead to unintuitive outcomes. It is not possible to specify a rule that // applies to the same origin as the setting origin if the setting origin has // a nonstandard port. CheckAddUrlRuleTestCases( inclusion_rules, {// The pattern is rejected due to the colon, despite being the // same origin. {Result::kExclude, "site.test:8888", "/", false}, // A rule with the same host without port specified is accepted. // This rule applies to any URL with the specified host. {Result::kExclude, "site.test", "/", true}, // Any explicitly specified port is rejected (due to the colon), // even if it's the standard one. {Result::kExclude, "site.test:443", "/", false}}); EXPECT_EQ(inclusion_rules.num_url_rules_for_testing(), 1u); CheckEvaluateUrlTestCases( inclusion_rules, {// This is same-origin but gets caught in the "site.test" rule because // the rule didn't specify a port. {"https://site.test:8888", Result::kExclude}, // This is same-site but gets caught in the "site.test" rule because // the rule didn't specify a port. {"https://site.test:1234", Result::kExclude}, // Same-site is included by basic rules. {"https://other.site.test", Result::kInclude}, // Also excluded explicitly by rule. {"https://site.test", Result::kExclude}, {"https://site.test:443", Result::kExclude}}); } TEST(SessionInclusionRulesTest, ToFromProto) { // Create a valid SessionInclusionRules object with default inclusion rule and // a couple of additional URL rules. url::Origin root_site_origin = url::Origin::Create(GURL("https://site.test")); ASSERT_DOMAIN_AND_REGISTRY(root_site_origin, "site.test"); SessionInclusionRules inclusion_rules{root_site_origin}; EXPECT_TRUE(inclusion_rules.may_include_site_for_testing()); inclusion_rules.SetIncludeSite(true); EXPECT_TRUE(inclusion_rules.AddUrlRuleIfValid(Result::kExclude, "excluded.site.test", "/")); EXPECT_TRUE(inclusion_rules.AddUrlRuleIfValid(Result::kInclude, "included.site.test", "/")); // Create a corresponding proto object and validate. proto::SessionInclusionRules proto = inclusion_rules.ToProto(); EXPECT_EQ(root_site_origin.Serialize(), proto.origin()); EXPECT_TRUE(proto.do_include_site()); ASSERT_EQ(proto.url_rules().size(), 2); { const auto& rule = proto.url_rules(0); EXPECT_EQ(rule.rule_type(), proto::RuleType::EXCLUDE); EXPECT_EQ(rule.host_matcher_rule(), "excluded.site.test"); EXPECT_EQ(rule.path_prefix(), "/"); } { const auto& rule = proto.url_rules(1); EXPECT_EQ(rule.rule_type(), proto::RuleType::INCLUDE); EXPECT_EQ(rule.host_matcher_rule(), "included.site.test"); EXPECT_EQ(rule.path_prefix(), "/"); } // Create a SessionInclusionRules object from the proto and verify // that it is the same as the original. std::unique_ptr restored_inclusion_rules = SessionInclusionRules::CreateFromProto(proto); ASSERT_TRUE(restored_inclusion_rules != nullptr); EXPECT_EQ(*restored_inclusion_rules, inclusion_rules); } TEST(SessionInclusionRulesTest, FailCreateFromInvalidProto) { // Empty proto. { proto::SessionInclusionRules proto; EXPECT_FALSE(SessionInclusionRules::CreateFromProto(proto)); } // Opaque origin. { proto::SessionInclusionRules proto; proto.set_origin("about:blank"); proto.set_do_include_site(false); EXPECT_FALSE(SessionInclusionRules::CreateFromProto(proto)); } // Create a fully populated proto. url::Origin root_site_origin = url::Origin::Create(GURL("https://site.test")); SessionInclusionRules inclusion_rules{root_site_origin}; inclusion_rules.SetIncludeSite(true); inclusion_rules.AddUrlRuleIfValid(Result::kExclude, "excluded.site.test", "/"); inclusion_rules.AddUrlRuleIfValid(Result::kInclude, "included.site.test", "/"); proto::SessionInclusionRules proto = inclusion_rules.ToProto(); // Test for missing proto fields by clearing the fields one at a time. { proto::SessionInclusionRules p(proto); p.clear_origin(); EXPECT_FALSE(SessionInclusionRules::CreateFromProto(p)); } { proto::SessionInclusionRules p(proto); p.clear_do_include_site(); EXPECT_FALSE(SessionInclusionRules::CreateFromProto(p)); } // URL rules with missing parameters. { proto::SessionInclusionRules p(proto); p.mutable_url_rules(0)->clear_rule_type(); EXPECT_FALSE(SessionInclusionRules::CreateFromProto(p)); } { proto::SessionInclusionRules p(proto); p.mutable_url_rules(0)->clear_host_matcher_rule(); EXPECT_FALSE(SessionInclusionRules::CreateFromProto(p)); } { proto::SessionInclusionRules p(proto); p.mutable_url_rules(0)->clear_path_prefix(); EXPECT_FALSE(SessionInclusionRules::CreateFromProto(p)); } } } // namespace } // namespace net::device_bound_sessions