Route conversion registration requests from blink to ConversionStorage

This change implements a conversion registration flow for the Event Level
Conversion Measurement API.

A request is considered a conversion registration if it redirects to
/.well-known/register-conversion. This request is blocked in
frame_fetch_context and sent via a NavigationAssociatedInterface to
ConversionManager and logged to storage.

Design doc(RVG): https://crbug.com/1039466
Explainer: https://github.com/csharrison/conversion-measurement-api

Bug: 1042923
Change-Id: If0398a662f68cc6092922c79ba05f546bf0562e9
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/1998977
Commit-Queue: John Delaney <[email protected]>
Reviewed-by: Charlie Harrison <[email protected]>
Reviewed-by: Avi Drissman <[email protected]>
Reviewed-by: Mike West <[email protected]>
Cr-Commit-Position: refs/heads/master@{#739602}
diff --git a/content/browser/BUILD.gn b/content/browser/BUILD.gn
index d4098d7..c2db18f 100644
--- a/content/browser/BUILD.gn
+++ b/content/browser/BUILD.gn
@@ -672,6 +672,8 @@
     "content_index/content_index_service_impl.h",
     "content_service_delegate_impl.cc",
     "content_service_delegate_impl.h",
+    "conversions/conversion_host.cc",
+    "conversions/conversion_host.h",
     "conversions/conversion_manager.cc",
     "conversions/conversion_manager.h",
     "conversions/conversion_policy.cc",
diff --git a/content/browser/conversions/conversion_host.cc b/content/browser/conversions/conversion_host.cc
new file mode 100644
index 0000000..ec917fa
--- /dev/null
+++ b/content/browser/conversions/conversion_host.cc
@@ -0,0 +1,77 @@
+// Copyright 2020 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#include "content/browser/conversions/conversion_host.h"
+
+#include "base/bind.h"
+#include "base/bind_helpers.h"
+#include "content/browser/conversions/conversion_manager.h"
+#include "content/browser/conversions/conversion_policy.h"
+#include "content/browser/conversions/storable_conversion.h"
+#include "content/browser/storage_partition_impl.h"
+#include "content/public/browser/browser_context.h"
+#include "content/public/browser/render_frame_host.h"
+#include "content/public/browser/web_contents.h"
+#include "mojo/public/cpp/bindings/message.h"
+#include "services/network/public/cpp/is_potentially_trustworthy.h"
+#include "url/origin.h"
+
+namespace content {
+
+ConversionHost::ConversionHost(WebContents* contents)
+    : web_contents_(contents), receiver_(contents, this) {}
+
+ConversionHost::~ConversionHost() = default;
+
+// TODO(https://crbug.com/1044099): Limit the number of conversion redirects per
+// page-load to a reasonable number.
+void ConversionHost::RegisterConversion(
+    blink::mojom::ConversionPtr conversion) {
+  // If there is no conversion manager available, ignore any conversion
+  // registrations.
+  if (!GetManager())
+    return;
+  content::RenderFrameHost* render_frame_host =
+      receiver_.GetCurrentTargetFrame();
+
+  // Conversion registration is only allowed in the main frame.
+  if (render_frame_host->GetParent()) {
+    mojo::ReportBadMessage(
+        "blink.mojom.ConversionHost can only be used by the main frame.");
+    return;
+  }
+
+  // Only allow conversion registration on secure pages with a secure conversion
+  // redirects.
+  if (!network::IsOriginPotentiallyTrustworthy(
+          render_frame_host->GetLastCommittedOrigin()) ||
+      !network::IsOriginPotentiallyTrustworthy(conversion->reporting_origin)) {
+    mojo::ReportBadMessage(
+        "blink.mojom.ConversionHost can only be used in secure contexts with a "
+        "secure conversion registration origin.");
+    return;
+  }
+
+  StorableConversion storable_conversion(
+      GetManager()->GetConversionPolicy().GetSanitizedConversionData(
+          conversion->conversion_data),
+      render_frame_host->GetLastCommittedOrigin(),
+      conversion->reporting_origin);
+
+  GetManager()->HandleConversion(storable_conversion);
+}
+
+void ConversionHost::SetCurrentTargetFrameForTesting(
+    RenderFrameHost* render_frame_host) {
+  receiver_.SetCurrentTargetFrameForTesting(render_frame_host);
+}
+
+ConversionManager* ConversionHost::GetManager() {
+  return static_cast<StoragePartitionImpl*>(
+             BrowserContext::GetDefaultStoragePartition(
+                 web_contents_->GetBrowserContext()))
+      ->GetConversionManager();
+}
+
+}  // namespace content
diff --git a/content/browser/conversions/conversion_host.h b/content/browser/conversions/conversion_host.h
new file mode 100644
index 0000000..468fa6b5
--- /dev/null
+++ b/content/browser/conversions/conversion_host.h
@@ -0,0 +1,52 @@
+// Copyright 2020 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#ifndef CONTENT_BROWSER_CONVERSIONS_CONVERSION_HOST_H_
+#define CONTENT_BROWSER_CONVERSIONS_CONVERSION_HOST_H_
+
+#include "base/gtest_prod_util.h"
+#include "content/public/browser/web_contents_receiver_set.h"
+#include "third_party/blink/public/mojom/conversions/conversions.mojom.h"
+
+namespace content {
+
+class ConversionManager;
+class RenderFrameHost;
+class WebContents;
+
+// Class responsible for listening to conversion events originating from blink,
+// and verifying that they are valid. Owned by the WebContents. Lifetime is
+// bound to lifetime of the WebContents.
+class CONTENT_EXPORT ConversionHost : public blink::mojom::ConversionHost {
+ public:
+  explicit ConversionHost(WebContents* web_contents);
+  ConversionHost(const ConversionHost& other) = delete;
+  ConversionHost& operator=(const ConversionHost& other) = delete;
+  ~ConversionHost() override;
+
+ private:
+  FRIEND_TEST_ALL_PREFIXES(ConversionHostTest, ConversionInSubframe_BadMessage);
+  FRIEND_TEST_ALL_PREFIXES(ConversionHostTest,
+                           ConversionOnInsecurePage_BadMessage);
+  FRIEND_TEST_ALL_PREFIXES(ConversionHostTest,
+                           ConversionWithInsecureReportingOrigin_BadMessage);
+  FRIEND_TEST_ALL_PREFIXES(ConversionHostTest, ValidConversion_NoBadMessage);
+
+  // blink::mojom::ConversionHost:
+  void RegisterConversion(blink::mojom::ConversionPtr conversion) override;
+
+  // Sets the target frame on |receiver_|.
+  void SetCurrentTargetFrameForTesting(RenderFrameHost* render_frame_host);
+
+  // Gets the manager for this web contents. Can be null.
+  ConversionManager* GetManager();
+
+  WebContents* web_contents_;
+
+  WebContentsFrameReceiverSet<blink::mojom::ConversionHost> receiver_;
+};
+
+}  // namespace content
+
+#endif  // CONTENT_BROWSER_CONVERSIONS_CONVERSION_HOST_H_
diff --git a/content/browser/conversions/conversion_host_unittest.cc b/content/browser/conversions/conversion_host_unittest.cc
new file mode 100644
index 0000000..255587e9
--- /dev/null
+++ b/content/browser/conversions/conversion_host_unittest.cc
@@ -0,0 +1,126 @@
+// Copyright 2020 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#include "content/browser/conversions/conversion_host.h"
+
+#include <memory>
+
+#include "base/test/scoped_feature_list.h"
+#include "content/browser/web_contents/web_contents_impl.h"
+#include "content/public/common/content_features.h"
+#include "content/public/test/test_renderer_host.h"
+#include "content/test/fake_mojo_message_dispatch_context.h"
+#include "content/test/test_render_frame_host.h"
+#include "content/test/test_web_contents.h"
+#include "mojo/public/cpp/test_support/test_utils.h"
+#include "third_party/blink/public/mojom/conversions/conversions.mojom.h"
+#include "url/gurl.h"
+#include "url/origin.h"
+
+namespace content {
+
+class ConversionHostTest : public RenderViewHostTestHarness {
+ public:
+  ConversionHostTest() {
+    feature_list_.InitAndEnableFeature(features::kConversionMeasurement);
+  }
+
+  void SetUp() override {
+    RenderViewHostTestHarness::SetUp();
+    static_cast<WebContentsImpl*>(web_contents())
+        ->RemoveReceiverSetForTesting(blink::mojom::ConversionHost::Name_);
+    conversion_host_ = std::make_unique<ConversionHost>(web_contents());
+    contents()->GetMainFrame()->InitializeRenderFrameIfNeeded();
+  }
+
+  TestWebContents* contents() {
+    return static_cast<TestWebContents*>(web_contents());
+  }
+
+  ConversionHost* conversion_host() { return conversion_host_.get(); }
+
+ private:
+  base::test::ScopedFeatureList feature_list_;
+  std::unique_ptr<ConversionHost> conversion_host_;
+};
+
+TEST_F(ConversionHostTest, ConversionInSubframe_BadMessage) {
+  contents()->NavigateAndCommit(GURL("http://www.example.com"));
+
+  // Create a subframe and use it as a target for the conversion registration
+  // mojo.
+  content::RenderFrameHostTester* rfh_tester =
+      content::RenderFrameHostTester::For(main_rfh());
+  content::RenderFrameHost* subframe = rfh_tester->AppendChild("subframe");
+  conversion_host()->SetCurrentTargetFrameForTesting(subframe);
+
+  // Create a fake dispatch context to trigger a bad message in.
+  FakeMojoMessageDispatchContext fake_dispatch_context;
+  mojo::test::BadMessageObserver bad_message_observer;
+  blink::mojom::ConversionPtr conversion = blink::mojom::Conversion::New();
+
+  // Message should be ignored because it was registered from a subframe.
+  conversion_host()->RegisterConversion(std::move(conversion));
+  EXPECT_EQ("blink.mojom.ConversionHost can only be used by the main frame.",
+            bad_message_observer.WaitForBadMessage());
+}
+
+TEST_F(ConversionHostTest, ConversionOnInsecurePage_BadMessage) {
+  // Create a page with an insecure origin.
+  contents()->NavigateAndCommit(GURL("http://www.example.com"));
+  conversion_host()->SetCurrentTargetFrameForTesting(main_rfh());
+
+  FakeMojoMessageDispatchContext fake_dispatch_context;
+  mojo::test::BadMessageObserver bad_message_observer;
+  blink::mojom::ConversionPtr conversion = blink::mojom::Conversion::New();
+  conversion->reporting_origin =
+      url::Origin::Create(GURL("https://secure.com"));
+
+  // Message should be ignored because it was registered from an insecure page.
+  conversion_host()->RegisterConversion(std::move(conversion));
+  EXPECT_EQ(
+      "blink.mojom.ConversionHost can only be used in secure contexts with a "
+      "secure conversion registration origin.",
+      bad_message_observer.WaitForBadMessage());
+}
+
+TEST_F(ConversionHostTest, ConversionWithInsecureReportingOrigin_BadMessage) {
+  contents()->NavigateAndCommit(GURL("https://www.example.com"));
+  conversion_host()->SetCurrentTargetFrameForTesting(main_rfh());
+
+  FakeMojoMessageDispatchContext fake_dispatch_context;
+  mojo::test::BadMessageObserver bad_message_observer;
+  blink::mojom::ConversionPtr conversion = blink::mojom::Conversion::New();
+  conversion->reporting_origin = url::Origin::Create(GURL("http://secure.com"));
+
+  // Message should be ignored because it was registered with an insecure
+  // redirect.
+  conversion_host()->RegisterConversion(std::move(conversion));
+  EXPECT_EQ(
+      "blink.mojom.ConversionHost can only be used in secure contexts with a "
+      "secure conversion registration origin.",
+      bad_message_observer.WaitForBadMessage());
+}
+
+TEST_F(ConversionHostTest, ValidConversion_NoBadMessage) {
+  // Create a page with an insecure origin.
+  contents()->NavigateAndCommit(GURL("https://www.example.com"));
+  conversion_host()->SetCurrentTargetFrameForTesting(main_rfh());
+
+  // Create a fake dispatch context to trigger a bad message in.
+  FakeMojoMessageDispatchContext fake_dispatch_context;
+  mojo::test::BadMessageObserver bad_message_observer;
+
+  blink::mojom::ConversionPtr conversion = blink::mojom::Conversion::New();
+  conversion->reporting_origin =
+      url::Origin::Create(GURL("https://secure.com"));
+  conversion_host()->RegisterConversion(std::move(conversion));
+
+  // Run loop to allow the bad message code to run if a bad message was
+  // triggered.
+  base::RunLoop().RunUntilIdle();
+  EXPECT_FALSE(bad_message_observer.got_bad_message());
+}
+
+}  // namespace content
diff --git a/content/browser/conversions/conversion_manager.cc b/content/browser/conversions/conversion_manager.cc
index dad2f88..2e35885 100644
--- a/content/browser/conversions/conversion_manager.cc
+++ b/content/browser/conversions/conversion_manager.cc
@@ -34,6 +34,22 @@
 
 ConversionManager::~ConversionManager() = default;
 
+void ConversionManager::HandleConversion(const StorableConversion& conversion) {
+  if (!storage_)
+    return;
+
+  // TODO(https://crbug.com/1043345): Add UMA for the number of conversions we
+  // are logging to storage, and the number of new reports logged to storage.
+  // Unretained is safe because any task to delete |storage_| will be posted
+  // after this one.
+  storage_task_runner_.get()->PostTask(
+      FROM_HERE,
+      base::BindOnce(
+          base::IgnoreResult(
+              &ConversionStorage::MaybeCreateAndStoreConversionReports),
+          base::Unretained(storage_.get()), conversion));
+}
+
 const ConversionPolicy& ConversionManager::GetConversionPolicy() const {
   return *conversion_policy_;
 }
diff --git a/content/browser/conversions/conversion_manager.h b/content/browser/conversions/conversion_manager.h
index 64eb19e..fe132c4 100644
--- a/content/browser/conversions/conversion_manager.h
+++ b/content/browser/conversions/conversion_manager.h
@@ -14,6 +14,7 @@
 #include "base/sequenced_task_runner.h"
 #include "content/browser/conversions/conversion_policy.h"
 #include "content/browser/conversions/conversion_storage.h"
+#include "content/browser/conversions/storable_conversion.h"
 
 namespace base {
 class Clock;
@@ -35,6 +36,10 @@
   ConversionManager& operator=(const ConversionManager& other) = delete;
   ~ConversionManager() override;
 
+  // Process a newly registered conversion. Will create and log any new
+  // conversion reports to storage.
+  void HandleConversion(const StorableConversion& conversion);
+
   const ConversionPolicy& GetConversionPolicy() const;
 
  private:
diff --git a/content/browser/conversions/conversion_policy.cc b/content/browser/conversions/conversion_policy.cc
index 9b21031..d323ccf 100644
--- a/content/browser/conversions/conversion_policy.cc
+++ b/content/browser/conversions/conversion_policy.cc
@@ -4,11 +4,52 @@
 
 #include "content/browser/conversions/conversion_policy.h"
 
+#include "base/format_macros.h"
 #include "base/logging.h"
+#include "base/memory/ptr_util.h"
+#include "base/rand_util.h"
+#include "base/strings/stringprintf.h"
 #include "base/time/time.h"
 
 namespace content {
 
+namespace {
+
+// Maximum number of allowed conversion metadata values. Higher entropy
+// conversion metadata is stripped to these lower bits.
+const int kMaxAllowedConversionValues = 8;
+
+}  // namespace
+
+uint64_t ConversionPolicy::NoiseProvider::GetNoisedConversionData(
+    uint64_t conversion_data) const {
+  // Return |conversion_data| without any noise 95% of the time.
+  if (base::RandDouble() > .05)
+    return conversion_data;
+
+  // 5% of the time return a random number in the allowed range. Note that the
+  // value is noised 5% of the time, but only wrong 5 *
+  // (kMaxAllowedConversionValues - 1) / kMaxAllowedConversionValues percent of
+  // the time.
+  return static_cast<uint64_t>(base::RandInt(0, kMaxAllowedConversionValues));
+}
+
+// static
+std::unique_ptr<ConversionPolicy> ConversionPolicy::CreateForTesting(
+    std::unique_ptr<NoiseProvider> noise_provider) {
+  return base::WrapUnique<ConversionPolicy>(
+      new ConversionPolicy(std::move(noise_provider)));
+}
+
+ConversionPolicy::ConversionPolicy()
+    : noise_provider_(std::make_unique<NoiseProvider>()) {}
+
+ConversionPolicy::ConversionPolicy(
+    std::unique_ptr<ConversionPolicy::NoiseProvider> noise_provider)
+    : noise_provider_(std::move(noise_provider)) {}
+
+ConversionPolicy::~ConversionPolicy() = default;
+
 base::Time ConversionPolicy::GetReportTimeForConversion(
     const ConversionReport& report) const {
   // After the initial impression, a schedule of reporting windows and deadlines
@@ -85,4 +126,19 @@
   last_report->attribution_credit = 100;
 }
 
+std::string ConversionPolicy::GetSanitizedConversionData(
+    uint64_t conversion_data) const {
+  // Add noise to the conversion when the value is first sanitized from a
+  // conversion registration event. This noised data will be used for all
+  // associated impressions that convert.
+  conversion_data = noise_provider_->GetNoisedConversionData(conversion_data);
+
+  // Allow at most 3 bits of entropy in conversion data. base::StringPrintf() is
+  // used over base::HexEncode() because HexEncode() returns a hex string with
+  // little-endian ordering. Big-endian ordering is expected here because the
+  // API assumes big-endian when parsing attributes.
+  return base::StringPrintf("%" PRIx64,
+                            conversion_data % kMaxAllowedConversionValues);
+}
+
 }  // namespace content
diff --git a/content/browser/conversions/conversion_policy.h b/content/browser/conversions/conversion_policy.h
index 28031c5..1a14eac9 100644
--- a/content/browser/conversions/conversion_policy.h
+++ b/content/browser/conversions/conversion_policy.h
@@ -5,6 +5,9 @@
 #ifndef CONTENT_BROWSER_CONVERSIONS_CONVERSION_POLICY_H_
 #define CONTENT_BROWSER_CONVERSIONS_CONVERSION_POLICY_H_
 
+#include <stdint.h>
+#include <memory>
+#include <string>
 #include <vector>
 
 #include "base/time/time.h"
@@ -17,10 +20,26 @@
 // storing, and sending impressions and conversions.
 class CONTENT_EXPORT ConversionPolicy {
  public:
-  ConversionPolicy() = default;
+  // Helper class that generates noised conversion data. Can be overridden to
+  // make testing deterministic.
+  class CONTENT_EXPORT NoiseProvider {
+   public:
+    NoiseProvider() = default;
+    virtual ~NoiseProvider() = default;
+
+    // Returns a noise value of |conversion_data|. By default, this reports
+    // completely random data for 5% of conversions, and sends the real data for
+    // 95%. Virtual for testing.
+    virtual uint64_t GetNoisedConversionData(uint64_t conversion_data) const;
+  };
+
+  static std::unique_ptr<ConversionPolicy> CreateForTesting(
+      std::unique_ptr<NoiseProvider> noise_provider);
+
+  ConversionPolicy();
   ConversionPolicy(const ConversionPolicy& other) = delete;
   ConversionPolicy& operator=(const ConversionPolicy& other) = delete;
-  virtual ~ConversionPolicy() = default;
+  virtual ~ConversionPolicy();
 
   // Get the time a conversion report should be sent, by batching reports into
   // set reporting windows based on their impression time. This strictly delays
@@ -37,6 +56,17 @@
   // for the most recent impression a credit of 100, and the rest a credit of 0.
   virtual void AssignAttributionCredits(
       std::vector<ConversionReport>* reports) const;
+
+  // Gets the sanitized conversion data for a conversion. This strips entropy
+  // from the provided to data to at most 3 bits of information.
+  virtual std::string GetSanitizedConversionData(
+      uint64_t conversion_data) const;
+
+ private:
+  // For testing only.
+  ConversionPolicy(std::unique_ptr<NoiseProvider> noise_provider);
+
+  std::unique_ptr<NoiseProvider> noise_provider_;
 };
 
 }  // namespace content
diff --git a/content/browser/conversions/conversion_policy_unittest.cc b/content/browser/conversions/conversion_policy_unittest.cc
index 805ad9b..b2e5941 100644
--- a/content/browser/conversions/conversion_policy_unittest.cc
+++ b/content/browser/conversions/conversion_policy_unittest.cc
@@ -4,8 +4,10 @@
 
 #include "content/browser/conversions/conversion_policy.h"
 
+#include <memory>
 #include <vector>
 
+#include "base/strings/string_number_conversions.h"
 #include "base/time/time.h"
 #include "content/browser/conversions/conversion_test_utils.h"
 #include "testing/gtest/include/gtest/gtest.h"
@@ -25,6 +27,22 @@
       /*conversion_id=*/base::nullopt);
 }
 
+// Fake ConversionNoiseProvider that return un-noised conversion data.
+class EmptyNoiseProvider : public ConversionPolicy::NoiseProvider {
+ public:
+  uint64_t GetNoisedConversionData(uint64_t conversion_data) const override {
+    return conversion_data;
+  }
+};
+
+// Mock ConversionNoiseProvider that always noises values by +1.
+class IncrementingNoiseProvider : public ConversionPolicy::NoiseProvider {
+ public:
+  uint64_t GetNoisedConversionData(uint64_t conversion_data) const override {
+    return conversion_data + 1;
+  }
+};
+
 }  // namespace
 
 class ConversionPolicyTest : public testing::Test {
@@ -32,6 +50,31 @@
   ConversionPolicyTest() = default;
 };
 
+TEST_F(ConversionPolicyTest, HighEntropyConversionData_StrippedToLowerBits) {
+  uint64_t conversion_data = 8LU;
+
+  // The policy should strip the data to the lower 3 bits.
+  EXPECT_EQ("0", ConversionPolicy::CreateForTesting(
+                     std::make_unique<EmptyNoiseProvider>())
+                     ->GetSanitizedConversionData(conversion_data));
+}
+
+TEST_F(ConversionPolicyTest, ThreeBitConversionData_Unchanged) {
+  std::unique_ptr<ConversionPolicy> policy = ConversionPolicy::CreateForTesting(
+      std::make_unique<EmptyNoiseProvider>());
+  for (uint64_t conversion_data = 0; conversion_data < 8; conversion_data++) {
+    EXPECT_EQ(base::NumberToString(conversion_data),
+              policy->GetSanitizedConversionData(conversion_data));
+  }
+}
+
+TEST_F(ConversionPolicyTest, SantizizeConversionData_OutputHasNoise) {
+  // The policy should include noise when sanitizing data.
+  EXPECT_EQ("5", ConversionPolicy::CreateForTesting(
+                     std::make_unique<IncrementingNoiseProvider>())
+                     ->GetSanitizedConversionData(4UL));
+}
+
 TEST_F(ConversionPolicyTest, ImmediateConversion_FirstWindowUsed) {
   base::Time impression_time = base::Time::Now();
   auto report = GetReport(impression_time, /*conversion_time=*/impression_time);
diff --git a/content/browser/conversions/conversion_registration_browsertest.cc b/content/browser/conversions/conversion_registration_browsertest.cc
new file mode 100644
index 0000000..55d40c2
--- /dev/null
+++ b/content/browser/conversions/conversion_registration_browsertest.cc
@@ -0,0 +1,270 @@
+// Copyright 2020 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#include <stdint.h>
+#include <memory>
+
+#include "base/bind.h"
+#include "base/test/scoped_feature_list.h"
+#include "content/browser/conversions/conversion_host.h"
+#include "content/browser/web_contents/web_contents_impl.h"
+#include "content/public/common/content_features.h"
+#include "content/public/test/browser_test_utils.h"
+#include "content/public/test/content_browser_test.h"
+#include "content/public/test/content_browser_test_utils.h"
+#include "content/shell/browser/shell.h"
+#include "content/test/resource_load_observer.h"
+#include "net/dns/mock_host_resolver.h"
+#include "net/test/embedded_test_server/default_handlers.h"
+#include "net/test/embedded_test_server/embedded_test_server.h"
+#include "third_party/blink/public/mojom/conversions/conversions.mojom.h"
+#include "url/gurl.h"
+
+namespace content {
+
+namespace {
+
+// Well known path for registering conversions.
+const std::string kWellKnownUrl = ".well-known/register-conversion";
+
+}  // namespace
+
+// A mock conversion host which waits for a conversion registration
+// mojo message is received. Tracks the last seen conversion data.
+class TestConversionHost : public ConversionHost {
+ public:
+  static std::unique_ptr<TestConversionHost> ReplaceAndGetConversionHost(
+      WebContents* contents) {
+    static_cast<WebContentsImpl*>(contents)->RemoveReceiverSetForTesting(
+        blink::mojom::ConversionHost::Name_);
+    return std::make_unique<TestConversionHost>(contents);
+  }
+
+  explicit TestConversionHost(WebContents* contents)
+      : ConversionHost(contents) {}
+
+  void RegisterConversion(blink::mojom::ConversionPtr conversion) override {
+    last_conversion_data_ = conversion->conversion_data;
+    num_conversions_++;
+
+    // Don't quit the run loop if we have not seen the expected number of
+    // conversions.
+    if (num_conversions_ < expected_num_conversions_)
+      return;
+    conversion_waiter_.Quit();
+  }
+
+  // Returns the last conversion data after |expected_num_conversions| have been
+  // observed.
+  uint64_t WaitForNumConversions(size_t expected_num_conversions) {
+    if (expected_num_conversions == num_conversions_)
+      return last_conversion_data_;
+    expected_num_conversions_ = expected_num_conversions;
+    conversion_waiter_.Run();
+    return last_conversion_data_;
+  }
+
+  size_t num_conversions() { return num_conversions_; }
+
+ private:
+  uint64_t last_conversion_data_ = 0;
+  size_t num_conversions_ = 0;
+  size_t expected_num_conversions_ = 0;
+  base::RunLoop conversion_waiter_;
+};
+
+class ConversionRegistrationBrowserTest : public ContentBrowserTest {
+ public:
+  ConversionRegistrationBrowserTest() {
+    feature_list_.InitAndEnableFeature(features::kConversionMeasurement);
+  }
+
+  void SetUpOnMainThread() override {
+    host_resolver()->AddRule("*", "127.0.0.1");
+    embedded_test_server()->ServeFilesFromSourceDirectory(
+        "content/test/data/conversions");
+    SetupCrossSiteRedirector(embedded_test_server());
+    ASSERT_TRUE(embedded_test_server()->Start());
+
+    https_server_ = std::make_unique<net::EmbeddedTestServer>(
+        net::EmbeddedTestServer::TYPE_HTTPS);
+    https_server_->SetSSLConfig(net::EmbeddedTestServer::CERT_TEST_NAMES);
+    net::test_server::RegisterDefaultHandlers(https_server_.get());
+    https_server_->ServeFilesFromSourceDirectory(
+        "content/test/data/conversions");
+    SetupCrossSiteRedirector(https_server_.get());
+    ASSERT_TRUE(https_server_->Start());
+  }
+
+  WebContents* web_contents() { return shell()->web_contents(); }
+
+  net::EmbeddedTestServer* https_server() { return https_server_.get(); }
+
+ private:
+  base::test::ScopedFeatureList feature_list_;
+  std::unique_ptr<net::EmbeddedTestServer> https_server_;
+};
+
+// Test that full conversion path does not cause any failure when a conversion
+// registration mojo is received.
+// TODO(johnidel): This should really be testing internals of ConversionHost.
+// That is trivial when reporting for conversions is added.
+IN_PROC_BROWSER_TEST_F(ConversionRegistrationBrowserTest,
+                       ConversionRegistration_NoCrash) {
+  EXPECT_TRUE(NavigateToURL(
+      shell(),
+      embedded_test_server()->GetURL("/page_with_conversion_redirect.html")));
+  EXPECT_TRUE(ExecJs(web_contents(), "createTrackingPixel(\"" + kWellKnownUrl +
+                                         "?conversion-data=100\");"));
+
+  ASSERT_NO_FATAL_FAILURE(
+      EXPECT_TRUE(NavigateToURL(shell(), GURL("about:blank"))));
+}
+
+IN_PROC_BROWSER_TEST_F(ConversionRegistrationBrowserTest,
+                       ConversionRegistered_ConversionDataReceived) {
+  EXPECT_TRUE(NavigateToURL(
+      shell(),
+      embedded_test_server()->GetURL("/page_with_conversion_redirect.html")));
+  std::unique_ptr<TestConversionHost> host =
+      TestConversionHost::ReplaceAndGetConversionHost(web_contents());
+
+  EXPECT_TRUE(ExecJs(web_contents(), "registerConversion(123)"));
+  EXPECT_EQ(123UL, host->WaitForNumConversions(1));
+}
+
+IN_PROC_BROWSER_TEST_F(ConversionRegistrationBrowserTest,
+                       ConversionRegistrationNotRedirect_NotReceived) {
+  EXPECT_TRUE(NavigateToURL(
+      shell(),
+      embedded_test_server()->GetURL("/page_with_conversion_redirect.html")));
+  std::unique_ptr<TestConversionHost> host =
+      TestConversionHost::ReplaceAndGetConversionHost(web_contents());
+
+  GURL registration_url = embedded_test_server()->GetURL(
+      "/" + kWellKnownUrl + "?conversion-data=200");
+
+  // Create a load observer that will wait for the redirect to complete. If a
+  // conversion was registered, this redirect would never complete.
+  ResourceLoadObserver load_observer(shell());
+  EXPECT_TRUE(ExecJs(web_contents(),
+                     JsReplace("createTrackingPixel($1);", registration_url)));
+  load_observer.WaitForResourceCompletion(registration_url);
+
+  // Conversion mojo messages are sent on the same message pipe as navigation
+  // messages. Because the conversion would have been sequenced prior to the
+  // navigation message, it would be observed before the NavigateToURL() call
+  // finishes.
+  EXPECT_TRUE(NavigateToURL(shell(), GURL("about:blank")));
+  EXPECT_EQ(0u, host->num_conversions());
+}
+
+IN_PROC_BROWSER_TEST_F(ConversionRegistrationBrowserTest,
+                       ConversionRegistrationInPreload_NotReceived) {
+  std::unique_ptr<TestConversionHost> host =
+      TestConversionHost::ReplaceAndGetConversionHost(web_contents());
+  EXPECT_TRUE(
+      NavigateToURL(shell(), embedded_test_server()->GetURL(
+                                 "/page_with_preload_conversion_ping.html")));
+
+  EXPECT_TRUE(NavigateToURL(shell(), GURL("about:blank")));
+  EXPECT_EQ(0u, host->num_conversions());
+}
+
+IN_PROC_BROWSER_TEST_F(ConversionRegistrationBrowserTest,
+                       ConversionRegistrationNoData_ReceivedZero) {
+  EXPECT_TRUE(NavigateToURL(
+      shell(),
+      embedded_test_server()->GetURL("/page_with_conversion_redirect.html")));
+  std::unique_ptr<TestConversionHost> host =
+      TestConversionHost::ReplaceAndGetConversionHost(web_contents());
+
+  EXPECT_TRUE(ExecJs(web_contents(), "createTrackingPixel(\"server-redirect?" +
+                                         kWellKnownUrl + "\");"));
+
+  // Conversion data should be defaulted to 0.
+  EXPECT_EQ(0UL, host->WaitForNumConversions(1));
+}
+
+IN_PROC_BROWSER_TEST_F(ConversionRegistrationBrowserTest,
+                       ConversionRegisteredFromChildFrame_NotReceived) {
+  EXPECT_TRUE(NavigateToURL(
+      shell(),
+      embedded_test_server()->GetURL("/page_with_subframe_conversion.html")));
+  std::unique_ptr<TestConversionHost> host =
+      TestConversionHost::ReplaceAndGetConversionHost(web_contents());
+
+  GURL redirect_url = embedded_test_server()->GetURL(
+      "/server-redirect?" + kWellKnownUrl + "?conversion-data=200");
+  ResourceLoadObserver load_observer(shell());
+  EXPECT_TRUE(ExecJs(ChildFrameAt(web_contents()->GetMainFrame(), 0),
+                     JsReplace("createTrackingPixel($1);", redirect_url)));
+  load_observer.WaitForResourceCompletion(redirect_url);
+
+  EXPECT_TRUE(NavigateToURL(shell(), GURL("about:blank")));
+  EXPECT_EQ(0u, host->num_conversions());
+}
+
+IN_PROC_BROWSER_TEST_F(
+    ConversionRegistrationBrowserTest,
+    RegisterWithDifferentUrlTypes_ConversionReceivedOrIgnored) {
+  const char kSecureHost[] = "a.test";
+  struct {
+    std::string page_host;
+    std::string redirect_host;
+    bool expected_conversion;
+  } kTestCases[] = {
+      {"localhost" /* page_host */, "localhost" /* redirect_host */,
+       true /* conversion_expected */},
+      {"127.0.0.1" /* page_host */, "127.0.0.1" /* redirect_host */,
+       true /* conversion_expected */},
+      {"insecure.com" /* page_host */, "insecure.com" /* redirect_host */,
+       false /* conversion_expected */},
+      {kSecureHost /* page_host */, kSecureHost /* redirect_host */,
+       true /* conversion_expected */},
+      {"insecure.com" /* page_host */, kSecureHost /* redirect_host */,
+       false /* conversion_expected */},
+      {kSecureHost /* page_host */, "insecure.com" /* redirect_host */,
+       false /* conversion_expected */}};
+
+  for (const auto& test_case : kTestCases) {
+    std::unique_ptr<TestConversionHost> host =
+        TestConversionHost::ReplaceAndGetConversionHost(web_contents());
+
+    // Secure hosts must be served from the https server.
+    net::EmbeddedTestServer* page_server = (test_case.page_host == kSecureHost)
+                                               ? https_server()
+                                               : embedded_test_server();
+    EXPECT_TRUE(NavigateToURL(
+        shell(), page_server->GetURL(test_case.page_host,
+                                     "/page_with_conversion_redirect.html")));
+
+    net::EmbeddedTestServer* redirect_server =
+        (test_case.redirect_host == kSecureHost) ? https_server()
+                                                 : embedded_test_server();
+    GURL redirect_url = redirect_server->GetURL(
+        test_case.redirect_host,
+        "/server-redirect?" + kWellKnownUrl + "?conversion-data=200");
+    ResourceLoadObserver load_observer(shell());
+    EXPECT_TRUE(ExecJs(web_contents(),
+                       JsReplace("createTrackingPixel($1);", redirect_url)));
+
+    // Either wait for a conversion redirect to be received, or wait for the url
+    // to finish loading if we are not expecting a conversions. Because
+    // conversion redirects are blocked, we do not receive completed load
+    // information for them.
+    if (test_case.expected_conversion) {
+      EXPECT_EQ(200UL, host->WaitForNumConversions(1));
+    } else {
+      load_observer.WaitForResourceCompletion(redirect_url);
+    }
+
+    // Navigate the page. By the time the navigation finishes, we will have
+    // received any conversion mojo messages.
+    EXPECT_TRUE(NavigateToURL(shell(), GURL("about:blank")));
+    EXPECT_EQ(test_case.expected_conversion, host->num_conversions());
+  }
+}
+
+}  // namespace content
diff --git a/content/browser/storage_partition_impl.cc b/content/browser/storage_partition_impl.cc
index 8e592a6..14a57bf2 100644
--- a/content/browser/storage_partition_impl.cc
+++ b/content/browser/storage_partition_impl.cc
@@ -1697,6 +1697,11 @@
   return native_file_system_manager_.get();
 }
 
+ConversionManager* StoragePartitionImpl::GetConversionManager() {
+  DCHECK(initialized_);
+  return conversion_manager_.get();
+}
+
 ContentIndexContextImpl* StoragePartitionImpl::GetContentIndexContext() {
   DCHECK(initialized_);
   return content_index_context_.get();
diff --git a/content/browser/storage_partition_impl.h b/content/browser/storage_partition_impl.h
index 078cc37..6cf0151 100644
--- a/content/browser/storage_partition_impl.h
+++ b/content/browser/storage_partition_impl.h
@@ -191,6 +191,7 @@
   PrefetchURLLoaderService* GetPrefetchURLLoaderService();
   CookieStoreContext* GetCookieStoreContext();
   NativeFileSystemManagerImpl* GetNativeFileSystemManager();
+  ConversionManager* GetConversionManager();
 
   // blink::mojom::StoragePartitionService interface.
   void OpenLocalStorage(
diff --git a/content/browser/web_contents/web_contents_impl.cc b/content/browser/web_contents/web_contents_impl.cc
index 61519dfd..975a63c 100644
--- a/content/browser/web_contents/web_contents_impl.cc
+++ b/content/browser/web_contents/web_contents_impl.cc
@@ -48,6 +48,7 @@
 #include "content/browser/browser_plugin/browser_plugin_embedder.h"
 #include "content/browser/browser_plugin/browser_plugin_guest.h"
 #include "content/browser/child_process_security_policy_impl.h"
+#include "content/browser/conversions/conversion_host.h"
 #include "content/browser/devtools/protocol/page_handler.h"
 #include "content/browser/devtools/render_frame_devtools_agent_host.h"
 #include "content/browser/display_cutout/display_cutout_host_impl.h"
@@ -601,6 +602,10 @@
 #if defined(OS_ANDROID)
   display_cutout_host_impl_ = std::make_unique<DisplayCutoutHostImpl>(this);
 #endif
+
+  // ConversionHost takes a weak ref on |this|, so it must be created outside of
+  // the initializer list.
+  conversion_host_ = std::make_unique<ConversionHost>(this);
 }
 
 WebContentsImpl::~WebContentsImpl() {
@@ -1223,6 +1228,11 @@
   return it->second;
 }
 
+void WebContentsImpl::RemoveReceiverSetForTesting(
+    const std::string& interface_name) {
+  RemoveReceiverSet(interface_name);
+}
+
 std::vector<WebContentsImpl*> WebContentsImpl::GetWebContentsAndAllInner() {
   std::vector<WebContentsImpl*> all_contents(1, this);
 
diff --git a/content/browser/web_contents/web_contents_impl.h b/content/browser/web_contents/web_contents_impl.h
index 6078c2d..63ad3ce 100644
--- a/content/browser/web_contents/web_contents_impl.h
+++ b/content/browser/web_contents/web_contents_impl.h
@@ -91,6 +91,7 @@
 enum class PictureInPictureResult;
 class BrowserPluginEmbedder;
 class BrowserPluginGuest;
+class ConversionHost;
 class DisplayCutoutHostImpl;
 class FindRequestManager;
 class InterstitialPageImpl;
@@ -280,6 +281,9 @@
   // interface.
   WebContentsReceiverSet* GetReceiverSet(const std::string& interface_name);
 
+  // Removes a WebContentsReceiverSet so that it can be overridden for testing.
+  void RemoveReceiverSetForTesting(const std::string& interface_name);
+
   // Returns the focused WebContents.
   // If there are multiple inner/outer WebContents (when embedding <webview>,
   // <guestview>, ...) returns the single one containing the currently focused
@@ -1875,6 +1879,9 @@
   // Manages media players, CDMs, and power save blockers for media.
   std::unique_ptr<MediaWebContentsObserver> media_web_contents_observer_;
 
+  // Observes registration of conversions.
+  std::unique_ptr<ConversionHost> conversion_host_;
+
 #if BUILDFLAG(ENABLE_PLUGINS)
   // Observes pepper playback changes, and notifies MediaSession.
   std::unique_ptr<PepperPlaybackObserver> pepper_playback_observer_;
diff --git a/content/child/runtime_features.cc b/content/child/runtime_features.cc
index b9123ff..9c4ef41 100644
--- a/content/child/runtime_features.cc
+++ b/content/child/runtime_features.cc
@@ -332,35 +332,37 @@
   // function and using feature string name with EnableFeatureFromString.
   const RuntimeFeatureToChromiumFeatureMap<const char*>
       runtimeFeatureNameToChromiumFeatureMapping[] = {
-          {"FontSrcLocalMatching", features::kFontSrcLocalMatching,
-           kUseFeatureState},
-          {"LegacyWindowsDWriteFontFallback",
-           features::kLegacyWindowsDWriteFontFallback, kUseFeatureState},
           {"AddressSpace", network::features::kBlockNonSecureExternalRequests,
            kEnableOnly},
-          {"BlockCredentialedSubresources",
-           features::kBlockCredentialedSubresources, kDisableOnly},
           {"AllowContentInitiatedDataUrlNavigations",
            features::kAllowContentInitiatedDataUrlNavigations,
            kUseFeatureState},
-          {"LayoutNG", blink::features::kLayoutNG, kUseFeatureState},
-          {"UserAgentClientHint", features::kUserAgentClientHint, kEnableOnly},
           {"AudioWorkletRealtimeThread",
            blink::features::kAudioWorkletRealtimeThread, kEnableOnly},
-          {"TrustedDOMTypes", features::kTrustedDOMTypes, kEnableOnly},
-          {"IgnoreCrossOriginWindowWhenNamedAccessOnWindow",
-           blink::features::kIgnoreCrossOriginWindowWhenNamedAccessOnWindow,
-           kEnableOnly},
-          {"StorageAccessAPI", blink::features::kStorageAccessAPI, kEnableOnly},
-          {"ShadowDOMV0", blink::features::kWebComponentsV0Enabled,
-           kEnableOnly},
-          {"CustomElementsV0", blink::features::kWebComponentsV0Enabled,
-           kEnableOnly},
-          {"HTMLImports", blink::features::kWebComponentsV0Enabled,
+          {"BlockCredentialedSubresources",
+           features::kBlockCredentialedSubresources, kDisableOnly},
+          {"ConversionMeasurement", features::kConversionMeasurement,
            kEnableOnly},
           {"CSSReducedFontLoadingInvalidations",
            blink::features::kCSSReducedFontLoadingInvalidations,
            kUseFeatureState},
+          {"CustomElementsV0", blink::features::kWebComponentsV0Enabled,
+           kEnableOnly},
+          {"FontSrcLocalMatching", features::kFontSrcLocalMatching,
+           kUseFeatureState},
+          {"HTMLImports", blink::features::kWebComponentsV0Enabled,
+           kEnableOnly},
+          {"IgnoreCrossOriginWindowWhenNamedAccessOnWindow",
+           blink::features::kIgnoreCrossOriginWindowWhenNamedAccessOnWindow,
+           kEnableOnly},
+          {"LayoutNG", blink::features::kLayoutNG, kUseFeatureState},
+          {"LegacyWindowsDWriteFontFallback",
+           features::kLegacyWindowsDWriteFontFallback, kUseFeatureState},
+          {"ShadowDOMV0", blink::features::kWebComponentsV0Enabled,
+           kEnableOnly},
+          {"StorageAccessAPI", blink::features::kStorageAccessAPI, kEnableOnly},
+          {"TrustedDOMTypes", features::kTrustedDOMTypes, kEnableOnly},
+          {"UserAgentClientHint", features::kUserAgentClientHint, kEnableOnly},
 
       };
   for (const auto& mapping : runtimeFeatureNameToChromiumFeatureMapping) {
diff --git a/content/test/BUILD.gn b/content/test/BUILD.gn
index 0aea7da4..32c1d90 100644
--- a/content/test/BUILD.gn
+++ b/content/test/BUILD.gn
@@ -847,6 +847,7 @@
     "../browser/child_process_security_policy_browsertest.cc",
     "../browser/content_index/content_index_browsertest.cc",
     "../browser/content_service_browsertest.cc",
+    "../browser/conversions/conversion_registration_browsertest.cc",
     "../browser/cross_origin_opener_policy_browsertest.cc",
     "../browser/cross_site_transfer_browsertest.cc",
     "../browser/data_decoder_browsertest.cc",
@@ -1563,6 +1564,7 @@
     "../browser/code_cache/generated_code_cache_unittest.cc",
     "../browser/content_index/content_index_database_unittest.cc",
     "../browser/content_index/content_index_service_impl_unittest.cc",
+    "../browser/conversions/conversion_host_unittest.cc",
     "../browser/conversions/conversion_policy_unittest.cc",
     "../browser/conversions/conversion_storage_sql_unittest.cc",
     "../browser/conversions/conversion_storage_unittest.cc",
diff --git a/content/test/data/conversions/page_with_conversion_redirect.html b/content/test/data/conversions/page_with_conversion_redirect.html
new file mode 100644
index 0000000..fde54c3a
--- /dev/null
+++ b/content/test/data/conversions/page_with_conversion_redirect.html
@@ -0,0 +1,4 @@
+<html>
+  <script src="register_conversion.js"></script>
+  This page has a script which creates images that redirect to the conversion registration endpoint.
+</html>
diff --git a/content/test/data/conversions/page_with_preload_conversion_ping.html b/content/test/data/conversions/page_with_preload_conversion_ping.html
new file mode 100644
index 0000000..69c7dd7
--- /dev/null
+++ b/content/test/data/conversions/page_with_preload_conversion_ping.html
@@ -0,0 +1,5 @@
+<html>
+  <head>
+    <link rel="preload" href="server-redirect?.well-known/register-conversion?conversion-data=1" as="image">
+  </head>
+</html>
diff --git a/content/test/data/conversions/page_with_subframe_conversion.html b/content/test/data/conversions/page_with_subframe_conversion.html
new file mode 100644
index 0000000..dad1068
--- /dev/null
+++ b/content/test/data/conversions/page_with_subframe_conversion.html
@@ -0,0 +1,4 @@
+<html>
+  <script src="register_conversion.js"></script>
+  <iframe src="page_with_conversion_redirect.html"></iframe>
+</html>
diff --git a/content/test/data/conversions/register_conversion.js b/content/test/data/conversions/register_conversion.js
new file mode 100644
index 0000000..90246b4
--- /dev/null
+++ b/content/test/data/conversions/register_conversion.js
@@ -0,0 +1,17 @@
+// Copyright 2020 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+function registerConversion(data) {
+  let img = document.createElement("img");
+  img.src =
+      "server-redirect?.well-known/register-conversion?conversion-data=" +
+      data;
+  document.body.appendChild(img);
+}
+
+function createTrackingPixel(url) {
+  let img = document.createElement("img");
+  img.src = url;
+  document.body.appendChild(img);
+}
diff --git a/third_party/blink/public/mojom/BUILD.gn b/third_party/blink/public/mojom/BUILD.gn
index 65f296c..690dac0 100644
--- a/third_party/blink/public/mojom/BUILD.gn
+++ b/third_party/blink/public/mojom/BUILD.gn
@@ -35,6 +35,7 @@
     "commit_result/commit_result.mojom",
     "contacts/contacts_manager.mojom",
     "content_index/content_index.mojom",
+    "conversions/conversions.mojom",
     "cookie_store/cookie_store.mojom",
     "crash/crash_memory_metrics_reporter.mojom",
     "credentialmanager/credential_manager.mojom",
diff --git a/third_party/blink/public/mojom/conversions/OWNERS b/third_party/blink/public/mojom/conversions/OWNERS
new file mode 100644
index 0000000..08850f4
--- /dev/null
+++ b/third_party/blink/public/mojom/conversions/OWNERS
@@ -0,0 +1,2 @@
+per-file *.mojom=set noparent
+per-file *.mojom=file://ipc/SECURITY_OWNERS
diff --git a/third_party/blink/public/mojom/conversions/conversions.mojom b/third_party/blink/public/mojom/conversions/conversions.mojom
new file mode 100644
index 0000000..2182910a6
--- /dev/null
+++ b/third_party/blink/public/mojom/conversions/conversions.mojom
@@ -0,0 +1,25 @@
+// Copyright 2020 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+module blink.mojom;
+
+import "url/mojom/origin.mojom";
+
+struct Conversion {
+  // Origin of the conversion registration redirect.
+  url.mojom.Origin reporting_origin;
+
+  // Conversion data specified in conversion redirect. 0 is used as a
+  // default if none is provided.
+  uint64 conversion_data = 0;
+};
+
+// Sent from renderer to browser process when a resource request matching the
+// .well-known conversion registration path is intercepted.
+interface ConversionHost {
+  // Registers a conversion on the site with data provided in the conversion
+  // registration redirect. Only called for requests loaded in the top-level
+  // browsing context.
+  RegisterConversion(Conversion conversion);
+};
\ No newline at end of file
diff --git a/third_party/blink/renderer/core/loader/base_fetch_context.cc b/third_party/blink/renderer/core/loader/base_fetch_context.cc
index 64341a44..92bd7d39 100644
--- a/third_party/blink/renderer/core/loader/base_fetch_context.cc
+++ b/third_party/blink/renderer/core/loader/base_fetch_context.cc
@@ -55,6 +55,13 @@
           filter->IsAdResource(request.Url(), request.GetRequestContext()));
 }
 
+bool BaseFetchContext::SendConversionRequestInsteadOfRedirecting(
+    const KURL& url,
+    ResourceRequest::RedirectStatus redirect_status,
+    SecurityViolationReportingPolicy reporting_policy) const {
+  return false;
+}
+
 void BaseFetchContext::PrintAccessDeniedMessage(const KURL& url) const {
   if (url.IsNull())
     return;
@@ -234,6 +241,11 @@
     return ResourceRequestBlockedReason::kOther;
   }
 
+  if (SendConversionRequestInsteadOfRedirecting(url, redirect_status,
+                                                reporting_policy)) {
+    return ResourceRequestBlockedReason::kOther;
+  }
+
   // Let the client have the final say into whether or not the load should
   // proceed.
   if (GetSubresourceFilter() && type != ResourceType::kImportResource) {
diff --git a/third_party/blink/renderer/core/loader/base_fetch_context.h b/third_party/blink/renderer/core/loader/base_fetch_context.h
index 69605437..4ccce5e 100644
--- a/third_party/blink/renderer/core/loader/base_fetch_context.h
+++ b/third_party/blink/renderer/core/loader/base_fetch_context.h
@@ -69,6 +69,14 @@
   bool CalculateIfAdSubresource(const ResourceRequest& resource_request,
                                 ResourceType type) override;
 
+  // Returns whether a request to |url| is a conversion registration request.
+  // Conversion registration requests are redirects to a well-known conversion
+  // registration endpoint.
+  virtual bool SendConversionRequestInsteadOfRedirecting(
+      const KURL& url,
+      ResourceRequest::RedirectStatus redirect_status,
+      SecurityViolationReportingPolicy reporting_policy) const;
+
   virtual const ContentSecurityPolicy* GetContentSecurityPolicy() const = 0;
 
  protected:
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 82bbc38..3d19824 100644
--- a/third_party/blink/renderer/core/loader/frame_fetch_context.cc
+++ b/third_party/blink/renderer/core/loader/frame_fetch_context.cc
@@ -36,8 +36,11 @@
 #include "base/feature_list.h"
 #include "base/optional.h"
 #include "build/build_config.h"
+#include "mojo/public/cpp/bindings/associated_remote.h"
+#include "third_party/blink/public/common/associated_interfaces/associated_interface_provider.h"
 #include "third_party/blink/public/common/client_hints/client_hints.h"
 #include "third_party/blink/public/common/device_memory/approximated_device_memory.h"
+#include "third_party/blink/public/mojom/conversions/conversions.mojom-blink.h"
 #include "third_party/blink/public/mojom/feature_policy/feature_policy.mojom-blink.h"
 #include "third_party/blink/public/mojom/fetch/fetch_api_request.mojom-blink.h"
 #include "third_party/blink/public/mojom/loader/request_context_frame_type.mojom-blink.h"
@@ -87,6 +90,7 @@
 #include "third_party/blink/renderer/core/timing/dom_window_performance.h"
 #include "third_party/blink/renderer/core/timing/performance.h"
 #include "third_party/blink/renderer/core/timing/window_performance.h"
+#include "third_party/blink/renderer/core/url/url_search_params.h"
 #include "third_party/blink/renderer/platform/bindings/script_forbidden_scope.h"
 #include "third_party/blink/renderer/platform/exported/wrapped_resource_request.h"
 #include "third_party/blink/renderer/platform/instrumentation/histogram.h"
@@ -1057,6 +1061,67 @@
       resource_request, type, known_ad);
 }
 
+bool FrameFetchContext::SendConversionRequestInsteadOfRedirecting(
+    const KURL& url,
+    ResourceRequest::RedirectStatus redirect_status,
+    SecurityViolationReportingPolicy reporting_policy) const {
+  if (!RuntimeEnabledFeatures::ConversionMeasurementEnabled())
+    return false;
+
+  // Only register conversions pings that are redirects in the main frame.
+  // TODO(https://crbug.com/1042919): This should also validate that the
+  // redirect is same origin to ensure that the reporting domain has consented
+  // to the registration event.
+  if (!frame_or_imported_document_ || !GetFrame() ||
+      !GetFrame()->IsMainFrame() ||
+      redirect_status != ResourceRequest::RedirectStatus::kFollowedRedirect) {
+    return false;
+  }
+
+  const char kWellKnownConversionRegsitrationPath[] =
+      "/.well-known/register-conversion";
+  if (url.GetPath() != kWellKnownConversionRegsitrationPath)
+    return false;
+
+  // Only allow conversion registration on secure pages with a secure conversion
+  // redirect.
+  scoped_refptr<const SecurityOrigin> redirect_origin =
+      SecurityOrigin::Create(url);
+  if (!GetFrame()
+           ->GetSecurityContext()
+           ->GetSecurityOrigin()
+           ->IsPotentiallyTrustworthy() ||
+      !redirect_origin->IsPotentiallyTrustworthy()) {
+    return false;
+  }
+
+  // Only report conversions for requests with reporting enabled (i.e. do not
+  // count preload requests). However, return true.
+  if (reporting_policy == SecurityViolationReportingPolicy::kSuppressReporting)
+    return true;
+
+  mojom::blink::ConversionPtr conversion = mojom::blink::Conversion::New();
+  conversion->reporting_origin = SecurityOrigin::Create(url);
+  conversion->conversion_data = 0UL;
+
+  const char kConversionDataParam[] = "conversion-data";
+  URLSearchParams* search_params = URLSearchParams::Create(url.Query());
+  if (search_params->has(kConversionDataParam)) {
+    bool is_valid_integer = false;
+    uint64_t data = search_params->get(kConversionDataParam)
+                        .ToUInt64Strict(&is_valid_integer);
+
+    // Default invalid params to 0.
+    conversion->conversion_data = is_valid_integer ? data : 0UL;
+  }
+
+  mojo::AssociatedRemote<mojom::blink::ConversionHost> conversion_host;
+  GetFrame()->GetRemoteNavigationAssociatedInterfaces()->GetInterface(
+      &conversion_host);
+  conversion_host->RegisterConversion(std::move(conversion));
+  return true;
+}
+
 mojo::PendingReceiver<mojom::blink::WorkerTimingContainer>
 FrameFetchContext::TakePendingWorkerTimingReceiver(int request_id) {
   return MasterDocumentLoader()->TakePendingWorkerTimingReceiver(request_id);
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 a50534a..280ece0 100644
--- a/third_party/blink/renderer/core/loader/frame_fetch_context.h
+++ b/third_party/blink/renderer/core/loader/frame_fetch_context.h
@@ -109,6 +109,11 @@
   bool CalculateIfAdSubresource(const ResourceRequest& resource_request,
                                 ResourceType type) override;
 
+  bool SendConversionRequestInsteadOfRedirecting(
+      const KURL& url,
+      ResourceRequest::RedirectStatus redirect_status,
+      SecurityViolationReportingPolicy reporting_policy) const override;
+
   mojo::PendingReceiver<mojom::blink::WorkerTimingContainer>
   TakePendingWorkerTimingReceiver(int request_id) override;
 
diff --git a/third_party/blink/renderer/platform/runtime_enabled_features.json5 b/third_party/blink/renderer/platform/runtime_enabled_features.json5
index 6bc3a10..9ab39550 100644
--- a/third_party/blink/renderer/platform/runtime_enabled_features.json5
+++ b/third_party/blink/renderer/platform/runtime_enabled_features.json5
@@ -369,6 +369,10 @@
       status: "experimental",
     },
     {
+      name: "ConversionMeasurement",
+      status: "test",
+    },
+    {
       name: "CookieDeprecationMessages",
       status: "experimental",
     },