Implement `Sec-CH-UA-*` replacements for `User-Agent`.

This is a first pass that more or less completely implements the hints.
There's still a reasonable amount of work to be done around the edges,
like allowing devtools overrides, and getting the data delivered in the
new headers to be exactly what it ought to be. Small steps!

Intent to Implement: https://groups.google.com/a/chromium.org/d/msg/blink-dev/WQ0eC_Gf8bw/dhWMhCYYDwAJ

Bug: 928669
Change-Id: I36081a7864c9c40f6f46652477a32f5d90caaed9
Reviewed-on: https://chromium-review.googlesource.com/c/1469941
Reviewed-by: Kinuko Yasuda <kinuko@chromium.org>
Reviewed-by: Tarun Bansal <tbansal@chromium.org>
Reviewed-by: Yutaka Hirano <yhirano@chromium.org>
Reviewed-by: Yoav Weiss <yoavweiss@chromium.org>
Commit-Queue: Mike West <mkwst@chromium.org>
Cr-Commit-Position: refs/heads/master@{#632581}
diff --git a/chrome/browser/client_hints/client_hints.cc b/chrome/browser/client_hints/client_hints.cc
index a72ae92..09f73ed 100644
--- a/chrome/browser/client_hints/client_hints.cc
+++ b/chrome/browser/client_hints/client_hints.cc
@@ -12,8 +12,10 @@
 #include "base/metrics/field_trial_params.h"
 #include "base/rand_util.h"
 #include "base/strings/string_number_conversions.h"
+#include "base/strings/stringprintf.h"
 #include "build/build_config.h"
 #include "chrome/browser/browser_process.h"
+#include "chrome/browser/chrome_content_browser_client.h"
 #include "chrome/browser/content_settings/host_content_settings_map_factory.h"
 #include "chrome/browser/profiles/profile.h"
 #include "chrome/common/client_hints/client_hints.h"
@@ -24,6 +26,7 @@
 #include "content/public/browser/browser_context.h"
 #include "content/public/browser/browser_thread.h"
 #include "content/public/common/content_features.h"
+#include "content/public/common/content_switches.h"
 #include "content/public/common/origin_util.h"
 #include "net/base/url_util.h"
 #include "net/http/http_request_headers.h"
@@ -157,6 +160,12 @@
   return effective_connection_type;
 }
 
+bool UserAgentClientHintEnabled() {
+  return base::FeatureList::IsEnabled(features::kUserAgentClientHint) ||
+         base::CommandLine::ForCurrentProcess()->HasSwitch(
+             switches::kEnableExperimentalWebPlatformFeatures);
+}
+
 }  // namespace
 
 namespace client_hints {
@@ -378,12 +387,53 @@
             profile->GetPrefs()->GetString(language::prefs::kAcceptLanguages)));
   }
 
+  if (UserAgentClientHintEnabled()) {
+    blink::UserAgentMetadata ua = ::GetUserAgentMetadata();
+
+    // The `Sec-CH-UA` client hint is attached to all outgoing requests. The
+    // opt-in controls the header's value, not its presence. This is
+    // (intentionally) different than other client hints.
+    //
+    // https://tools.ietf.org/html/draft-west-ua-client-hints-00#section-2.4
+    additional_headers->SetHeader(
+        blink::kClientHintsHeaderMapping[static_cast<int>(
+            blink::mojom::WebClientHintsType::kUA)],
+        // TODO(mkwst): This should include only the major version if the
+        // recipient hasn't opted into the hint.
+        ua.version.empty() ? ua.brand.c_str()
+                           : base::StringPrintf("%s %s", ua.brand.c_str(),
+                                                ua.version.c_str()));
+
+    if (web_client_hints.IsEnabled(blink::mojom::WebClientHintsType::kUAArch)) {
+      additional_headers->SetHeader(
+          blink::kClientHintsHeaderMapping[static_cast<int>(
+              blink::mojom::WebClientHintsType::kUAArch)],
+          ua.architecture);
+    }
+
+    if (web_client_hints.IsEnabled(
+            blink::mojom::WebClientHintsType::kUAPlatform)) {
+      additional_headers->SetHeader(
+          blink::kClientHintsHeaderMapping[static_cast<int>(
+              blink::mojom::WebClientHintsType::kUAPlatform)],
+          ua.platform);
+    }
+
+    if (web_client_hints.IsEnabled(
+            blink::mojom::WebClientHintsType::kUAModel)) {
+      additional_headers->SetHeader(
+          blink::kClientHintsHeaderMapping[static_cast<int>(
+              blink::mojom::WebClientHintsType::kUAModel)],
+          ua.model);
+    }
+  }
+
   // Static assert that triggers if a new client hint header is added. If a
   // new client hint header is added, the following assertion should be updated.
   // If possible, logic should be added above so that the request headers for
   // the newly added client hint can be added to the request.
   static_assert(
-      blink::mojom::WebClientHintsType::kLang ==
+      blink::mojom::WebClientHintsType::kUAModel ==
           blink::mojom::WebClientHintsType::kMaxValue,
       "Consider adding client hint request headers from the browser process");
 
diff --git a/chrome/browser/client_hints/client_hints_browsertest.cc b/chrome/browser/client_hints/client_hints_browsertest.cc
index 39ba63c..e7ce545 100644
--- a/chrome/browser/client_hints/client_hints_browsertest.cc
+++ b/chrome/browser/client_hints/client_hints_browsertest.cc
@@ -4,6 +4,7 @@
 
 #include <cctype>
 
+#include "base/base_switches.h"
 #include "base/bind.h"
 #include "base/command_line.h"
 #include "base/metrics/field_trial_param_associator.h"
@@ -138,6 +139,7 @@
         https_cross_origin_server_(net::EmbeddedTestServer::TYPE_HTTPS),
         expect_client_hints_on_main_frame_(false),
         expect_client_hints_on_subresources_(false),
+        count_user_agent_hint_headers_seen_(0),
         count_client_hints_headers_seen_(0),
         request_interceptor_(nullptr) {
     http_server_.ServeFilesFromSourceDirectory("chrome/test/data/client_hints");
@@ -234,6 +236,17 @@
 
   ~ClientHintsBrowserTest() override {}
 
+  virtual std::unique_ptr<base::FeatureList> EnabledFeatures() {
+    std::unique_ptr<base::FeatureList> feature_list(new base::FeatureList);
+    feature_list->InitializeFromCommandLine("UserAgentClientHint", "");
+    return feature_list;
+  }
+
+  void SetUp() override {
+    scoped_feature_list_.InitWithFeatureList(EnabledFeatures());
+    InProcessBrowserTest::SetUp();
+  }
+
   void SetUpOnMainThread() override {
     host_resolver()->AddRule("*", "127.0.0.1");
 
@@ -369,6 +382,10 @@
 
   const GURL& redirect_url() const { return redirect_url_; }
 
+  size_t count_user_agent_hint_headers_seen() const {
+    return count_user_agent_hint_headers_seen_;
+  }
+
   size_t count_client_hints_headers_seen() const {
     return count_client_hints_headers_seen_;
   }
@@ -536,7 +553,12 @@
     for (size_t i = 0; i < blink::kClientHintsMappingsCount; ++i) {
       if (base::ContainsKey(request.headers,
                             blink::kClientHintsHeaderMapping[i])) {
-        count_client_hints_headers_seen_++;
+        // The user agent hint is special:
+        if (std::string(blink::kClientHintsHeaderMapping[i]) == "sec-ch-ua") {
+          count_user_agent_hint_headers_seen_++;
+        } else {
+          count_client_hints_headers_seen_++;
+        }
       }
     }
   }
@@ -550,6 +572,12 @@
       if (std::string(blink::kClientHintsHeaderMapping[i]) == "width") {
         continue;
       }
+
+      // `Sec-CH-UA` is attached on all requests.
+      if (std::string(blink::kClientHintsHeaderMapping[i]) == "sec-ch-ua") {
+        continue;
+      }
+
       EXPECT_EQ(expect_client_hints,
                 base::ContainsKey(request.headers,
                                   blink::kClientHintsHeaderMapping[i]));
@@ -650,6 +678,7 @@
   // Expect client hints on all the subresource requests.
   bool expect_client_hints_on_subresources_;
 
+  size_t count_user_agent_hint_headers_seen_;
   size_t count_client_hints_headers_seen_;
 
   std::unique_ptr<ThirdPartyURLLoaderInterceptor> request_interceptor_;
@@ -669,10 +698,11 @@
                          testing::Bool());
 
 class ClientHintsAllowThirdPartyBrowserTest : public ClientHintsBrowserTest {
-  void SetUpCommandLine(base::CommandLine* cmd) override {
-    scoped_feature_list_.InitFromCommandLine("AllowClientHintsToThirdParty",
-                                             "");
-    ClientHintsBrowserTest::SetUpCommandLine(cmd);
+  std::unique_ptr<base::FeatureList> EnabledFeatures() override {
+    std::unique_ptr<base::FeatureList> feature_list(new base::FeatureList);
+    feature_list->InitializeFromCommandLine(
+        "AllowClientHintsToThirdParty,UserAgentClientHint", "");
+    return feature_list;
   }
 };
 
@@ -708,8 +738,8 @@
   content::FetchHistogramsFromChildProcesses();
   SubprocessMetricsProvider::MergeHistogramDeltasForTesting();
 
-  // client_hints_url() sets seven client hints.
-  histogram_tester.ExpectUniqueSample("ClientHints.UpdateSize", 7, 1);
+  // client_hints_url() sets eleven client hints.
+  histogram_tester.ExpectUniqueSample("ClientHints.UpdateSize", 11, 1);
   // accept_ch_with_lifetime_url() sets client hints persist duration to 3600
   // seconds.
   histogram_tester.ExpectUniqueSample("ClientHints.PersistDuration",
@@ -764,9 +794,12 @@
   content::FetchHistogramsFromChildProcesses();
   SubprocessMetricsProvider::MergeHistogramDeltasForTesting();
 
-  // seven client hints are attached to the image request, and seven to the main
-  // frame request.
-  EXPECT_EQ(14u, count_client_hints_headers_seen());
+  // The user agent hint is attached to all three requests:
+  EXPECT_EQ(3u, count_user_agent_hint_headers_seen());
+
+  // Ten client hints are attached to the image request, and ten to the
+  // main frame request.
+  EXPECT_EQ(20u, count_client_hints_headers_seen());
 
   // Navigating to without_accept_ch_without_lifetime_img_foo_com() should not
   // attach client hints to the image subresouce contained in that page since
@@ -779,13 +812,15 @@
 
   // The device-memory and dprheader is attached to the main frame request.
 #if defined(OS_ANDROID)
-  EXPECT_EQ(7u, count_client_hints_headers_seen());
+  EXPECT_EQ(10u, count_client_hints_headers_seen());
 #else
-  EXPECT_EQ(21u, count_client_hints_headers_seen());
+  EXPECT_EQ(30u, count_client_hints_headers_seen());
 #endif
-  // Requests to third party servers should not have client hints attached.
+
+  // Requests to third party servers should have only one client hint attached
+  // (`Sec-CH-UA`).
   EXPECT_EQ(1u, third_party_request_count_seen());
-  EXPECT_EQ(0u, third_party_client_hints_count_seen());
+  EXPECT_EQ(1u, third_party_client_hints_count_seen());
 }
 
 // Test that client hints are attached to third party subresources if
@@ -805,7 +840,7 @@
   ui_test_utils::NavigateToURL(browser(), gurl);
   histogram_tester.ExpectTotalCount("ClientHints.UpdateEventCount", 0);
 
-  EXPECT_EQ(7u, count_client_hints_headers_seen());
+  EXPECT_EQ(11u, count_client_hints_headers_seen());
 
   // Requests to third party servers should not have client hints attached.
   EXPECT_EQ(1u, third_party_request_count_seen());
@@ -832,14 +867,16 @@
   ui_test_utils::NavigateToURL(browser(), gurl);
   histogram_tester.ExpectTotalCount("ClientHints.UpdateEventCount", 0);
 
-  EXPECT_EQ(7u, count_client_hints_headers_seen());
+  EXPECT_EQ(2u, count_user_agent_hint_headers_seen());
+  EXPECT_EQ(10u, count_client_hints_headers_seen());
 
   // Requests to third party servers should not have client hints attached.
   EXPECT_EQ(1u, third_party_request_count_seen());
 
   // Client hints should not be sent to the third-party when feature
-  // "AllowClientHintsToThirdParty" is not enabled.
-  EXPECT_EQ(0u, third_party_client_hints_count_seen());
+  // "AllowClientHintsToThirdParty" is not enabled, with the exception of the
+  // `Sec-CH-UA` hint, which is sent with every request.
+  EXPECT_EQ(1u, third_party_client_hints_count_seen());
 }
 
 // Loads a HTTPS webpage that does not request persisting of client hints.
@@ -1001,7 +1038,7 @@
   content::FetchHistogramsFromChildProcesses();
   SubprocessMetricsProvider::MergeHistogramDeltasForTesting();
 
-  histogram_tester.ExpectUniqueSample("ClientHints.UpdateSize", 7, 1);
+  histogram_tester.ExpectUniqueSample("ClientHints.UpdateSize", 11u, 1);
   // |gurl| sets client hints persist duration to 3600 seconds.
   histogram_tester.ExpectUniqueSample("ClientHints.PersistDuration",
                                       3600 * 1000, 1);
@@ -1019,9 +1056,12 @@
   ui_test_utils::NavigateToURL(browser(),
                                without_accept_ch_without_lifetime_local_url());
 
-  // seven client hints are attached to the image request, and seven to the main
-  // frame request.
-  EXPECT_EQ(14u, count_client_hints_headers_seen());
+  // The user agent hint is attached to all three requests:
+  EXPECT_EQ(3u, count_user_agent_hint_headers_seen());
+
+  // Ten client hints are attached to the image request, and ten to the
+  // main frame request.
+  EXPECT_EQ(20u, count_client_hints_headers_seen());
 }
 
 // Loads a webpage that does not request persisting of client hints.
@@ -1061,7 +1101,7 @@
   content::FetchHistogramsFromChildProcesses();
   SubprocessMetricsProvider::MergeHistogramDeltasForTesting();
 
-  histogram_tester.ExpectUniqueSample("ClientHints.UpdateSize", 7, 1);
+  histogram_tester.ExpectUniqueSample("ClientHints.UpdateSize", 11u, 1);
   // accept_ch_with_lifetime_url() sets client hints persist duration to 3600
   // seconds.
   histogram_tester.ExpectUniqueSample("ClientHints.PersistDuration",
@@ -1079,9 +1119,12 @@
   ui_test_utils::NavigateToURL(browser(),
                                without_accept_ch_without_lifetime_url());
 
-  // seven client hints are attached to the image request, and seven to the main
-  // frame request.
-  EXPECT_EQ(14u, count_client_hints_headers_seen());
+  // The user agent hint is attached to all three requests:
+  EXPECT_EQ(3u, count_user_agent_hint_headers_seen());
+
+  // Ten client hints are attached to the image request, and ten to the
+  // main frame request.
+  EXPECT_EQ(20u, count_client_hints_headers_seen());
 }
 
 // The test first fetches a page that sets Accept-CH-Lifetime. Next, it fetches
@@ -1111,7 +1154,7 @@
   content::FetchHistogramsFromChildProcesses();
   SubprocessMetricsProvider::MergeHistogramDeltasForTesting();
 
-  histogram_tester.ExpectUniqueSample("ClientHints.UpdateSize", 7, 1);
+  histogram_tester.ExpectUniqueSample("ClientHints.UpdateSize", 11u, 1);
   // accept_ch_with_lifetime_url() sets client hints persist duration to 3600
   // seconds.
   histogram_tester.ExpectUniqueSample("ClientHints.PersistDuration",
@@ -1128,9 +1171,12 @@
   SetClientHintExpectationsOnSubresources(true);
   ui_test_utils::NavigateToURL(browser(), redirect_url());
 
-  // seven client hints are attached to the image request, and seven to the main
-  // frame request.
-  EXPECT_EQ(14u, count_client_hints_headers_seen());
+  // The user agent hint is attached to all three requests:
+  EXPECT_EQ(3u, count_user_agent_hint_headers_seen());
+
+  // Ten client hints are attached to the image request, and ten to the
+  // main frame request.
+  EXPECT_EQ(20u, count_client_hints_headers_seen());
 }
 
 // Ensure that even when cookies are blocked, client hint preferences are
@@ -1182,7 +1228,7 @@
   content::FetchHistogramsFromChildProcesses();
   SubprocessMetricsProvider::MergeHistogramDeltasForTesting();
 
-  histogram_tester.ExpectUniqueSample("ClientHints.UpdateSize", 7, 1);
+  histogram_tester.ExpectUniqueSample("ClientHints.UpdateSize", 11u, 1);
   // |gurl_with| tries to set client hints persist duration to 3600 seconds.
   histogram_tester.ExpectUniqueSample("ClientHints.PersistDuration",
                                       3600 * 1000, 1);
@@ -1205,9 +1251,12 @@
   ui_test_utils::NavigateToURL(browser(),
                                without_accept_ch_without_lifetime_url());
 
-  // seven client hints are attached to the image request, and seven to the main
-  // frame request.
-  EXPECT_EQ(14u, count_client_hints_headers_seen());
+  // The user agent hint is attached to all three requests:
+  EXPECT_EQ(3u, count_user_agent_hint_headers_seen());
+
+  // Ten client hints are attached to the image request, and ten to the
+  // main frame request.
+  EXPECT_EQ(20u, count_client_hints_headers_seen());
 
   // Clear settings.
   HostContentSettingsMapFactory::GetForProfile(browser()->profile())
@@ -1278,8 +1327,9 @@
   histogram_tester.ExpectUniqueSample("ClientHints.UpdateEventCount", 1, 1);
   content::FetchHistogramsFromChildProcesses();
   SubprocessMetricsProvider::MergeHistogramDeltasForTesting();
+  EXPECT_EQ(1u, count_user_agent_hint_headers_seen());
 
-  histogram_tester.ExpectUniqueSample("ClientHints.UpdateSize", 7, 1);
+  histogram_tester.ExpectUniqueSample("ClientHints.UpdateSize", 11u, 1);
   // accept_ch_with_lifetime_url() tries to set client hints persist duration to
   // 3600 seconds.
   histogram_tester.ExpectUniqueSample("ClientHints.PersistDuration",
@@ -1301,6 +1351,7 @@
                                without_accept_ch_without_lifetime_url());
   EXPECT_EQ(0u, count_client_hints_headers_seen());
   VerifyContentSettingsNotNotified();
+  EXPECT_EQ(1u, count_user_agent_hint_headers_seen());
 
   // Allow the Javascript: Client hints should now be attached.
   HostContentSettingsMapFactory::GetForProfile(browser()->profile())
@@ -1313,9 +1364,12 @@
   ui_test_utils::NavigateToURL(browser(),
                                without_accept_ch_without_lifetime_url());
 
-  // seven client hints are attached to the image request, and seven to the main
-  // frame request.
-  EXPECT_EQ(14u, count_client_hints_headers_seen());
+  // The user agent hint is attached to all three requests:
+  EXPECT_EQ(3u, count_user_agent_hint_headers_seen());
+
+  // Ten client hints are attached to the image request, and ten to the
+  // main frame request.
+  EXPECT_EQ(20u, count_client_hints_headers_seen());
 
   // Clear settings.
   HostContentSettingsMapFactory::GetForProfile(browser()->profile())
@@ -1346,6 +1400,7 @@
           CONTENT_SETTING_BLOCK);
   ui_test_utils::NavigateToURL(browser(),
                                accept_ch_without_lifetime_img_localhost());
+  EXPECT_EQ(0u, count_user_agent_hint_headers_seen());
   EXPECT_EQ(0u, count_client_hints_headers_seen());
   EXPECT_EQ(1u, third_party_request_count_seen());
   EXPECT_EQ(0u, third_party_client_hints_count_seen());
@@ -1361,9 +1416,10 @@
   ui_test_utils::NavigateToURL(browser(),
                                accept_ch_without_lifetime_img_localhost());
 
-  EXPECT_EQ(7u, count_client_hints_headers_seen());
+  EXPECT_EQ(2u, count_user_agent_hint_headers_seen());
+  EXPECT_EQ(10u, count_client_hints_headers_seen());
   EXPECT_EQ(2u, third_party_request_count_seen());
-  EXPECT_EQ(0u, third_party_client_hints_count_seen());
+  EXPECT_EQ(1u, third_party_client_hints_count_seen());
   VerifyContentSettingsNotNotified();
 
   // Clear settings.
@@ -1379,9 +1435,10 @@
           CONTENT_SETTING_BLOCK);
   ui_test_utils::NavigateToURL(browser(),
                                accept_ch_without_lifetime_img_localhost());
-  EXPECT_EQ(7u, count_client_hints_headers_seen());
+  EXPECT_EQ(2u, count_user_agent_hint_headers_seen());
+  EXPECT_EQ(10u, count_client_hints_headers_seen());
   EXPECT_EQ(3u, third_party_request_count_seen());
-  EXPECT_EQ(0u, third_party_client_hints_count_seen());
+  EXPECT_EQ(1u, third_party_client_hints_count_seen());
 
   // Clear settings.
   HostContentSettingsMapFactory::GetForProfile(browser()->profile())
@@ -1415,9 +1472,10 @@
 
   SetClientHintExpectationsOnSubresources(true);
   ui_test_utils::NavigateToURL(browser(), gurl);
-  EXPECT_EQ(7u, count_client_hints_headers_seen());
+  EXPECT_EQ(2u, count_user_agent_hint_headers_seen());
+  EXPECT_EQ(10u, count_client_hints_headers_seen());
   EXPECT_EQ(1u, third_party_request_count_seen());
-  EXPECT_EQ(0u, third_party_client_hints_count_seen());
+  EXPECT_EQ(1u, third_party_client_hints_count_seen());
 
   // Clear settings.
   HostContentSettingsMapFactory::GetForProfile(browser()->profile())
@@ -1448,7 +1506,7 @@
   content::FetchHistogramsFromChildProcesses();
   SubprocessMetricsProvider::MergeHistogramDeltasForTesting();
 
-  histogram_tester.ExpectUniqueSample("ClientHints.UpdateSize", 7, 1);
+  histogram_tester.ExpectUniqueSample("ClientHints.UpdateSize", 11u, 1);
   // accept_ch_with_lifetime_url() sets client hints persist duration to 3600
   // seconds.
   histogram_tester.ExpectUniqueSample("ClientHints.PersistDuration",
@@ -1466,9 +1524,12 @@
   ui_test_utils::NavigateToURL(incognito,
                                without_accept_ch_without_lifetime_url());
 
-  // Seven client hints are attached to the image request, and seven to the main
-  // frame request.
-  EXPECT_EQ(14u, count_client_hints_headers_seen());
+  // The user agent hint is attached to all three requests:
+  EXPECT_EQ(3u, count_user_agent_hint_headers_seen());
+
+  // Ten client hints are attached to the image request, and ten to the
+  // main frame request.
+  EXPECT_EQ(20u, count_client_hints_headers_seen());
 
   // Navigate using regular profile. Client hints should not be send.
   SetClientHintExpectationsOnMainFrame(false);
@@ -1476,9 +1537,11 @@
   ui_test_utils::NavigateToURL(browser(),
                                without_accept_ch_without_lifetime_url());
 
-  // Seven client hints are attached to the image request, and seven to the main
-  // frame request.
-  EXPECT_EQ(14u, count_client_hints_headers_seen());
+  // The user agent hint is attached to the two new requests.
+  EXPECT_EQ(5u, count_user_agent_hint_headers_seen());
+
+  // No additional hints are sent.
+  EXPECT_EQ(20u, count_client_hints_headers_seen());
 }
 
 class ClientHintsWebHoldbackBrowserTest : public ClientHintsBrowserTest {
@@ -1491,8 +1554,7 @@
     return web_effective_connection_type_override_;
   }
 
- private:
-  void ConfigureHoldbackExperiment() {
+  std::unique_ptr<base::FeatureList> EnabledFeatures() override {
     base::FieldTrialParamAssociator::GetInstance()->ClearAllParamsForTesting();
     const std::string kTrialName = "TrialFoo";
     const std::string kGroupName = "GroupFoo";  // Value not used
@@ -1505,17 +1567,21 @@
     params["web_effective_connection_type_override"] =
         net::GetNameForEffectiveConnectionType(
             web_effective_connection_type_override_);
-    ASSERT_TRUE(
+    EXPECT_TRUE(
         base::FieldTrialParamAssociator::GetInstance()
             ->AssociateFieldTrialParams(kTrialName, kGroupName, params));
 
     std::unique_ptr<base::FeatureList> feature_list(new base::FeatureList);
+    feature_list->InitializeFromCommandLine("UserAgentClientHint", "");
     feature_list->RegisterFieldTrialOverride(
         features::kNetworkQualityEstimatorWebHoldback.name,
         base::FeatureList::OVERRIDE_ENABLE_FEATURE, trial.get());
-    scoped_feature_list_override_.InitWithFeatureList(std::move(feature_list));
+    return feature_list;
   }
 
+ private:
+  void ConfigureHoldbackExperiment() {}
+
   const net::EffectiveConnectionType web_effective_connection_type_override_ =
       net::EFFECTIVE_CONNECTION_TYPE_3G;
 
@@ -1548,7 +1614,8 @@
   content::FetchHistogramsFromChildProcesses();
   SubprocessMetricsProvider::MergeHistogramDeltasForTesting();
 
-  EXPECT_EQ(14u, count_client_hints_headers_seen());
+  EXPECT_EQ(3u, count_user_agent_hint_headers_seen());
+  EXPECT_EQ(20u, count_client_hints_headers_seen());
   EXPECT_EQ(0u, third_party_request_count_seen());
   EXPECT_EQ(0u, third_party_client_hints_count_seen());
 }
diff --git a/chrome/test/data/client_hints/accept_ch_with_lifetime.html.mock-http-headers b/chrome/test/data/client_hints/accept_ch_with_lifetime.html.mock-http-headers
index 6e22fe1..93ad1f1 100644
--- a/chrome/test/data/client_hints/accept_ch_with_lifetime.html.mock-http-headers
+++ b/chrome/test/data/client_hints/accept_ch_with_lifetime.html.mock-http-headers
@@ -1,3 +1,3 @@
 HTTP/1.1 200 OK
-Accept-CH: dpr,device-memory,viewport-width,rtt,downlink,ect,lang
+Accept-CH: dpr,device-memory,viewport-width,rtt,downlink,ect,lang,ua,arch,platform,model
 Accept-CH-Lifetime: 3600
diff --git a/chrome/test/data/client_hints/accept_ch_without_lifetime.html.mock-http-headers b/chrome/test/data/client_hints/accept_ch_without_lifetime.html.mock-http-headers
index 0234b8c..9c3216a 100644
--- a/chrome/test/data/client_hints/accept_ch_without_lifetime.html.mock-http-headers
+++ b/chrome/test/data/client_hints/accept_ch_without_lifetime.html.mock-http-headers
@@ -1,2 +1,2 @@
 HTTP/1.1 200 OK
-Accept-CH: dpr,device-memory,viewport-width,rtt,downlink,ect,lang
+Accept-CH: dpr,device-memory,viewport-width,rtt,downlink,ect,lang,ua,arch,platform,model
diff --git a/chrome/test/data/client_hints/accept_ch_without_lifetime_img_localhost.html.mock-http-headers b/chrome/test/data/client_hints/accept_ch_without_lifetime_img_localhost.html.mock-http-headers
index 0234b8c..9c3216a 100644
--- a/chrome/test/data/client_hints/accept_ch_without_lifetime_img_localhost.html.mock-http-headers
+++ b/chrome/test/data/client_hints/accept_ch_without_lifetime_img_localhost.html.mock-http-headers
@@ -1,2 +1,2 @@
 HTTP/1.1 200 OK
-Accept-CH: dpr,device-memory,viewport-width,rtt,downlink,ect,lang
+Accept-CH: dpr,device-memory,viewport-width,rtt,downlink,ect,lang,ua,arch,platform,model
diff --git a/chrome/test/data/client_hints/http_equiv_accept_ch_with_lifetime.html b/chrome/test/data/client_hints/http_equiv_accept_ch_with_lifetime.html
index d3c6d8c..bdb172a 100644
--- a/chrome/test/data/client_hints/http_equiv_accept_ch_with_lifetime.html
+++ b/chrome/test/data/client_hints/http_equiv_accept_ch_with_lifetime.html
@@ -1,5 +1,5 @@
 <html>
-<meta http-equiv="Accept-CH" content="dpr,device-memory,viewport-width,rtt,downlink,ect,lang">
+<meta http-equiv="Accept-CH" content="dpr,device-memory,viewport-width,rtt,downlink,ect,lang,ua,arch,platform,model">
 <meta http-equiv="Accept-CH-Lifetime" content="3600">
 <link rel="icon" href="data:;base64,=">
 </html>
diff --git a/chrome/test/data/client_hints/http_equiv_accept_ch_without_lifetime.html b/chrome/test/data/client_hints/http_equiv_accept_ch_without_lifetime.html
index 19f02f1..6d77646bd 100644
--- a/chrome/test/data/client_hints/http_equiv_accept_ch_without_lifetime.html
+++ b/chrome/test/data/client_hints/http_equiv_accept_ch_without_lifetime.html
@@ -1,5 +1,5 @@
 <html>
-<meta http-equiv="Accept-CH" content="dpr,device-memory,viewport-width,rtt,downlink,ect,lang">
+<meta http-equiv="Accept-CH" content="dpr,device-memory,viewport-width,rtt,downlink,ect,lang,ua,arch,platform,model">
 <link rel="icon" href="data:;base64,=">
 <head></head>
 Empty file which uses link-rel to disable favicon fetches. The corresponding
diff --git a/chrome/test/data/client_hints/http_equiv_accept_ch_without_lifetime_img_localhost.html b/chrome/test/data/client_hints/http_equiv_accept_ch_without_lifetime_img_localhost.html
index 02e61fa..9c7ae7b2 100644
--- a/chrome/test/data/client_hints/http_equiv_accept_ch_without_lifetime_img_localhost.html
+++ b/chrome/test/data/client_hints/http_equiv_accept_ch_without_lifetime_img_localhost.html
@@ -1,5 +1,5 @@
 <html>
-<meta http-equiv="Accept-CH" content="dpr,device-memory,viewport-width,rtt,downlink,ect,lang">
+<meta http-equiv="Accept-CH" content="dpr,device-memory,viewport-width,rtt,downlink,ect,lang,ua,arch,platform,model">
 <link rel="icon" href="data:;base64,=">
 <head></head>
 Empty file which uses link-rel to disable favicon fetches. The corresponding
diff --git a/content/child/runtime_features.cc b/content/child/runtime_features.cc
index 8b61441..9d9590e 100644
--- a/content/child/runtime_features.cc
+++ b/content/child/runtime_features.cc
@@ -484,6 +484,9 @@
   WebRuntimeFeatures::EnableMimeHandlerViewInCrossProcessFrame(
       base::FeatureList::IsEnabled(
           features::kMimeHandlerViewInCrossProcessFrame));
+
+  if (base::FeatureList::IsEnabled(features::kUserAgentClientHint))
+    WebRuntimeFeatures::EnableFeatureFromString("UserAgentClientHint", true);
 }
 
 }  // namespace
diff --git a/content/public/common/content_features.cc b/content/public/common/content_features.cc
index 8f69d78..cb2213e 100644
--- a/content/public/common/content_features.cc
+++ b/content/public/common/content_features.cc
@@ -498,6 +498,11 @@
 const base::Feature kUserActivationV2{"UserActivationV2",
                                       base::FEATURE_ENABLED_BY_DEFAULT};
 
+// An experimental replacement for the `User-Agent` header, defined in
+// https://tools.ietf.org/html/draft-west-ua-client-hints.
+const base::Feature kUserAgentClientHint{"UserAgentClientHint",
+                                         base::FEATURE_DISABLED_BY_DEFAULT};
+
 // Enables V8's low memory mode for subframes. This is used only
 // in conjunction with the --site-per-process feature.
 const base::Feature kV8LowMemoryModeForSubframes{
diff --git a/content/public/common/content_features.h b/content/public/common/content_features.h
index b57a897..9a957af 100644
--- a/content/public/common/content_features.h
+++ b/content/public/common/content_features.h
@@ -120,6 +120,7 @@
 CONTENT_EXPORT extern const base::Feature kTouchpadOverscrollHistoryNavigation;
 CONTENT_EXPORT extern const base::Feature kUserActivationSameOriginVisibility;
 CONTENT_EXPORT extern const base::Feature kUserActivationV2;
+CONTENT_EXPORT extern const base::Feature kUserAgentClientHint;
 CONTENT_EXPORT extern const base::Feature kV8LowMemoryModeForSubframes;
 CONTENT_EXPORT extern const base::Feature kV8Orinoco;
 CONTENT_EXPORT extern const base::Feature kV8VmFuture;
diff --git a/services/network/public/cpp/cors/cors.cc b/services/network/public/cpp/cors/cors.cc
index 29d5ec5..c84c25b 100644
--- a/services/network/public/cpp/cors/cors.cc
+++ b/services/network/public/cpp/cors/cors.cc
@@ -359,21 +359,38 @@
   // Treat 'Intervention' as a CORS-safelisted header, since it is added by
   // Chrome when an intervention is (or may be) applied.
   static const char* const safe_names[] = {
-      "accept", "accept-language", "content-language", "intervention",
-      "content-type", "save-data",
+      "accept",
+      "accept-language",
+      "content-language",
+      "intervention",
+      "content-type",
+      "save-data",
       // The Device Memory header field is a number that indicates the client’s
       // device memory i.e. approximate amount of ram in GiB. The header value
       // must satisfy ABNF  1*DIGIT [ "." 1*DIGIT ]
       // See
       // https://w3c.github.io/device-memory/#sec-device-memory-client-hint-header
       // for more details.
-      "device-memory", "dpr", "width", "viewport-width",
+      "device-memory",
+      "dpr",
+      "width",
+      "viewport-width",
 
       // The `Sec-CH-Lang` header field is a proposed replacement for
       // `Accept-Language`, using the Client Hints infrastructure.
       //
       // https://tools.ietf.org/html/draft-west-lang-client-hint
-      "sec-ch-lang"};
+      "sec-ch-lang",
+
+      // The `Sec-CH-UA-*` header fields are proposed replacements for
+      // `User-Agent`, using the Client Hints infrastructure.
+      //
+      // https://tools.ietf.org/html/draft-west-ua-client-hints
+      "sec-ch-ua",
+      "sec-ch-ua-platform",
+      "sec-ch-ua-arch",
+      "sec-ch-ua-model",
+  };
   const std::string lower_name = base::ToLowerASCII(name);
   if (std::find(std::begin(safe_names), std::end(safe_names), lower_name) ==
       std::end(safe_names))
diff --git a/services/network/public/cpp/cors/cors_unittest.cc b/services/network/public/cpp/cors/cors_unittest.cc
index 2a24891..2fdda5d 100644
--- a/services/network/public/cpp/cors/cors_unittest.cc
+++ b/services/network/public/cpp/cors/cors_unittest.cc
@@ -386,6 +386,16 @@
   // https://crbug.com/924969
 }
 
+TEST_F(CorsTest, SafelistedSecCHUA) {
+  EXPECT_TRUE(IsCorsSafelistedHeader("Sec-CH-UA", "\"User Agent!\""));
+  EXPECT_TRUE(IsCorsSafelistedHeader("Sec-CH-UA-Platform", "\"Platform!\""));
+  EXPECT_TRUE(IsCorsSafelistedHeader("Sec-CH-UA-Arch", "\"Architecture!\""));
+  EXPECT_TRUE(IsCorsSafelistedHeader("Sec-CH-UA-Model", "\"Model!\""));
+
+  // TODO(mkwst): Validate that `Sec-CH-UA-*` is a structured header.
+  // https://crbug.com/924969
+}
+
 TEST_F(CorsTest, SafelistedContentLanguage) {
   EXPECT_TRUE(IsCorsSafelistedHeader("content-language", "en,ja"));
   EXPECT_TRUE(IsCorsSafelistedHeader("cONTent-LANguaGe", "en,ja"));
diff --git a/third_party/blink/common/client_hints/client_hints.cc b/third_party/blink/common/client_hints/client_hints.cc
index 5777f6c..d6a2568 100644
--- a/third_party/blink/common/client_hints/client_hints.cc
+++ b/third_party/blink/common/client_hints/client_hints.cc
@@ -10,12 +10,24 @@
 namespace blink {
 
 const char* const kClientHintsNameMapping[] = {
-    "device-memory", "dpr",      "width", "viewport-width",
-    "rtt",           "downlink", "ect",   "lang"};
+    "device-memory", "dpr",  "width", "viewport-width", "rtt",      "downlink",
+    "ect",           "lang", "ua",    "arch",           "platform", "model",
+};
 
 const char* const kClientHintsHeaderMapping[] = {
-    "device-memory", "dpr",      "width", "viewport-width",
-    "rtt",           "downlink", "ect",   "sec-ch-lang"};
+    "device-memory",
+    "dpr",
+    "width",
+    "viewport-width",
+    "rtt",
+    "downlink",
+    "ect",
+    "sec-ch-lang",
+    "sec-ch-ua",
+    "sec-ch-ua-arch",
+    "sec-ch-ua-platform",
+    "sec-ch-ua-model",
+};
 
 const size_t kClientHintsMappingsCount = base::size(kClientHintsNameMapping);
 
diff --git a/third_party/blink/public/platform/web_client_hints_types.mojom b/third_party/blink/public/platform/web_client_hints_types.mojom
index 3a41a1c..4f9a2b1 100644
--- a/third_party/blink/public/platform/web_client_hints_types.mojom
+++ b/third_party/blink/public/platform/web_client_hints_types.mojom
@@ -24,6 +24,10 @@
   kDownlink = 5,
   kEct = 6,
   kLang = 7,
+  kUA = 8,
+  kUAArch = 9,
+  kUAPlatform = 10,
+  kUAModel = 11,
 
   // Warning: Before adding a new client hint, read the warning at the top.
 };
diff --git a/third_party/blink/public/platform/web_feature.mojom b/third_party/blink/public/platform/web_feature.mojom
index d4cb9fd..cc6c1d5 100644
--- a/third_party/blink/public/platform/web_feature.mojom
+++ b/third_party/blink/public/platform/web_feature.mojom
@@ -2225,6 +2225,10 @@
   kV8UserActivation_IsActive_AttributeGetter = 2786,
   kTextEncoderEncodeInto = 2787,
   kInvalidBasicCardMethodData = 2788,
+  kClientHintsUA = 2789,
+  kClientHintsUAArch = 2790,
+  kClientHintsUAPlatform = 2791,
+  kClientHintsUAModel = 2792,
 
   // Add new features immediately above this line. Don't change assigned
   // numbers of any item, and don't reuse removed slots.
diff --git a/third_party/blink/renderer/core/frame/navigator.h b/third_party/blink/renderer/core/frame/navigator.h
index 3c049fc..16ed6e3 100644
--- a/third_party/blink/renderer/core/frame/navigator.h
+++ b/third_party/blink/renderer/core/frame/navigator.h
@@ -71,8 +71,12 @@
 
   String GetAcceptLanguages() override;
   UserAgentMetadata GetUserAgentMetadata() const override;
+  void SetUserAgentMetadataForTesting(UserAgentMetadata);
 
   void Trace(blink::Visitor*) override;
+
+ private:
+  UserAgentMetadata metadata_;
 };
 
 }  // namespace blink
diff --git a/third_party/blink/renderer/core/loader/frame_fetch_context.cc b/third_party/blink/renderer/core/loader/frame_fetch_context.cc
index 7269fca..812e49a 100644
--- a/third_party/blink/renderer/core/loader/frame_fetch_context.cc
+++ b/third_party/blink/renderer/core/loader/frame_fetch_context.cc
@@ -106,6 +106,7 @@
 #include "third_party/blink/renderer/platform/network/network_state_notifier.h"
 #include "third_party/blink/renderer/platform/network/network_utils.h"
 #include "third_party/blink/renderer/platform/weborigin/scheme_registry.h"
+#include "third_party/blink/renderer/platform/wtf/text/string_builder.h"
 #include "third_party/blink/renderer/platform/wtf/vector.h"
 
 namespace blink {
@@ -180,6 +181,7 @@
               const ClientHintsPreferences& client_hints_preferences,
               float device_pixel_ratio,
               const String& user_agent,
+              const UserAgentMetadata& user_agent_metadata,
               bool is_svg_image_chrome_client)
       : url(url),
         parent_security_origin(std::move(parent_security_origin)),
@@ -189,6 +191,7 @@
         client_hints_preferences(client_hints_preferences),
         device_pixel_ratio(device_pixel_ratio),
         user_agent(user_agent),
+        user_agent_metadata(user_agent_metadata),
         is_svg_image_chrome_client(is_svg_image_chrome_client) {}
 
   const KURL url;
@@ -199,6 +202,7 @@
   const ClientHintsPreferences client_hints_preferences;
   const float device_pixel_ratio;
   const String user_agent;
+  const UserAgentMetadata user_agent_metadata;
   const bool is_svg_image_chrome_client;
 
   void Trace(blink::Visitor* visitor) {
@@ -723,6 +727,33 @@
   if (!AllowScriptFromSourceWithoutNotifying(request.Url()))
     return;
 
+  // Sec-CH-UA is special: we always send the header to all origins that are
+  // eligible for client hints (e.g. secure transport, JavaScript enabled). We
+  // alter the header's value based on whether or not the site has opted into
+  // additional detail.
+  //
+  // https://github.com/WICG/ua-client-hints
+  blink::UserAgentMetadata ua = GetUserAgentMetadata();
+  if (RuntimeEnabledFeatures::UserAgentClientHintEnabled()) {
+    StringBuilder result;
+    result.Append(ua.brand.data());
+    if (!ua.version.empty()) {
+      result.Append(' ');
+      if (ShouldSendClientHint(mojom::WebClientHintsType::kUA,
+                               hints_preferences, enabled_hints)) {
+        result.Append(ua.version.data());
+      } else {
+        // TODO(mkwst): This should only send the major version, but we haven't
+        // piped that through yet.
+        result.Append(ua.version.data());
+      }
+    }
+    request.AddHTTPHeaderField(
+        blink::kClientHintsHeaderMapping[static_cast<size_t>(
+            mojom::WebClientHintsType::kUA)],
+        result.ToAtomicString());
+  }
+
   bool is_1p_origin = IsFirstPartyOrigin(request.Url());
 
   if (!base::FeatureList::IsEnabled(kAllowClientHintsToThirdParty) &&
@@ -830,6 +861,30 @@
             ->navigator()
             ->SerializeLanguagesForClientHintHeader());
   }
+
+  if (ShouldSendClientHint(mojom::WebClientHintsType::kUAArch,
+                           hints_preferences, enabled_hints)) {
+    request.AddHTTPHeaderField(
+        blink::kClientHintsHeaderMapping[static_cast<size_t>(
+            mojom::WebClientHintsType::kUAArch)],
+        AtomicString(ua.architecture.data()));
+  }
+
+  if (ShouldSendClientHint(mojom::WebClientHintsType::kUAPlatform,
+                           hints_preferences, enabled_hints)) {
+    request.AddHTTPHeaderField(
+        blink::kClientHintsHeaderMapping[static_cast<size_t>(
+            mojom::WebClientHintsType::kUAPlatform)],
+        AtomicString(ua.platform.data()));
+  }
+
+  if (ShouldSendClientHint(mojom::WebClientHintsType::kUAModel,
+                           hints_preferences, enabled_hints)) {
+    request.AddHTTPHeaderField(
+        blink::kClientHintsHeaderMapping[static_cast<size_t>(
+            mojom::WebClientHintsType::kUAModel)],
+        AtomicString(ua.model.data()));
+  }
 }
 
 void FrameFetchContext::PopulateResourceRequest(
@@ -1072,6 +1127,12 @@
   return GetFrame()->Loader().UserAgent();
 }
 
+UserAgentMetadata FrameFetchContext::GetUserAgentMetadata() const {
+  if (GetResourceFetcherProperties().IsDetached())
+    return frozen_state_->user_agent_metadata;
+  return GetLocalFrameClient()->UserAgentMetadata();
+}
+
 const ClientHintsPreferences FrameFetchContext::GetClientHintsPreferences()
     const {
   if (GetResourceFetcherProperties().IsDetached())
@@ -1114,13 +1175,15 @@
     frozen_state_ = MakeGarbageCollected<FrozenState>(
         Url(), GetParentSecurityOrigin(), GetContentSecurityPolicy(),
         GetSiteForCookies(), GetTopFrameOrigin(), GetClientHintsPreferences(),
-        GetDevicePixelRatio(), GetUserAgent(), IsSVGImageChromeClient());
+        GetDevicePixelRatio(), GetUserAgent(), GetUserAgentMetadata(),
+        IsSVGImageChromeClient());
   } else {
     // Some getters are unavailable in this case.
     frozen_state_ = MakeGarbageCollected<FrozenState>(
         NullURL(), GetParentSecurityOrigin(), GetContentSecurityPolicy(),
         GetSiteForCookies(), GetTopFrameOrigin(), GetClientHintsPreferences(),
-        GetDevicePixelRatio(), GetUserAgent(), IsSVGImageChromeClient());
+        GetDevicePixelRatio(), GetUserAgent(), GetUserAgentMetadata(),
+        IsSVGImageChromeClient());
   }
 
   frame_or_imported_document_ = nullptr;
diff --git a/third_party/blink/renderer/core/loader/frame_fetch_context.h b/third_party/blink/renderer/core/loader/frame_fetch_context.h
index b756382..6f9d676 100644
--- a/third_party/blink/renderer/core/loader/frame_fetch_context.h
+++ b/third_party/blink/renderer/core/loader/frame_fetch_context.h
@@ -60,6 +60,7 @@
 class ResourceResponse;
 class Settings;
 class WebContentSettingsClient;
+struct UserAgentMetadata;
 struct WebEnabledClientHints;
 
 class CORE_EXPORT FrameFetchContext final : public BaseFetchContext {
@@ -204,6 +205,7 @@
   WebContentSettingsClient* GetContentSettingsClient() const;
   Settings* GetSettings() const;
   String GetUserAgent() const;
+  UserAgentMetadata GetUserAgentMetadata() const;
   const ClientHintsPreferences GetClientHintsPreferences() const;
   float GetDevicePixelRatio() const;
   bool ShouldSendClientHint(mojom::WebClientHintsType,
diff --git a/third_party/blink/renderer/core/loader/frame_fetch_context_test.cc b/third_party/blink/renderer/core/loader/frame_fetch_context_test.cc
index b2f9d7d..bb44e2e 100644
--- a/third_party/blink/renderer/core/loader/frame_fetch_context_test.cc
+++ b/third_party/blink/renderer/core/loader/frame_fetch_context_test.cc
@@ -643,6 +643,7 @@
                     bool is_present,
                     const char* header_value,
                     float width = 0) {
+    SCOPED_TRACE(testing::Message() << header_name);
     ClientHintsPreferences hints_preferences;
 
     FetchParameters::ResourceWidth resource_width;
@@ -822,6 +823,69 @@
   ExpectHeader("http://www.example.com/1.gif", "Sec-CH-Lang", false, "");
 }
 
+TEST_F(FrameFetchContextHintsTest, MonitorUAHints) {
+  // `Sec-CH-UA` is always sent for secure requests
+  ExpectHeader("https://www.example.com/1.gif", "Sec-CH-UA", true, "");
+  ExpectHeader("http://www.example.com/1.gif", "Sec-CH-UA", false, "");
+
+  // `Sec-CH-UA-*` requires opt-in.
+  ExpectHeader("https://www.example.com/1.gif", "Sec-CH-UA-Arch", false, "");
+  ExpectHeader("http://www.example.com/1.gif", "Sec-CH-UA-Arch", false, "");
+  ExpectHeader("https://www.example.com/1.gif", "Sec-CH-UA-Platform", false,
+               "");
+  ExpectHeader("http://www.example.com/1.gif", "Sec-CH-UA-Platform", false, "");
+  ExpectHeader("https://www.example.com/1.gif", "Sec-CH-UA-Model", false, "");
+  ExpectHeader("http://www.example.com/1.gif", "Sec-CH-UA-Model", false, "");
+
+  {
+    ClientHintsPreferences preferences;
+    preferences.SetShouldSendForTesting(mojom::WebClientHintsType::kUAArch);
+    document->GetFrame()->GetClientHintsPreferences().UpdateFrom(preferences);
+
+    ExpectHeader("https://www.example.com/1.gif", "Sec-CH-UA-Arch", true, "");
+    ExpectHeader("https://www.example.com/1.gif", "Sec-CH-UA-Platform", false,
+                 "");
+    ExpectHeader("https://www.example.com/1.gif", "Sec-CH-UA-Model", false, "");
+
+    ExpectHeader("http://www.example.com/1.gif", "Sec-CH-UA-Arch", false, "");
+    ExpectHeader("http://www.example.com/1.gif", "Sec-CH-UA-Platform", false,
+                 "");
+    ExpectHeader("http://www.example.com/1.gif", "Sec-CH-UA-Model", false, "");
+  }
+
+  {
+    ClientHintsPreferences preferences;
+    preferences.SetShouldSendForTesting(mojom::WebClientHintsType::kUAPlatform);
+    document->GetFrame()->GetClientHintsPreferences().UpdateFrom(preferences);
+
+    ExpectHeader("https://www.example.com/1.gif", "Sec-CH-UA-Arch", false, "");
+    ExpectHeader("https://www.example.com/1.gif", "Sec-CH-UA-Platform", true,
+                 "");
+    ExpectHeader("https://www.example.com/1.gif", "Sec-CH-UA-Model", false, "");
+
+    ExpectHeader("http://www.example.com/1.gif", "Sec-CH-UA-Arch", false, "");
+    ExpectHeader("http://www.example.com/1.gif", "Sec-CH-UA-Platform", false,
+                 "");
+    ExpectHeader("http://www.example.com/1.gif", "Sec-CH-UA-Model", false, "");
+  }
+
+  {
+    ClientHintsPreferences preferences;
+    preferences.SetShouldSendForTesting(mojom::WebClientHintsType::kUAModel);
+    document->GetFrame()->GetClientHintsPreferences().UpdateFrom(preferences);
+
+    ExpectHeader("https://www.example.com/1.gif", "Sec-CH-UA-Arch", false, "");
+    ExpectHeader("https://www.example.com/1.gif", "Sec-CH-UA-Platform", false,
+                 "");
+    ExpectHeader("https://www.example.com/1.gif", "Sec-CH-UA-Model", true, "");
+
+    ExpectHeader("http://www.example.com/1.gif", "Sec-CH-UA-Arch", false, "");
+    ExpectHeader("http://www.example.com/1.gif", "Sec-CH-UA-Platform", false,
+                 "");
+    ExpectHeader("http://www.example.com/1.gif", "Sec-CH-UA-Model", false, "");
+  }
+}
+
 TEST_F(FrameFetchContextHintsTest, MonitorAllHints) {
   ExpectHeader("https://www.example.com/1.gif", "Device-Memory", false, "");
   ExpectHeader("https://www.example.com/1.gif", "DPR", false, "");
@@ -831,6 +895,13 @@
   ExpectHeader("https://www.example.com/1.gif", "downlink", false, "");
   ExpectHeader("https://www.example.com/1.gif", "ect", false, "");
   ExpectHeader("https://www.example.com/1.gif", "Sec-CH-Lang", false, "");
+  ExpectHeader("https://www.example.com/1.gif", "Sec-CH-UA-Arch", false, "");
+  ExpectHeader("https://www.example.com/1.gif", "Sec-CH-UA-Platform", false,
+               "");
+  ExpectHeader("https://www.example.com/1.gif", "Sec-CH-UA-Model", false, "");
+
+  // `Sec-CH-UA` is special.
+  ExpectHeader("https://www.example.com/1.gif", "Sec-CH-UA", true, "");
 
   ClientHintsPreferences preferences;
   preferences.SetShouldSendForTesting(mojom::WebClientHintsType::kDeviceMemory);
@@ -843,6 +914,10 @@
   preferences.SetShouldSendForTesting(mojom::WebClientHintsType::kDownlink);
   preferences.SetShouldSendForTesting(mojom::WebClientHintsType::kEct);
   preferences.SetShouldSendForTesting(mojom::WebClientHintsType::kLang);
+  preferences.SetShouldSendForTesting(mojom::WebClientHintsType::kUA);
+  preferences.SetShouldSendForTesting(mojom::WebClientHintsType::kUAArch);
+  preferences.SetShouldSendForTesting(mojom::WebClientHintsType::kUAPlatform);
+  preferences.SetShouldSendForTesting(mojom::WebClientHintsType::kUAModel);
   ApproximatedDeviceMemory::SetPhysicalMemoryMBForTesting(4096);
   document->GetFrame()->GetClientHintsPreferences().UpdateFrom(preferences);
   ExpectHeader("https://www.example.com/1.gif", "Device-Memory", true, "4");
@@ -854,6 +929,11 @@
   ExpectHeader("https://www.example.com/1.gif", "Sec-CH-Lang", true,
                "\"en\", \"de\", \"fr\"");
 
+  ExpectHeader("https://www.example.com/1.gif", "Sec-CH-UA", true, "");
+  ExpectHeader("https://www.example.com/1.gif", "Sec-CH-UA-Arch", true, "");
+  ExpectHeader("https://www.example.com/1.gif", "Sec-CH-UA-Platform", true, "");
+  ExpectHeader("https://www.example.com/1.gif", "Sec-CH-UA-Model", true, "");
+
   // Value of network quality client hints may vary, so only check if the
   // header is present and the values are non-negative/non-empty.
   bool conversion_ok = false;
diff --git a/third_party/blink/renderer/core/loader/private/frame_client_hints_preferences_context.cc b/third_party/blink/renderer/core/loader/private/frame_client_hints_preferences_context.cc
index 480d034..0216216 100644
--- a/third_party/blink/renderer/core/loader/private/frame_client_hints_preferences_context.cc
+++ b/third_party/blink/renderer/core/loader/private/frame_client_hints_preferences_context.cc
@@ -22,6 +22,10 @@
     WebFeature::kClientHintsDownlink,
     WebFeature::kClientHintsEct,
     WebFeature::kClientHintsLang,
+    WebFeature::kClientHintsUA,
+    WebFeature::kClientHintsUAArch,
+    WebFeature::kClientHintsUAPlatform,
+    WebFeature::kClientHintsUAModel,
 };
 
 static_assert(static_cast<int>(mojom::WebClientHintsType::kMaxValue) + 1 ==
diff --git a/third_party/blink/renderer/platform/loader/fetch/client_hints_preferences.cc b/third_party/blink/renderer/platform/loader/fetch/client_hints_preferences.cc
index d9f2154..b05491f 100644
--- a/third_party/blink/renderer/platform/loader/fetch/client_hints_preferences.cc
+++ b/third_party/blink/renderer/platform/loader/fetch/client_hints_preferences.cc
@@ -48,6 +48,26 @@
       mojom::WebClientHintsType::kLang,
       enabled_hints.IsEnabled(mojom::WebClientHintsType::kLang) &&
           RuntimeEnabledFeatures::LangClientHintHeaderEnabled());
+
+  enabled_hints.SetIsEnabled(
+      mojom::WebClientHintsType::kUA,
+      enabled_hints.IsEnabled(mojom::WebClientHintsType::kUA) &&
+          RuntimeEnabledFeatures::UserAgentClientHintEnabled());
+
+  enabled_hints.SetIsEnabled(
+      mojom::WebClientHintsType::kUAArch,
+      enabled_hints.IsEnabled(mojom::WebClientHintsType::kUAArch) &&
+          RuntimeEnabledFeatures::UserAgentClientHintEnabled());
+
+  enabled_hints.SetIsEnabled(
+      mojom::WebClientHintsType::kUAPlatform,
+      enabled_hints.IsEnabled(mojom::WebClientHintsType::kUAPlatform) &&
+          RuntimeEnabledFeatures::UserAgentClientHintEnabled());
+
+  enabled_hints.SetIsEnabled(
+      mojom::WebClientHintsType::kUAModel,
+      enabled_hints.IsEnabled(mojom::WebClientHintsType::kUAModel) &&
+          RuntimeEnabledFeatures::UserAgentClientHintEnabled());
 }
 
 }  // namespace
diff --git a/third_party/blink/renderer/platform/loader/fetch/client_hints_preferences_test.cc b/third_party/blink/renderer/platform/loader/fetch/client_hints_preferences_test.cc
index dea2544..fcf6551 100644
--- a/third_party/blink/renderer/platform/loader/fetch/client_hints_preferences_test.cc
+++ b/third_party/blink/renderer/platform/loader/fetch/client_hints_preferences_test.cc
@@ -25,22 +25,39 @@
     bool expectation_downlink;
     bool expectation_ect;
     bool expectation_lang;
+    bool expectation_ua;
+    bool expectation_ua_arch;
+    bool expectation_ua_platform;
+    bool expectation_ua_model;
   } cases[] = {
       {"width, dpr, viewportWidth", true, true, false, false, false, false,
-       false},
+       false, false, false, false, false},
       {"WiDtH, dPr, viewport-width, rtt, downlink, ect, lang", true, true, true,
-       true, true, true, true},
+       true, true, true, true, false, false, false, false},
       {"WiDtH, dPr, viewport-width, rtt, downlink, effective-connection-type",
-       true, true, true, true, true, false, false},
+       true, true, true, true, true, false, false, false, false, false, false},
       {"WIDTH, DPR, VIWEPROT-Width", true, true, false, false, false, false,
-       false},
+       false, false, false, false, false},
       {"VIewporT-Width, wutwut, width", true, false, true, false, false, false,
-       false},
-      {"dprw", false, false, false, false, false, false, false},
-      {"DPRW", false, false, false, false, false, false, false},
+       false, false, false, false, false},
+      {"dprw", false, false, false, false, false, false, false, false, false,
+       false, false},
+      {"DPRW", false, false, false, false, false, false, false, false, false,
+       false, false},
+      {"ua", false, false, false, false, false, false, false, true, false,
+       false, false},
+      {"arch", false, false, false, false, false, false, false, false, true,
+       false, false},
+      {"platform", false, false, false, false, false, false, false, false,
+       false, true, false},
+      {"model", false, false, false, false, false, false, false, false, false,
+       false, true},
+      {"ua, arch, platform, model", false, false, false, false, false, false,
+       false, true, true, true, true},
   };
 
   for (const auto& test_case : cases) {
+    SCOPED_TRACE(testing::Message() << test_case.header_value);
     ClientHintsPreferences preferences;
     const KURL kurl(String::FromUTF8("https://www.google.com/"));
     preferences.UpdateFromAcceptClientHintsHeader(test_case.header_value, kurl,
@@ -61,6 +78,14 @@
               preferences.ShouldSend(mojom::WebClientHintsType::kEct));
     EXPECT_EQ(test_case.expectation_lang,
               preferences.ShouldSend(mojom::WebClientHintsType::kLang));
+    EXPECT_EQ(test_case.expectation_ua,
+              preferences.ShouldSend(mojom::WebClientHintsType::kUA));
+    EXPECT_EQ(test_case.expectation_ua_arch,
+              preferences.ShouldSend(mojom::WebClientHintsType::kUAArch));
+    EXPECT_EQ(test_case.expectation_ua_platform,
+              preferences.ShouldSend(mojom::WebClientHintsType::kUAPlatform));
+    EXPECT_EQ(test_case.expectation_ua_model,
+              preferences.ShouldSend(mojom::WebClientHintsType::kUAModel));
 
     // Calling UpdateFromAcceptClientHintsHeader with empty header should have
     // no impact on client hint preferences.
@@ -105,6 +130,10 @@
   EXPECT_TRUE(preferences.ShouldSend(mojom::WebClientHintsType::kDownlink));
   EXPECT_FALSE(preferences.ShouldSend(mojom::WebClientHintsType::kEct));
   EXPECT_FALSE(preferences.ShouldSend(mojom::WebClientHintsType::kLang));
+  EXPECT_FALSE(preferences.ShouldSend(mojom::WebClientHintsType::kUA));
+  EXPECT_FALSE(preferences.ShouldSend(mojom::WebClientHintsType::kUAArch));
+  EXPECT_FALSE(preferences.ShouldSend(mojom::WebClientHintsType::kUAPlatform));
+  EXPECT_FALSE(preferences.ShouldSend(mojom::WebClientHintsType::kUAModel));
 
   // Calling UpdateFromAcceptClientHintsHeader with empty header should have
   // no impact on client hint preferences.
@@ -116,6 +145,10 @@
   EXPECT_TRUE(preferences.ShouldSend(mojom::WebClientHintsType::kDownlink));
   EXPECT_FALSE(preferences.ShouldSend(mojom::WebClientHintsType::kEct));
   EXPECT_FALSE(preferences.ShouldSend(mojom::WebClientHintsType::kLang));
+  EXPECT_FALSE(preferences.ShouldSend(mojom::WebClientHintsType::kUA));
+  EXPECT_FALSE(preferences.ShouldSend(mojom::WebClientHintsType::kUAArch));
+  EXPECT_FALSE(preferences.ShouldSend(mojom::WebClientHintsType::kUAPlatform));
+  EXPECT_FALSE(preferences.ShouldSend(mojom::WebClientHintsType::kUAModel));
 
   // Calling UpdateFromAcceptClientHintsHeader with an invalid header should
   // have no impact on client hint preferences.
@@ -126,6 +159,10 @@
   EXPECT_TRUE(preferences.ShouldSend(mojom::WebClientHintsType::kRtt));
   EXPECT_TRUE(preferences.ShouldSend(mojom::WebClientHintsType::kDownlink));
   EXPECT_FALSE(preferences.ShouldSend(mojom::WebClientHintsType::kLang));
+  EXPECT_FALSE(preferences.ShouldSend(mojom::WebClientHintsType::kUA));
+  EXPECT_FALSE(preferences.ShouldSend(mojom::WebClientHintsType::kUAArch));
+  EXPECT_FALSE(preferences.ShouldSend(mojom::WebClientHintsType::kUAPlatform));
+  EXPECT_FALSE(preferences.ShouldSend(mojom::WebClientHintsType::kUAModel));
 
   // Calling UpdateFromAcceptClientHintsHeader with "width" header should
   // have no impact on already enabled client hint preferences.
@@ -137,6 +174,10 @@
   EXPECT_TRUE(preferences.ShouldSend(mojom::WebClientHintsType::kDownlink));
   EXPECT_FALSE(preferences.ShouldSend(mojom::WebClientHintsType::kEct));
   EXPECT_FALSE(preferences.ShouldSend(mojom::WebClientHintsType::kLang));
+  EXPECT_FALSE(preferences.ShouldSend(mojom::WebClientHintsType::kUA));
+  EXPECT_FALSE(preferences.ShouldSend(mojom::WebClientHintsType::kUAArch));
+  EXPECT_FALSE(preferences.ShouldSend(mojom::WebClientHintsType::kUAPlatform));
+  EXPECT_FALSE(preferences.ShouldSend(mojom::WebClientHintsType::kUAModel));
 
   preferences.UpdateFromAcceptClientHintsLifetimeHeader("1000", kurl, nullptr);
   EXPECT_EQ(base::TimeDelta::FromSeconds(1000),
@@ -170,21 +211,27 @@
     bool expect_downlink;
     bool expect_ect;
     bool expect_lang;
+    bool expect_ua;
+    bool expect_ua_arch;
+    bool expect_ua_platform;
+    bool expect_ua_model;
   } test_cases[] = {
       {"width, dpr, viewportWidth, lang", "", 0, false, true, true, false,
-       false, false, false, true},
+       false, false, false, true, false, false, false, false},
       {"width, dpr, viewportWidth", "-1000", 0, false, true, true, false, false,
-       false, false, false},
+       false, false, false, false, false, false, false},
       {"width, dpr, viewportWidth", "1000s", 0, false, true, true, false, false,
-       false, false, false},
+       false, false, false, false, false, false, false},
       {"width, dpr, viewportWidth", "1000.5", 0, false, true, true, false,
-       false, false, false, false},
+       false, false, false, false, false, false, false, false},
       {"width, dpr, rtt, downlink, ect", "1000", 1000, false, true, true, false,
-       true, true, true, false},
+       true, true, true, false, false, false, false, false},
       {"device-memory", "-1000", 0, true, false, false, false, false, false,
-       false, false},
+       false, false, false, false, false, false},
       {"dpr rtt", "1000", 1000, false, false, false, false, false, false, false,
-       false},
+       false, false, false, false, false},
+      {"ua, arch, platform, model", "1000", 1000, false, false, false, false,
+       false, false, false, false, true, true, true, true},
   };
 
   for (const auto& test : test_cases) {
@@ -202,6 +249,11 @@
     EXPECT_FALSE(enabled_types.IsEnabled(mojom::WebClientHintsType::kDownlink));
     EXPECT_FALSE(enabled_types.IsEnabled(mojom::WebClientHintsType::kEct));
     EXPECT_FALSE(enabled_types.IsEnabled(mojom::WebClientHintsType::kLang));
+    EXPECT_FALSE(enabled_types.IsEnabled(mojom::WebClientHintsType::kUA));
+    EXPECT_FALSE(enabled_types.IsEnabled(mojom::WebClientHintsType::kUAArch));
+    EXPECT_FALSE(
+        enabled_types.IsEnabled(mojom::WebClientHintsType::kUAPlatform));
+    EXPECT_FALSE(enabled_types.IsEnabled(mojom::WebClientHintsType::kUAModel));
     TimeDelta persist_duration = preferences.GetPersistDuration();
     EXPECT_EQ(base::TimeDelta(), persist_duration);
 
@@ -236,6 +288,14 @@
               enabled_types.IsEnabled(mojom::WebClientHintsType::kEct));
     EXPECT_EQ(test.expect_lang,
               enabled_types.IsEnabled(mojom::WebClientHintsType::kLang));
+    EXPECT_EQ(test.expect_ua,
+              enabled_types.IsEnabled(mojom::WebClientHintsType::kUA));
+    EXPECT_EQ(test.expect_ua_arch,
+              enabled_types.IsEnabled(mojom::WebClientHintsType::kUAArch));
+    EXPECT_EQ(test.expect_ua_platform,
+              enabled_types.IsEnabled(mojom::WebClientHintsType::kUAPlatform));
+    EXPECT_EQ(test.expect_ua_model,
+              enabled_types.IsEnabled(mojom::WebClientHintsType::kUAModel));
   }
 }
 
diff --git a/third_party/blink/web_tests/external/wpt/service-workers/service-worker/fetch-request-xhr.https-expected.txt b/third_party/blink/web_tests/external/wpt/service-workers/service-worker/fetch-request-xhr.https-expected.txt
index 76e0835..3de90db 100644
--- a/third_party/blink/web_tests/external/wpt/service-workers/service-worker/fetch-request-xhr.https-expected.txt
+++ b/third_party/blink/web_tests/external/wpt/service-workers/service-worker/fetch-request-xhr.https-expected.txt
@@ -1,9 +1,9 @@
 This is a testharness.js-based test.
 PASS initialize global state
-FAIL event.request has the expected headers for same-origin GET. promise_test: Unhandled rejection with value: object "Error: assert_array_equals: event.request has the expected headers for same-origin GET. lengths differ, expected 1 got 6"
-FAIL event.request has the expected headers for same-origin POST. promise_test: Unhandled rejection with value: object "Error: assert_array_equals: event.request has the expected headers for same-origin POST. lengths differ, expected 2 got 8"
-FAIL event.request has the expected headers for cross-origin GET. promise_test: Unhandled rejection with value: object "Error: assert_array_equals: event.request has the expected headers for cross-origin GET. lengths differ, expected 1 got 6"
-FAIL event.request has the expected headers for cross-origin POST. promise_test: Unhandled rejection with value: object "Error: assert_array_equals: event.request has the expected headers for cross-origin POST. lengths differ, expected 2 got 8"
+FAIL event.request has the expected headers for same-origin GET. promise_test: Unhandled rejection with value: object "Error: assert_array_equals: event.request has the expected headers for same-origin GET. lengths differ, expected 1 got 7"
+FAIL event.request has the expected headers for same-origin POST. promise_test: Unhandled rejection with value: object "Error: assert_array_equals: event.request has the expected headers for same-origin POST. lengths differ, expected 2 got 9"
+FAIL event.request has the expected headers for cross-origin GET. promise_test: Unhandled rejection with value: object "Error: assert_array_equals: event.request has the expected headers for cross-origin GET. lengths differ, expected 1 got 7"
+FAIL event.request has the expected headers for cross-origin POST. promise_test: Unhandled rejection with value: object "Error: assert_array_equals: event.request has the expected headers for cross-origin POST. lengths differ, expected 2 got 9"
 PASS FetchEvent#request.body contains XHR request data (string)
 PASS FetchEvent#request.body contains XHR request data (blob)
 PASS FetchEvent#request.method is set to XHR method
diff --git a/tools/metrics/histograms/enums.xml b/tools/metrics/histograms/enums.xml
index 5f69e39..de314d1 100644
--- a/tools/metrics/histograms/enums.xml
+++ b/tools/metrics/histograms/enums.xml
@@ -21582,6 +21582,10 @@
   <int value="2786" label="V8UserActivation_IsActive_AttributeGetter"/>
   <int value="2787" label="TextEncoderEncodeInto"/>
   <int value="2788" label="InvalidBasicCardMethodData"/>
+  <int value="2789" label="ClientHintsUA"/>
+  <int value="2790" label="ClientHintsUAArch"/>
+  <int value="2791" label="ClientHintsUAPlatform"/>
+  <int value="2792" label="ClientHintsUAModel"/>
 </enum>
 
 <enum name="FeaturePolicyFeature">