From 4f0cb8e9b16a270d0a7fd313f161b4c2c9a0df29 Mon Sep 17 00:00:00 2001 From: chasej Date: Thu, 13 Oct 2016 14:32:33 -0700 Subject: [PATCH] Support subdomain matching in trial tokens This CL adds support for subdomain trial tokens, such that a properly issued token will enable a trial for multiple subdomains, rather than a single specified origin. In bug 653349, it was proposed to use a leading "*" to indicate a wildcard token (e.g. *.example.com). In this implementation, an explicit flag was added to the token format. The explicit flag keeping the token parsing simple, by keeping the url::Origin as the representation of the origin, and avoiding custom string parsing. BUG=653349 Review-Url: https://codereview.chromium.org/2411803002 Cr-Commit-Position: refs/heads/master@{#425168} --- content/common/origin_trials/trial_token.cc | 17 ++- content/common/origin_trials/trial_token.h | 16 ++- .../origin_trials/trial_token_unittest.cc | 127 +++++++++++++++++- .../fuzzer_corpus/origin_trial_token_data/24 | 1 + .../fuzzer_corpus/origin_trial_token_data/25 | 6 + .../fuzzer_corpus/origin_trial_token_data/26 | 1 + .../fuzzer_corpus/origin_trial_token_data/27 | 1 + .../origin_trial_token_fuzzer.dict | 3 + tools/origin_trials/generate_token.py | 42 ++++-- 9 files changed, 194 insertions(+), 20 deletions(-) create mode 100644 content/test/data/fuzzer_corpus/origin_trial_token_data/24 create mode 100644 content/test/data/fuzzer_corpus/origin_trial_token_data/25 create mode 100644 content/test/data/fuzzer_corpus/origin_trial_token_data/26 create mode 100644 content/test/data/fuzzer_corpus/origin_trial_token_data/27 diff --git a/content/common/origin_trials/trial_token.cc b/content/common/origin_trials/trial_token.cc index 2935d9cc87642a..ae11999ee3a6e3 100644 --- a/content/common/origin_trials/trial_token.cc +++ b/content/common/origin_trials/trial_token.cc @@ -157,6 +157,14 @@ std::unique_ptr TrialToken::Parse( return nullptr; } + // The |isSubdomain| flag is optional. If found, ensure it is a valid boolean. + bool is_subdomain = false; + if (datadict->HasKey("isSubdomain")) { + if (!datadict->GetBoolean("isSubdomain", &is_subdomain)) { + return nullptr; + } + } + // Ensure that the feature name is a valid string. if (feature_name.empty()) { return nullptr; @@ -168,10 +176,15 @@ std::unique_ptr TrialToken::Parse( } return base::WrapUnique( - new TrialToken(origin, feature_name, expiry_timestamp)); + new TrialToken(origin, is_subdomain, feature_name, expiry_timestamp)); } bool TrialToken::ValidateOrigin(const url::Origin& origin) const { + if (match_subdomains_) { + return origin.scheme() == origin_.scheme() && + origin.DomainIs(origin_.host()) && + origin.port() == origin_.port(); + } return origin == origin_; } @@ -203,9 +216,11 @@ bool TrialToken::ValidateSignature(base::StringPiece signature, } TrialToken::TrialToken(const url::Origin& origin, + bool match_subdomains, const std::string& feature_name, uint64_t expiry_timestamp) : origin_(origin), + match_subdomains_(match_subdomains), feature_name_(feature_name), expiry_time_(base::Time::FromDoubleT(expiry_timestamp)) {} diff --git a/content/common/origin_trials/trial_token.h b/content/common/origin_trials/trial_token.h index 73eb3616f677fa..9432756394ab3f 100644 --- a/content/common/origin_trials/trial_token.h +++ b/content/common/origin_trials/trial_token.h @@ -21,14 +21,13 @@ enum class WebOriginTrialTokenStatus; namespace content { -// The Experimental Framework (EF) provides limited access to experimental -// features, on a per-origin basis (origin trials). This class defines the trial -// token data structure, used to securely provide access to an experimental -// feature. +// The Origin Trials Framework (OT) provides limited access to experimental +// features, on a per-origin basis. This class defines the trial token data +// structure, used to securely provide access to an experimental feature. // -// Features are defined by string names, provided by the implementers. The EF +// Features are defined by string names, provided by the implementers. The OT // code does not maintain an enum or constant list for feature names. Instead, -// the EF validates the name provided by the feature implementation against any +// it validates the name provided by the feature implementation against any // provided tokens. // // More documentation on the token format can be found at @@ -58,6 +57,7 @@ class CONTENT_EXPORT TrialToken { const base::Time& now) const; url::Origin origin() { return origin_; } + bool match_subdomains() const { return match_subdomains_; } std::string feature_name() { return feature_name_; } base::Time expiry_time() { return expiry_time_; } @@ -92,12 +92,16 @@ class CONTENT_EXPORT TrialToken { private: TrialToken(const url::Origin& origin, + bool match_subdomains, const std::string& feature_name, uint64_t expiry_timestamp); // The origin for which this token is valid. Must be a secure origin. url::Origin origin_; + // Indicates if the token should match all subdomains of the origin. + bool match_subdomains_; + // The name of the experimental feature which this token enables. std::string feature_name_; diff --git a/content/common/origin_trials/trial_token_unittest.cc b/content/common/origin_trials/trial_token_unittest.cc index 3c88134d1a5936..b5d396c63aad19 100644 --- a/content/common/origin_trials/trial_token_unittest.cc +++ b/content/common/origin_trials/trial_token_unittest.cc @@ -65,13 +65,39 @@ const char* kSampleToken = "eGFtcGxlLmNvbTo0NDMiLCAiZmVhdHVyZSI6ICJGcm9idWxhdGUiLCAiZXhwaXJ5" "IjogMTQ1ODc2NjI3N30="; +// This is a good subdomain trial token, signed with the above test private key. +// Generate this token with the command (in tools/origin_trials): +// generate_token.py example.com Frobulate --is-subdomain +// --expire-timestamp=1458766277 +const char* kSampleSubdomainToken = + "Auu+j9nXAQoy5+t00MiWakZwFExcdNC8ENkRdK1gL4OMFHS0AbZCscslDTcP1fjN" + "FjpbmQG+VCPk1NrldVXZng4AAABoeyJvcmlnaW4iOiAiaHR0cHM6Ly9leGFtcGxl" + "LmNvbTo0NDMiLCAiaXNTdWJkb21haW4iOiB0cnVlLCAiZmVhdHVyZSI6ICJGcm9i" + "dWxhdGUiLCAiZXhwaXJ5IjogMTQ1ODc2NjI3N30="; + +// This is a good trial token, explicitly not a subdomain, signed with the above +// test private key. Generate this token with the command: +// generate_token.py valid.example.com Frobulate --no-subdomain +// --expire-timestamp=1458766277 +const char* kSampleNonSubdomainToken = + "AreD979D7tO0luSZTr1+/+J6E0SSj/GEUyLK41o1hXFzXw1R7Z1hCDHs0gXWVSu1" + "lvH52Winvy39tHbsU2gJJQYAAABveyJvcmlnaW4iOiAiaHR0cHM6Ly92YWxpZC5l" + "eGFtcGxlLmNvbTo0NDMiLCAiaXNTdWJkb21haW4iOiBmYWxzZSwgImZlYXR1cmUi" + "OiAiRnJvYnVsYXRlIiwgImV4cGlyeSI6IDE0NTg3NjYyNzd9"; + const char* kExpectedFeatureName = "Frobulate"; const char* kExpectedOrigin = "https://valid.example.com"; +const char* kExpectedSubdomainOrigin = "https://example.com"; +const char* kExpectedMultipleSubdomainOrigin = + "https://part1.part2.part3.example.com"; const uint64_t kExpectedExpiry = 1458766277; // The token should not be valid for this origin, or for this feature. const char* kInvalidOrigin = "https://invalid.example.com"; const char* kInsecureOrigin = "http://valid.example.com"; +const char* kIncorrectPortOrigin = "https://valid.example.com:444"; +const char* kIncorrectDomainOrigin = "https://valid.example2.com"; +const char* kInvalidTLDOrigin = "https://com"; const char* kInvalidFeatureName = "Grokalyze"; // The token should be valid if the current time is kValidTimestamp or earlier. @@ -112,6 +138,14 @@ const char kSampleTokenJSON[] = "{\"origin\": \"https://valid.example.com:443\", \"feature\": " "\"Frobulate\", \"expiry\": 1458766277}"; +const char kSampleNonSubdomainTokenJSON[] = + "{\"origin\": \"https://valid.example.com:443\", \"isSubdomain\": false, " + "\"feature\": \"Frobulate\", \"expiry\": 1458766277}"; + +const char kSampleSubdomainTokenJSON[] = + "{\"origin\": \"https://example.com:443\", \"isSubdomain\": true, " + "\"feature\": \"Frobulate\", \"expiry\": 1458766277}"; + // Various ill-formed trial tokens. These should all fail to parse. const char* kInvalidTokens[] = { // Invalid - Not JSON at all @@ -126,13 +160,17 @@ const char* kInvalidTokens[] = { "{}", "{\"something\": 1}", "{\"origin\": \"https://a.a\"}", - "{\"origin\": \"https://a.a\", \"feature\": \"a\"}" + "{\"origin\": \"https://a.a\", \"feature\": \"a\"}", "{\"origin\": \"https://a.a\", \"expiry\": 1458766277}", "{\"feature\": \"FeatureName\", \"expiry\": 1458766277}", // Incorrect types "{\"origin\": 1, \"feature\": \"a\", \"expiry\": 1458766277}", "{\"origin\": \"https://a.a\", \"feature\": 1, \"expiry\": 1458766277}", "{\"origin\": \"https://a.a\", \"feature\": \"a\", \"expiry\": \"1\"}", + "{\"origin\": \"https://a.a\", \"isSubdomain\": \"true\", \"feature\": " + "\"a\", \"expiry\": 1458766277}", + "{\"origin\": \"https://a.a\", \"isSubdomain\": 1, \"feature\": \"a\", " + "\"expiry\": 1458766277}", // Negative expiry timestamp "{\"origin\": \"https://a.a\", \"feature\": \"a\", \"expiry\": -1}", // Origin not a proper origin URL @@ -140,7 +178,8 @@ const char* kInvalidTokens[] = { "{\"origin\": \"data:text/plain,abcdef\", \"feature\": \"a\", \"expiry\": " "1458766277}", "{\"origin\": \"javascript:alert(1)\", \"feature\": \"a\", \"expiry\": " - "1458766277}"}; + "1458766277}", +}; } // namespace @@ -148,8 +187,14 @@ class TrialTokenTest : public testing::TestWithParam { public: TrialTokenTest() : expected_origin_(GURL(kExpectedOrigin)), + expected_subdomain_origin_(GURL(kExpectedSubdomainOrigin)), + expected_multiple_subdomain_origin_( + GURL(kExpectedMultipleSubdomainOrigin)), invalid_origin_(GURL(kInvalidOrigin)), insecure_origin_(GURL(kInsecureOrigin)), + incorrect_port_origin_(GURL(kIncorrectPortOrigin)), + incorrect_domain_origin_(GURL(kIncorrectDomainOrigin)), + invalid_tld_origin_(GURL(kInvalidTLDOrigin)), expected_expiry_(base::Time::FromDoubleT(kExpectedExpiry)), valid_timestamp_(base::Time::FromDoubleT(kValidTimestamp)), invalid_timestamp_(base::Time::FromDoubleT(kInvalidTimestamp)), @@ -194,8 +239,13 @@ class TrialTokenTest : public testing::TestWithParam { base::StringPiece incorrect_public_key() { return incorrect_public_key_; } const url::Origin expected_origin_; + const url::Origin expected_subdomain_origin_; + const url::Origin expected_multiple_subdomain_origin_; const url::Origin invalid_origin_; const url::Origin insecure_origin_; + const url::Origin incorrect_port_origin_; + const url::Origin incorrect_domain_origin_; + const url::Origin invalid_tld_origin_; const base::Time expected_expiry_; const base::Time valid_timestamp_; @@ -220,6 +270,22 @@ TEST_F(TrialTokenTest, ValidateValidSignature) { EXPECT_STREQ(kSampleTokenJSON, token_payload.c_str()); } +TEST_F(TrialTokenTest, ValidateSubdomainValidSignature) { + std::string token_payload; + blink::WebOriginTrialTokenStatus status = + Extract(kSampleSubdomainToken, correct_public_key(), &token_payload); + ASSERT_EQ(blink::WebOriginTrialTokenStatus::Success, status); + EXPECT_STREQ(kSampleSubdomainTokenJSON, token_payload.c_str()); +} + +TEST_F(TrialTokenTest, ValidateNonSubdomainValidSignature) { + std::string token_payload; + blink::WebOriginTrialTokenStatus status = + Extract(kSampleNonSubdomainToken, correct_public_key(), &token_payload); + ASSERT_EQ(blink::WebOriginTrialTokenStatus::Success, status); + EXPECT_STREQ(kSampleNonSubdomainTokenJSON, token_payload.c_str()); +} + TEST_F(TrialTokenTest, ValidateInvalidSignature) { blink::WebOriginTrialTokenStatus status = ExtractIgnorePayload(kInvalidSignatureToken, correct_public_key()); @@ -274,16 +340,39 @@ TEST_F(TrialTokenTest, ParseValidToken) { std::unique_ptr token = Parse(kSampleTokenJSON); ASSERT_TRUE(token); EXPECT_EQ(kExpectedFeatureName, token->feature_name()); + EXPECT_FALSE(token->match_subdomains()); EXPECT_EQ(expected_origin_, token->origin()); EXPECT_EQ(expected_expiry_, token->expiry_time()); } +TEST_F(TrialTokenTest, ParseValidNonSubdomainToken) { + std::unique_ptr token = Parse(kSampleNonSubdomainTokenJSON); + ASSERT_TRUE(token); + EXPECT_EQ(kExpectedFeatureName, token->feature_name()); + EXPECT_FALSE(token->match_subdomains()); + EXPECT_EQ(expected_origin_, token->origin()); + EXPECT_EQ(expected_expiry_, token->expiry_time()); +} + +TEST_F(TrialTokenTest, ParseValidSubdomainToken) { + std::unique_ptr token = Parse(kSampleSubdomainTokenJSON); + ASSERT_TRUE(token); + EXPECT_EQ(kExpectedFeatureName, token->feature_name()); + EXPECT_TRUE(token->match_subdomains()); + EXPECT_EQ(kExpectedSubdomainOrigin, token->origin().Serialize()); + EXPECT_EQ(expected_subdomain_origin_, token->origin()); + EXPECT_EQ(expected_expiry_, token->expiry_time()); +} + TEST_F(TrialTokenTest, ValidateValidToken) { std::unique_ptr token = Parse(kSampleTokenJSON); ASSERT_TRUE(token); EXPECT_TRUE(ValidateOrigin(token.get(), expected_origin_)); EXPECT_FALSE(ValidateOrigin(token.get(), invalid_origin_)); EXPECT_FALSE(ValidateOrigin(token.get(), insecure_origin_)); + EXPECT_FALSE(ValidateOrigin(token.get(), incorrect_port_origin_)); + EXPECT_FALSE(ValidateOrigin(token.get(), incorrect_domain_origin_)); + EXPECT_FALSE(ValidateOrigin(token.get(), invalid_tld_origin_)); EXPECT_TRUE(ValidateFeatureName(token.get(), kExpectedFeatureName)); EXPECT_FALSE(ValidateFeatureName(token.get(), kInvalidFeatureName)); EXPECT_FALSE(ValidateFeatureName( @@ -294,6 +383,18 @@ TEST_F(TrialTokenTest, ValidateValidToken) { EXPECT_FALSE(ValidateDate(token.get(), invalid_timestamp_)); } +TEST_F(TrialTokenTest, ValidateValidSubdomainToken) { + std::unique_ptr token = Parse(kSampleSubdomainTokenJSON); + ASSERT_TRUE(token); + EXPECT_TRUE(ValidateOrigin(token.get(), expected_origin_)); + EXPECT_TRUE(ValidateOrigin(token.get(), expected_subdomain_origin_)); + EXPECT_TRUE(ValidateOrigin(token.get(), expected_multiple_subdomain_origin_)); + EXPECT_FALSE(ValidateOrigin(token.get(), insecure_origin_)); + EXPECT_FALSE(ValidateOrigin(token.get(), incorrect_port_origin_)); + EXPECT_FALSE(ValidateOrigin(token.get(), incorrect_domain_origin_)); + EXPECT_FALSE(ValidateOrigin(token.get(), invalid_tld_origin_)); +} + TEST_F(TrialTokenTest, TokenIsValid) { std::unique_ptr token = Parse(kSampleTokenJSON); ASSERT_TRUE(token); @@ -303,6 +404,28 @@ TEST_F(TrialTokenTest, TokenIsValid) { token->IsValid(invalid_origin_, valid_timestamp_)); EXPECT_EQ(blink::WebOriginTrialTokenStatus::WrongOrigin, token->IsValid(insecure_origin_, valid_timestamp_)); + EXPECT_EQ(blink::WebOriginTrialTokenStatus::WrongOrigin, + token->IsValid(incorrect_port_origin_, valid_timestamp_)); + EXPECT_EQ(blink::WebOriginTrialTokenStatus::Expired, + token->IsValid(expected_origin_, invalid_timestamp_)); +} + +TEST_F(TrialTokenTest, SubdomainTokenIsValid) { + std::unique_ptr token = Parse(kSampleSubdomainTokenJSON); + ASSERT_TRUE(token); + EXPECT_EQ(blink::WebOriginTrialTokenStatus::Success, + token->IsValid(expected_origin_, valid_timestamp_)); + EXPECT_EQ(blink::WebOriginTrialTokenStatus::Success, + token->IsValid(expected_subdomain_origin_, valid_timestamp_)); + EXPECT_EQ( + blink::WebOriginTrialTokenStatus::Success, + token->IsValid(expected_multiple_subdomain_origin_, valid_timestamp_)); + EXPECT_EQ(blink::WebOriginTrialTokenStatus::WrongOrigin, + token->IsValid(incorrect_domain_origin_, valid_timestamp_)); + EXPECT_EQ(blink::WebOriginTrialTokenStatus::WrongOrigin, + token->IsValid(insecure_origin_, valid_timestamp_)); + EXPECT_EQ(blink::WebOriginTrialTokenStatus::WrongOrigin, + token->IsValid(incorrect_port_origin_, valid_timestamp_)); EXPECT_EQ(blink::WebOriginTrialTokenStatus::Expired, token->IsValid(expected_origin_, invalid_timestamp_)); } diff --git a/content/test/data/fuzzer_corpus/origin_trial_token_data/24 b/content/test/data/fuzzer_corpus/origin_trial_token_data/24 new file mode 100644 index 00000000000000..8c374fe25679b9 --- /dev/null +++ b/content/test/data/fuzzer_corpus/origin_trial_token_data/24 @@ -0,0 +1 @@ +{"feature":"WebBluetooth","isSubdomain":true,origin":"https://example.com:443","expiry":1458766277} diff --git a/content/test/data/fuzzer_corpus/origin_trial_token_data/25 b/content/test/data/fuzzer_corpus/origin_trial_token_data/25 new file mode 100644 index 00000000000000..4f77080692d0f8 --- /dev/null +++ b/content/test/data/fuzzer_corpus/origin_trial_token_data/25 @@ -0,0 +1,6 @@ +{ + "feature": "WebBluetooth", + "isSubdomain": false, + "origin": "https://example.com:443", + "expiry": 1458766277 +} diff --git a/content/test/data/fuzzer_corpus/origin_trial_token_data/26 b/content/test/data/fuzzer_corpus/origin_trial_token_data/26 new file mode 100644 index 00000000000000..ea55826a918c93 --- /dev/null +++ b/content/test/data/fuzzer_corpus/origin_trial_token_data/26 @@ -0,0 +1 @@ +{"origin":"https://example.com:443","isSubdomain": 1,"something":"else","feature":"WebBluetooth","expiry":1458766277} diff --git a/content/test/data/fuzzer_corpus/origin_trial_token_data/27 b/content/test/data/fuzzer_corpus/origin_trial_token_data/27 new file mode 100644 index 00000000000000..4c20906ef09cd6 --- /dev/null +++ b/content/test/data/fuzzer_corpus/origin_trial_token_data/27 @@ -0,0 +1 @@ +{"origin":"https://example.com:443","isSubdomain":"something","feature":"WebBluetooth","expiry":1458766277} diff --git a/content/test/data/fuzzer_dictionaries/origin_trial_token_fuzzer.dict b/content/test/data/fuzzer_dictionaries/origin_trial_token_fuzzer.dict index 540558f242ddab..83315d652d2ec6 100644 --- a/content/test/data/fuzzer_dictionaries/origin_trial_token_fuzzer.dict +++ b/content/test/data/fuzzer_dictionaries/origin_trial_token_fuzzer.dict @@ -3,8 +3,11 @@ # found in the LICENSE file. "\"origin\"" +"\"isSubdomain\"" "\"feature\"" "\"expiry\"" "https://" "example.com" ":443" +"true" +"false" diff --git a/tools/origin_trials/generate_token.py b/tools/origin_trials/generate_token.py index 268f4567d19209..a79f169234b01b 100755 --- a/tools/origin_trials/generate_token.py +++ b/tools/origin_trials/generate_token.py @@ -8,6 +8,7 @@ usage: generate_token.py [-h] [--key-file KEY_FILE] [--expire-days EXPIRE_DAYS | --expire-timestamp EXPIRE_TIMESTAMP] + [--is_subdomain | --no-subdomain] origin trial_name Run "generate_token.py -h" for more help on usage. @@ -85,10 +86,13 @@ def ExpiryFromArgs(args): return int(args.expire_timestamp) return (int(time.time()) + (int(args.expire_days) * 86400)) -def GenerateTokenData(origin, api_name, expiry): - return json.dumps({"origin": origin, - "feature": api_name, - "expiry": expiry}).encode('utf-8') +def GenerateTokenData(origin, is_subdomain, feature_name, expiry): + data = {"origin": origin, + "feature": feature_name, + "expiry": expiry} + if is_subdomain is not None: + data["isSubdomain"] = is_subdomain + return json.dumps(data).encode('utf-8') def GenerateDataToSign(version, data): return version + struct.pack(">I",len(data)) + data @@ -104,11 +108,11 @@ def main(): default_key_file_absolute = os.path.join(script_dir, DEFAULT_KEY_FILE) parser = argparse.ArgumentParser( - description="Generate tokens for enabling experimental APIs") + description="Generate tokens for enabling experimental features") parser.add_argument("origin", - help="Origin for which to enable the API. This can be " - "either a hostname (default scheme HTTPS, default " - "port 443) or a URL.", + help="Origin for which to enable the feature. This can " + "be either a hostname (default scheme HTTPS, " + "default port 443) or a URL.", type=OriginFromArg) parser.add_argument("trial_name", help="Feature to enable. The current list of " @@ -117,14 +121,28 @@ def main(): parser.add_argument("--key-file", help="Ed25519 private key file to sign the token with", default=default_key_file_absolute) + + subdomain_group = parser.add_mutually_exclusive_group() + subdomain_group.add_argument("--is-subdomain", + help="Token will enable the feature for all " + "subdomains that match the origin", + dest="is_subdomain", + action="store_true") + subdomain_group.add_argument("--no-subdomain", + help="Token will only match the specified " + "origin (default behavior)", + dest="is_subdomain", + action="store_false") + parser.set_defaults(is_subdomain=None) + expiry_group = parser.add_mutually_exclusive_group() expiry_group.add_argument("--expire-days", - help="Days from now when the token should exipire", + help="Days from now when the token should expire", type=int, default=42) expiry_group.add_argument("--expire-timestamp", help="Exact time (seconds since 1970-01-01 " - "00:00:00 UTC) when the token should exipire", + "00:00:00 UTC) when the token should expire", type=int) args = parser.parse_args() @@ -141,7 +159,8 @@ def main(): print("Unable to use the specified private key file.") sys.exit(1) - token_data = GenerateTokenData(args.origin, args.trial_name, expiry) + token_data = GenerateTokenData(args.origin, args.is_subdomain, + args.trial_name, expiry) data_to_sign = GenerateDataToSign(VERSION, token_data) signature = Sign(private_key, data_to_sign) @@ -157,6 +176,7 @@ def main(): # Output the token details print "Token details:" print " Origin: %s" % args.origin + print " Is Subdomain: %s" % args.is_subdomain print " Feature: %s" % args.trial_name print " Expiry: %d (%s UTC)" % (expiry, datetime.utcfromtimestamp(expiry)) print