ORB: Signal blocked resources by injecting a network error.

"ORB v0.1" handles response blocking CORB-style, by injecting an empty
response. This change adapts blocked response handling to the ORB
spec, by injecting a network error instead. This is controlled by a
feature flag (OpaqueResponseBlockingV02) to allow for staged rollout
and rollback.

Bug: 1178928
Change-Id: I54852059ceb04194158a22b98da87e4f52f8d514
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/3785025
Reviewed-by: Matt Menke <mmenke@chromium.org>
Commit-Queue: Daniel Vogelheim <vogelheim@chromium.org>
Reviewed-by: Ɓukasz Anforowicz <lukasza@chromium.org>
Reviewed-by: Philip Rogers <pdr@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1048266}
diff --git a/content/browser/loader/cross_site_document_blocking_browsertest.cc b/content/browser/loader/cross_site_document_blocking_browsertest.cc
index b6a12b5..e500bcb 100644
--- a/content/browser/loader/cross_site_document_blocking_browsertest.cc
+++ b/content/browser/loader/cross_site_document_blocking_browsertest.cc
@@ -73,12 +73,18 @@
   kShouldBeBlocked = 1 << 0,
   kShouldBeSniffed = 1 << 1,
 
+  kBlockResourcesAsError = 1 << 2,
+
   kShouldBeAllowedWithoutSniffing = 0,
   kShouldBeBlockedWithoutSniffing = kShouldBeBlocked,
   kShouldBeSniffedAndAllowed = kShouldBeSniffed,
   kShouldBeSniffedAndBlocked = kShouldBeSniffed | kShouldBeBlocked,
 };
 
+constexpr CorbExpectations BlockAsError(const CorbExpectations expectations) {
+  return CorbExpectations(expectations | kBlockResourcesAsError);
+}
+
 std::ostream& operator<<(std::ostream& os, const CorbExpectations& value) {
   if (value == 0) {
     os << "(none)";
@@ -90,6 +96,8 @@
     os << "kShouldBeBlocked ";
   if (0 != (value & kShouldBeSniffed))
     os << "kShouldBeSniffed ";
+  if (0 != (value & kBlockResourcesAsError))
+    os << "kBlockResourcesAsError ";
   os << ")";
   return os;
 }
@@ -250,20 +258,31 @@
   void Verify(CorbExpectations expectations,
               const std::string& expected_resource_body) {
     if (0 != (expectations & kShouldBeBlocked)) {
-      ASSERT_EQ(net::OK, completion_status().error_code);
-
       // Verify that the body is empty.
       EXPECT_EQ("", response_body());
       EXPECT_EQ(0, completion_status().decoded_body_length);
 
-      // Verify that other response parts have been sanitized.
-      EXPECT_EQ(0u, response_head()->content_length);
-      const std::string& headers = response_head()->headers->raw_headers();
-      EXPECT_THAT(headers, Not(HasSubstr("Content-Length")));
-      EXPECT_THAT(headers, Not(HasSubstr("Content-Type")));
-
       // Verify that the console message would have been printed.
       EXPECT_TRUE(completion_status().should_report_corb_blocking);
+
+      // Verify the response code & headers, which depends on whether the
+      // response is blocked as an error, or as an empty response.
+      if (0 != (expectations & kBlockResourcesAsError)) {
+        ASSERT_EQ(net::ERR_BLOCKED_BY_ORB, completion_status().error_code);
+        ASSERT_FALSE(response_head());
+      } else {
+        ASSERT_EQ(net::OK, completion_status().error_code);
+
+        // Verify that response has no content.
+        EXPECT_EQ(0u, response_head()->content_length);
+
+        // Verify that response headers have been sanitized.
+        size_t iter = 0;
+        std::string name, value;
+        EXPECT_FALSE(response_head()->headers->EnumerateHeaderLines(
+            &iter, &name, &value));
+        EXPECT_EQ(iter, 0u);
+      }
     } else {
       ASSERT_EQ(net::OK, completion_status().error_code);
       EXPECT_FALSE(completion_status().should_report_corb_blocking);
@@ -475,6 +494,7 @@
   kWithCORBProtectionSniffing,
   kWithoutCORBProtectionSniffing,
   kWithORBv01,
+  kWithORBv02,
 };
 struct ImgTestParams {
   const char* resource;
@@ -492,18 +512,30 @@
             /* enabled_features= */ {network::features::
                                          kCORBProtectionSniffing},
             /* disabled_features= */ {
-                network::features::kOpaqueResponseBlockingV01});
+                network::features::kOpaqueResponseBlockingV01,
+                network::features::kOpaqueResponseBlockingV02});
         break;
       case TestMode::kWithoutCORBProtectionSniffing:
         scoped_feature_list_.InitWithFeatures(
             /* enabled_features= */ {},
             /* disabled_features= */ {
                 network::features::kCORBProtectionSniffing,
-                network::features::kOpaqueResponseBlockingV01});
+                network::features::kOpaqueResponseBlockingV01,
+                network::features::kOpaqueResponseBlockingV02});
         break;
       case TestMode::kWithORBv01:
-        scoped_feature_list_.InitAndEnableFeature(
-            network::features::kOpaqueResponseBlockingV01);
+        scoped_feature_list_.InitWithFeatures(
+            /* enabled_features= */ {network::features::
+                                         kOpaqueResponseBlockingV01},
+            /* disabled_features= */ {
+                network::features::kOpaqueResponseBlockingV02});
+        break;
+      case TestMode::kWithORBv02:
+        scoped_feature_list_.InitWithFeatures(
+            /* enabled_features= */
+            {network::features::kOpaqueResponseBlockingV01,
+             network::features::kOpaqueResponseBlockingV02},
+            /* disabled_features= */ {});
         break;
     }
   }
@@ -594,24 +626,32 @@
   INSTANTIATE_TEST_SUITE_P(                                                    \
       ORBv01_##tag, CrossSiteDocumentBlockingImgElementTest,                   \
       ::testing::Values(                                                       \
-          ImgTestParams{resource, expectations, TestMode::kWithORBv01}));
+          ImgTestParams{resource, expectations, TestMode::kWithORBv01}));      \
+  INSTANTIATE_TEST_SUITE_P(                                                    \
+      ORBv02_##tag, CrossSiteDocumentBlockingImgElementTest,                   \
+      ::testing::Values(ImgTestParams{resource, BlockAsError(expectations),    \
+                                      TestMode::kWithORBv02}));
 
-#define IMG_TEST_WITH_DIFFERENT_ORB_EXPECTATIONS(                           \
-    tag, resource, corb_expectations, orb_expectations)                     \
-  INSTANTIATE_TEST_SUITE_P(ProtectionSniffingOn_##tag,                      \
-                           CrossSiteDocumentBlockingImgElementTest,         \
-                           ::testing::Values(ImgTestParams{                 \
-                               resource, corb_expectations,                 \
-                               TestMode::kWithoutCORBProtectionSniffing})); \
-  INSTANTIATE_TEST_SUITE_P(ProtectionSniffingOff_##tag,                     \
-                           CrossSiteDocumentBlockingImgElementTest,         \
-                           ::testing::Values(ImgTestParams{                 \
-                               resource, corb_expectations,                 \
-                               TestMode::kWithCORBProtectionSniffing}));    \
-  INSTANTIATE_TEST_SUITE_P(                                                 \
-      ORBv01_##tag, CrossSiteDocumentBlockingImgElementTest,                \
-      ::testing::Values(                                                    \
-          ImgTestParams{resource, orb_expectations, TestMode::kWithORBv01}));
+#define IMG_TEST_WITH_DIFFERENT_ORB_EXPECTATIONS(                             \
+    tag, resource, corb_expectations, orb_expectations)                       \
+  INSTANTIATE_TEST_SUITE_P(ProtectionSniffingOn_##tag,                        \
+                           CrossSiteDocumentBlockingImgElementTest,           \
+                           ::testing::Values(ImgTestParams{                   \
+                               resource, corb_expectations,                   \
+                               TestMode::kWithoutCORBProtectionSniffing}));   \
+  INSTANTIATE_TEST_SUITE_P(ProtectionSniffingOff_##tag,                       \
+                           CrossSiteDocumentBlockingImgElementTest,           \
+                           ::testing::Values(ImgTestParams{                   \
+                               resource, corb_expectations,                   \
+                               TestMode::kWithCORBProtectionSniffing}));      \
+  INSTANTIATE_TEST_SUITE_P(                                                   \
+      ORBv01_##tag, CrossSiteDocumentBlockingImgElementTest,                  \
+      ::testing::Values(                                                      \
+          ImgTestParams{resource, orb_expectations, TestMode::kWithORBv01})); \
+  INSTANTIATE_TEST_SUITE_P(                                                   \
+      ORBv02_##tag, CrossSiteDocumentBlockingImgElementTest,                  \
+      ::testing::Values(ImgTestParams{                                        \
+          resource, BlockAsError(orb_expectations), TestMode::kWithORBv02}));
 
 // The following are files under content/test/data/site_isolation. All
 // should be disallowed for cross site XHR under the document blocking policy.
@@ -730,8 +770,18 @@
             network::features::kCORBProtectionSniffing);
         break;
       case TestMode::kWithORBv01:
-        scoped_feature_list_.InitAndEnableFeature(
-            network::features::kOpaqueResponseBlockingV01);
+        scoped_feature_list_.InitWithFeatures(
+            /* enabled_features= */ {network::features::
+                                         kOpaqueResponseBlockingV01},
+            /* disabled_features= */ {
+                network::features::kOpaqueResponseBlockingV02});
+        break;
+      case TestMode::kWithORBv02:
+        scoped_feature_list_.InitWithFeatures(
+            /* enabled_features= */
+            {network::features::kOpaqueResponseBlockingV01,
+             network::features::kOpaqueResponseBlockingV02},
+            /* disabled_features= */ {});
         break;
     }
   }
diff --git a/net/base/net_error_list.h b/net/base/net_error_list.h
index 772d699..20997e9 100644
--- a/net/base/net_error_list.h
+++ b/net/base/net_error_list.h
@@ -112,7 +112,7 @@
 
 // The request failed because the response was delivered along with requirements
 // which are not met ('X-Frame-Options' and 'Content-Security-Policy' ancestor
-// checks and 'Cross-Origin-Resource-Policy', for instance).
+// checks and 'Cross-Origin-Resource-Policy' for instance).
 NET_ERROR(BLOCKED_BY_RESPONSE, -27)
 
 // Error -28 was removed (BLOCKED_BY_XSS_AUDITOR).
@@ -127,6 +127,9 @@
 // The request was blocked because of no H/2 or QUIC session.
 NET_ERROR(H2_OR_QUIC_REQUIRED, -31)
 
+// The request was blocked by CORB or ORB.
+NET_ERROR(BLOCKED_BY_ORB, -32)
+
 // A connection was closed (corresponding to a TCP FIN).
 NET_ERROR(CONNECTION_CLOSED, -100)
 
diff --git a/services/network/public/cpp/corb/corb_api.cc b/services/network/public/cpp/corb/corb_api.cc
index 289e990..bde4d63 100644
--- a/services/network/public/cpp/corb/corb_api.cc
+++ b/services/network/public/cpp/corb/corb_api.cc
@@ -120,10 +120,11 @@
   }
 
   bool ShouldReportBlockedResponse() const override {
-    if (is_orb_enabled_)
-      return orb_analyzer_->ShouldReportBlockedResponse();
-    else
-      return corb_analyzer_->ShouldReportBlockedResponse();
+    return GetEnabledAnalyzer().ShouldReportBlockedResponse();
+  }
+
+  BlockedResponseHandling ShouldHandleBlockedResponseAs() const override {
+    return GetEnabledAnalyzer().ShouldHandleBlockedResponseAs();
   }
 
  private:
@@ -137,6 +138,13 @@
     return is_orb_enabled_ ? orb_decision_ : corb_decision_;
   }
 
+  const ResponseAnalyzer& GetEnabledAnalyzer() const {
+    if (is_orb_enabled_)
+      return *orb_analyzer_;
+    else
+      return *corb_analyzer_;
+  }
+
   const std::unique_ptr<CrossOriginReadBlocking::CorbResponseAnalyzer>
       corb_analyzer_;
   const std::unique_ptr<OpaqueResponseBlockingAnalyzer> orb_analyzer_;
diff --git a/services/network/public/cpp/corb/corb_api.h b/services/network/public/cpp/corb/corb_api.h
index ada1a99..4f440cc 100644
--- a/services/network/public/cpp/corb/corb_api.h
+++ b/services/network/public/cpp/corb/corb_api.h
@@ -49,6 +49,13 @@
     kSniffMore,
   };
 
+  // Decision for how to signal a blocking decision to the network stack and
+  // the application.
+  enum class BlockedResponseHandling {
+    kEmptyResponse,
+    kNetworkError,
+  };
+
   // The Init method should be called exactly once after getting the
   // ResponseAnalyzer from the Create method.  The Init method attempts to
   // calculate the `Decision` based on the HTTP response headers.  If
@@ -81,6 +88,9 @@
   // warning message written to the DevTools console.
   virtual bool ShouldReportBlockedResponse() const = 0;
 
+  // How should a blocked response be treated?
+  virtual BlockedResponseHandling ShouldHandleBlockedResponseAs() const = 0;
+
   virtual ~ResponseAnalyzer();
 };
 
diff --git a/services/network/public/cpp/corb/corb_impl.cc b/services/network/public/cpp/corb/corb_impl.cc
index 4b5d83c3..f387baf1 100644
--- a/services/network/public/cpp/corb/corb_impl.cc
+++ b/services/network/public/cpp/corb/corb_impl.cc
@@ -1029,6 +1029,13 @@
   return true;
 }
 
+ResponseAnalyzer::BlockedResponseHandling
+CrossOriginReadBlocking::CorbResponseAnalyzer::ShouldHandleBlockedResponseAs()
+    const {
+  // CORB wants blocked responses to be empty responses.
+  return ResponseAnalyzer::BlockedResponseHandling::kEmptyResponse;
+}
+
 Decision CrossOriginReadBlocking::CorbResponseAnalyzer::GetCorbDecision() {
   if (ShouldBlock())
     return Decision::kBlock;
diff --git a/services/network/public/cpp/corb/corb_impl.h b/services/network/public/cpp/corb/corb_impl.h
index cb9d8dd..d925980 100644
--- a/services/network/public/cpp/corb/corb_impl.h
+++ b/services/network/public/cpp/corb/corb_impl.h
@@ -118,6 +118,7 @@
     Decision Sniff(base::StringPiece data) override;
     Decision HandleEndOfSniffableResponseBody() override;
     bool ShouldReportBlockedResponse() const override;
+    BlockedResponseHandling ShouldHandleBlockedResponseAs() const override;
 
     class ConfirmationSniffer;
     class SimpleConfirmationSniffer;
diff --git a/services/network/public/cpp/corb/orb_impl.cc b/services/network/public/cpp/corb/orb_impl.cc
index 59a754d..17b32b23 100644
--- a/services/network/public/cpp/corb/orb_impl.cc
+++ b/services/network/public/cpp/corb/orb_impl.cc
@@ -14,6 +14,7 @@
 #include "net/http/http_util.h"
 #include "net/url_request/url_request.h"
 #include "services/network/public/cpp/corb/corb_impl.h"
+#include "services/network/public/cpp/features.h"
 #include "services/network/public/cpp/resource_request.h"
 #include "services/network/public/mojom/url_response_head.mojom.h"
 
@@ -437,6 +438,16 @@
   return !is_empty_response_ && is_http_status_okay_;
 }
 
+ResponseAnalyzer::BlockedResponseHandling
+OpaqueResponseBlockingAnalyzer::ShouldHandleBlockedResponseAs() const {
+  // "ORB v0.1" uses CORB-style error handling with injecting an empty response.
+  // Later versions use ORB-specified error handling, by injecting a network
+  // error.
+  return base::FeatureList::IsEnabled(features::kOpaqueResponseBlockingV02)
+             ? BlockedResponseHandling::kNetworkError
+             : BlockedResponseHandling::kEmptyResponse;
+}
+
 void OpaqueResponseBlockingAnalyzer::ReportOrbBlockedAndCorbDidnt() const {
   // We encountered a scenario where ORB may block more than CORB and therefore
   // let's log some extra data that may help us understand the kind of
diff --git a/services/network/public/cpp/corb/orb_impl.h b/services/network/public/cpp/corb/orb_impl.h
index 5cbe7acd..a406851 100644
--- a/services/network/public/cpp/corb/orb_impl.h
+++ b/services/network/public/cpp/corb/orb_impl.h
@@ -40,6 +40,7 @@
   Decision Sniff(base::StringPiece data) override;
   Decision HandleEndOfSniffableResponseBody() override;
   bool ShouldReportBlockedResponse() const override;
+  BlockedResponseHandling ShouldHandleBlockedResponseAs() const override;
 
   // TODO(https://crbug.com/1178928): Remove this once we gather enough
   // DumpWithoutCrashing data.
diff --git a/services/network/public/cpp/features.cc b/services/network/public/cpp/features.cc
index c5a9c0c..67726d2 100644
--- a/services/network/public/cpp/features.cc
+++ b/services/network/public/cpp/features.cc
@@ -124,6 +124,13 @@
 const base::Feature kOpaqueResponseBlockingV01{
     "OpaqueResponseBlockingV01", base::FEATURE_DISABLED_BY_DEFAULT};
 
+// Enables ORB blocked responses being treated as errors (according to the spec)
+// rather than the current, CORB-style handling of injecting an empty response.
+// This is ORB v0.2.
+// This should only be enabled when ORB v0.1 is, too.
+const base::Feature kOpaqueResponseBlockingV02{
+    "OpaqueResponseBlockingV02", base::FEATURE_DISABLED_BY_DEFAULT};
+
 // Enables preprocessing requests with the Trust Tokens API Fetch flags set,
 // and handling their responses, according to the protocol.
 // (See https://github.com/WICG/trust-token-api.)
diff --git a/services/network/public/cpp/features.h b/services/network/public/cpp/features.h
index 864f6f2..9d172c6 100644
--- a/services/network/public/cpp/features.h
+++ b/services/network/public/cpp/features.h
@@ -43,6 +43,8 @@
 extern const base::Feature kMdnsResponderGeneratedNameListing;
 COMPONENT_EXPORT(NETWORK_CPP)
 extern const base::Feature kOpaqueResponseBlockingV01;
+COMPONENT_EXPORT(NETWORK_CPP)
+extern const base::Feature kOpaqueResponseBlockingV02;
 
 COMPONENT_EXPORT(NETWORK_CPP)
 extern const base::Feature kTrustTokens;
diff --git a/services/network/url_loader.cc b/services/network/url_loader.cc
index 58fb0a3..ea3404a 100644
--- a/services/network/url_loader.cc
+++ b/services/network/url_loader.cc
@@ -1679,31 +1679,14 @@
   // Read Blocking / CORB).
   if (factory_params_.is_corb_enabled) {
     corb_analyzer_ = corb::ResponseAnalyzer::Create(per_factory_corb_state_);
+    is_more_corb_sniffing_needed_ = true;
     auto decision =
         corb_analyzer_->Init(url_request_->url(), url_request_->initiator(),
                              request_mode_, *response_);
-    switch (decision) {
-      case network::corb::ResponseAnalyzer::Decision::kBlock: {
-        bool should_report_corb_blocking =
-            corb_analyzer_->ShouldReportBlockedResponse();
-        corb_analyzer_.reset();
-        is_more_corb_sniffing_needed_ = false;
-        if (BlockResponseForCorb(should_report_corb_blocking) ==
-            kWillCancelRequest)
-          return;
-        break;
-      }
-
-      case network::corb::ResponseAnalyzer::Decision::kAllow:
-        corb_analyzer_.reset();
-        is_more_corb_sniffing_needed_ = false;
-        break;
-
-      case network::corb::ResponseAnalyzer::Decision::kSniffMore:
-        is_more_corb_sniffing_needed_ = true;
-        break;
-    }
+    if (MaybeBlockResponseForCorb(decision))
+      return;
   }
+
   if ((options_ & mojom::kURLLoadOptionSniffMimeType)) {
     if (ShouldSniffContent(url_request_->url(), *response_)) {
       // We're going to look at the data before deciding what the content type
@@ -1847,24 +1830,8 @@
                     corb_decision);
         }
 
-        switch (corb_decision) {
-          case network::corb::ResponseAnalyzer::Decision::kBlock: {
-            bool should_report_corb_blocking =
-                corb_analyzer_->ShouldReportBlockedResponse();
-            corb_analyzer_.reset();
-            is_more_corb_sniffing_needed_ = false;
-            if (BlockResponseForCorb(should_report_corb_blocking) ==
-                kWillCancelRequest)
-              return;
-            break;
-          }
-          case network::corb::ResponseAnalyzer::Decision::kAllow:
-            corb_analyzer_.reset();
-            is_more_corb_sniffing_needed_ = false;
-            break;
-          case network::corb::ResponseAnalyzer::Decision::kSniffMore:
-            break;
-        }
+        if (MaybeBlockResponseForCorb(corb_decision))
+          return;
       }
     }
 
@@ -2428,11 +2395,14 @@
   memory_cache_writer_.reset();
 }
 
-URLLoader::BlockResponseForCorbResult URLLoader::BlockResponseForCorb(
-    bool should_report_corb_blocking) {
+URLLoader::BlockResponseForCorbResult URLLoader::BlockResponseForCorb() {
   // CORB should only do work after the response headers have been received.
   DCHECK(has_received_response_);
 
+  // Caller should have set up a CorbAnalyzer for BlockResponseForCorb to be
+  // able to do its job.
+  DCHECK(corb_analyzer_);
+
   // The response headers and body shouldn't yet be sent to the URLLoaderClient.
   DCHECK(response_);
   DCHECK(consumer_handle_.is_valid());
@@ -2440,34 +2410,54 @@
   // Send stripped headers to the real URLLoaderClient.
   corb::SanitizeBlockedResponseHeaders(*response_);
 
-  // Send empty body to the real URLLoaderClient.
-  mojo::ScopedDataPipeProducerHandle producer_handle;
-  mojo::ScopedDataPipeConsumerHandle consumer_handle;
-  MojoResult result = mojo::CreateDataPipe(kBlockedBodyAllocationSize,
-                                           producer_handle, consumer_handle);
-  if (result != MOJO_RESULT_OK) {
-    NotifyCompleted(net::ERR_INSUFFICIENT_RESOURCES);
-    return kWillCancelRequest;
-  }
-  producer_handle.reset();
+  // Determine error code. This essentially handles the "ORB v0.1" and "ORB
+  // v0.2" difference.
+  int blocked_error_code =
+      (corb_analyzer_->ShouldHandleBlockedResponseAs() ==
+       corb::ResponseAnalyzer::BlockedResponseHandling::kEmptyResponse)
+          ? net::OK
+          : net::ERR_BLOCKED_BY_ORB;
 
-  url_loader_client_.Get()->OnReceiveResponse(
-      response_->Clone(), std::move(consumer_handle), absl::nullopt);
-
-  // Tell the real URLLoaderClient that the response has been completed.
-  if (corb_detachable_) {
-    // TODO(lukasza): https://crbug.com/827633#c5: Consider passing net::ERR_OK
-    // instead.  net::ERR_ABORTED was chosen for consistency with the old CORB
-    // implementation that used to go through DetachableResourceHandler.
-    CompleteBlockedResponse(net::ERR_ABORTED, should_report_corb_blocking);
-  } else {
-    // CORB responses are reported as a success.
-    CompleteBlockedResponse(net::OK, should_report_corb_blocking);
+  // todo(lukasza/vogelheim): https://crbug.com/827633#c5:
+  // This preserves compatibility with current implementations, which use
+  // net::ERR_ABORTED when the resource is detachable. We will not carry this
+  // forward past "ORB v0.1", so that this check will go away once
+  // OpaqueResponseBlockingV02 is perma-enabled.
+  if (corb_detachable_ && blocked_error_code == net::OK) {
+    CHECK(!base::FeatureList::IsEnabled(features::kOpaqueResponseBlockingV02));
+    blocked_error_code = net::ERR_ABORTED;
   }
 
+  // Send empty body to the real URLLoaderClient. This preserves "ORB v0.1"
+  // behaviour and will also go away once OpaqueResponseBlockingV02 is
+  // perma-enabled.
+  if (blocked_error_code == net::OK || blocked_error_code == net::ERR_ABORTED) {
+    mojo::ScopedDataPipeProducerHandle producer_handle;
+    mojo::ScopedDataPipeConsumerHandle consumer_handle;
+    MojoResult result = mojo::CreateDataPipe(kBlockedBodyAllocationSize,
+                                             producer_handle, consumer_handle);
+    if (result != MOJO_RESULT_OK) {
+      NotifyCompleted(net::ERR_INSUFFICIENT_RESOURCES);
+      return kWillCancelRequest;
+    }
+    producer_handle.reset();
+
+    // Tell the real URLLoaderClient that the response has been completed.
+    url_loader_client_.Get()->OnReceiveResponse(
+        response_->Clone(), std::move(consumer_handle), absl::nullopt);
+  }
+
+  CompleteBlockedResponse(blocked_error_code,
+                          corb_analyzer_->ShouldReportBlockedResponse());
+
   // If the factory is asking to complete requests of this type, then we need to
   // continue processing the response to make sure the network cache is
   // populated.  Otherwise we can cancel the request.
+  //
+  // TODO(lukasza/vogelheim): The `corb_detachable_` logic is meant to ensure a
+  // response is cached (in some cases). With HTTP cache partitioning, this is
+  // likely much less effective than it used to be. Maybe this mechanism should
+  // be retired.
   if (corb_detachable_) {
     // Discard any remaining callbacks or data by rerouting the pipes to
     // EmptyURLLoaderClient.
@@ -2497,6 +2487,29 @@
   return kWillCancelRequest;
 }
 
+bool URLLoader::MaybeBlockResponseForCorb(
+    corb::ResponseAnalyzer::Decision corb_decision) {
+  DCHECK(corb_analyzer_);
+  DCHECK(is_more_corb_sniffing_needed_);
+  bool will_cancel = false;
+  switch (corb_decision) {
+    case network::corb::ResponseAnalyzer::Decision::kBlock: {
+      will_cancel = BlockResponseForCorb() == kWillCancelRequest;
+      corb_analyzer_.reset();
+      is_more_corb_sniffing_needed_ = false;
+      break;
+    }
+    case network::corb::ResponseAnalyzer::Decision::kAllow:
+      corb_analyzer_.reset();
+      is_more_corb_sniffing_needed_ = false;
+      break;
+    case network::corb::ResponseAnalyzer::Decision::kSniffMore:
+      break;
+  }
+  DCHECK_EQ(is_more_corb_sniffing_needed_, !!corb_analyzer_);
+  return will_cancel;
+}
+
 void URLLoader::ReportFlaggedResponseCookies() {
   if (cookie_observer_) {
     std::vector<mojom::CookieOrLineWithAccessResultPtr> reported_cookies;
diff --git a/services/network/url_loader.h b/services/network/url_loader.h
index 0aa6aa95..798e7a7 100644
--- a/services/network/url_loader.h
+++ b/services/network/url_loader.h
@@ -419,8 +419,11 @@
     // processing the request (e.g. by calling ReadMore as necessary).
     kContinueRequest,
   };
-  BlockResponseForCorbResult BlockResponseForCorb(
-      bool should_report_corb_blocking);
+  // Block the response because of CORB (or ORB).
+  BlockResponseForCorbResult BlockResponseForCorb();
+  // Decide whether to call block a response via BlockResponseForCorb.
+  // Returns true if the request should be cancelled.
+  bool MaybeBlockResponseForCorb(corb::ResponseAnalyzer::Decision);
 
   void ReportFlaggedResponseCookies();
   void StartReading();
diff --git a/third_party/blink/web_tests/VirtualTestSuites b/third_party/blink/web_tests/VirtualTestSuites
index 7031c95..f221aeb 100644
--- a/third_party/blink/web_tests/VirtualTestSuites
+++ b/third_party/blink/web_tests/VirtualTestSuites
@@ -1273,5 +1273,23 @@
       "external/wpt/close-watcher"
     ],
     "args": ["--enable-blink-features=CloseWatcher"]
+  },
+  {
+    "prefix": "orb-v01",
+    "platforms": ["Linux"],
+    "args": ["--enable-features=OpaqueResponseBlockingV01"],
+    "bases": [
+      "external/wpt/fetch/corb",
+      "external/wpt/fetch/api",
+      "external/wpt/fetch/nosniff"
+    ]
+  },
+  {
+    "prefix": "orb-v02",
+    "platforms": ["Linux"],
+    "args": ["--enable-features=OpaqueResponseBlockingV01,OpaqueResponseBlockingV02"],
+    "bases": [
+      "external/wpt/fetch/corb"
+    ]
   }
 ]
diff --git a/third_party/blink/web_tests/external/wpt/fetch/corb/resources/response_block_probe.js b/third_party/blink/web_tests/external/wpt/fetch/corb/resources/response_block_probe.js
new file mode 100644
index 0000000..d23ad48
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/fetch/corb/resources/response_block_probe.js
@@ -0,0 +1 @@
+window.script_callback();
diff --git a/third_party/blink/web_tests/external/wpt/fetch/corb/resources/response_block_probe.js.headers b/third_party/blink/web_tests/external/wpt/fetch/corb/resources/response_block_probe.js.headers
new file mode 100644
index 0000000..0d848b0
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/fetch/corb/resources/response_block_probe.js.headers
@@ -0,0 +1 @@
+Content-Type: text/csv
diff --git a/third_party/blink/web_tests/external/wpt/fetch/corb/response_block.tentative.sub.https-expected.txt b/third_party/blink/web_tests/external/wpt/fetch/corb/response_block.tentative.sub.https-expected.txt
new file mode 100644
index 0000000..5f3cd87
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/fetch/corb/response_block.tentative.sub.https-expected.txt
@@ -0,0 +1,5 @@
+This is a testharness.js-based test.
+FAIL ORB: Expect error response. assert_equals: expected "script errored" but got "script loaded"
+FAIL !CORB: CORB would have expected an empty response. assert_not_equals: got disallowed value "script loaded"
+Harness: the test ran to completion.
+
diff --git a/third_party/blink/web_tests/external/wpt/fetch/corb/response_block.tentative.sub.https.html b/third_party/blink/web_tests/external/wpt/fetch/corb/response_block.tentative.sub.https.html
new file mode 100644
index 0000000..860e0d3
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/fetch/corb/response_block.tentative.sub.https.html
@@ -0,0 +1,44 @@
+<!DOCTYPE html>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<body>
+<script>
+// Test handling of blocked responses in CORB/ORB.
+function probe() {
+  // We will cross-origin load a script resource that should get blocked by all
+  // versions of CORB/ORB. Three things may happen:
+  //
+  // 1, The script might execute. (CORB/ORB not supported. Or a bug.)
+  // 2, An empty response is injected: The script loads but nothing is executed.
+  //    (Expected behaviour for CORB and "ORB v0.1".)
+  // 3, An error is injected and script loading aborts. (Expected for ORB.)
+
+  // A cross-origin response labelled as text/csv, which will call
+  // script_callback when executed.
+  const probe = "https://{{domains[www1]}}:{{ports[https][0]}}" +
+      "/fetch/corb/resources/response_block_probe.js";
+
+  // Load the probe as a script.
+  const script = document.createElement("script");
+  script.src = probe;
+  document.body.appendChild(script);
+
+  // Return a promise that will return a string description corresponding to the
+  // three conditions above. Not that a script_callback call is processed
+  // synchronously and hence will occur before the onload event is dispatched.
+  return new Promise((resolve, reject) => {
+    script.onload = _ => resolve("script loaded");
+    script.onerror = _ => resolve("script errored");
+    window.script_callback = _ => resolve("script executed");
+  });
+}
+
+promise_test(t => probe().then(
+    value => assert_equals(value, "script errored")),
+    "ORB: Expect error response.");
+
+// This test ensures we're _not_ seeing CORB behaviour.
+promise_test(t => probe().then(
+    value => assert_not_equals(value, "script loaded")),
+    "!CORB: CORB would have expected an empty response.");
+</script>
diff --git a/third_party/blink/web_tests/external/wpt/fetch/corb/script-html-correctly-labeled.tentative.sub.html b/third_party/blink/web_tests/external/wpt/fetch/corb/script-html-correctly-labeled.tentative.sub.html
index 8f4d767..6d1947c 100644
--- a/third_party/blink/web_tests/external/wpt/fetch/corb/script-html-correctly-labeled.tentative.sub.html
+++ b/third_party/blink/web_tests/external/wpt/fetch/corb/script-html-correctly-labeled.tentative.sub.html
@@ -14,11 +14,13 @@
 
   // Without CORB, the html document would cause a syntax error when parsed as
   // JavaScript, but with CORB there should be no errors (because CORB will
-  // replace the response body with an empty body).
-  script.onload = t.step_func_done(function(){})
+  // replace the response body with an empty body). With ORB, the script loading
+  // itself will error out.
+  script.onload = t.step_func_done();
+  script.onerror = t.step_func_done();
   addEventListener("error",function(e) {
     t.step(function() {
-      assert_unreached("Empty body of a CORS-blocked response shouldn't trigger syntax errors.");
+      assert_unreached("Empty body of a CORB-blocked response shouldn't trigger syntax errors.");
       t.done();
     })
   });
diff --git a/third_party/blink/web_tests/external/wpt/fetch/corb/script-resource-with-json-parser-breaker.tentative.sub.html b/third_party/blink/web_tests/external/wpt/fetch/corb/script-resource-with-json-parser-breaker.tentative.sub.html
index 1d9af35..f0eb1f0a 100644
--- a/third_party/blink/web_tests/external/wpt/fetch/corb/script-resource-with-json-parser-breaker.tentative.sub.html
+++ b/third_party/blink/web_tests/external/wpt/fetch/corb/script-resource-with-json-parser-breaker.tentative.sub.html
@@ -62,8 +62,10 @@
 
     // Without CORB, the JSON parser breaker would cause a syntax error when
     // parsed as JavaScript, but with CORB there should be no errors (because
-    // CORB will replace the response body with an empty body).
+    // CORB will replace the response body with an empty body). With ORB,
+    // the script loading itself should error out.
     script.onload = resolve;
+    script.onerror = resolve;
     addEventListener("error", t.unreached_func(
         "Empty body of a CORS-blocked response shouldn't trigger syntax errors."))
 
diff --git a/third_party/blink/web_tests/external/wpt/fetch/range/resources/partial-text.py b/third_party/blink/web_tests/external/wpt/fetch/range/resources/partial-text.py
index a005855..fa3d1171 100644
--- a/third_party/blink/web_tests/external/wpt/fetch/range/resources/partial-text.py
+++ b/third_party/blink/web_tests/external/wpt/fetch/range/resources/partial-text.py
@@ -8,12 +8,13 @@
 def main(request, response):
     total_length = int(request.GET.first(b'length', b'100'))
     partial_code = int(request.GET.first(b'partial', b'206'))
+    content_type = request.GET.first(b'type', b'text/plain')
     range_header = request.headers.get(b'Range', b'')
 
     # Send a 200 if there is no range request
     if not range_header:
         to_send = ''.zfill(total_length)
-        response.headers.set(b"Content-Type", b"text/plain")
+        response.headers.set(b"Content-Type", content_type)
         response.headers.set(b"Cache-Control", b"no-cache")
         response.headers.set(b"Content-Length", total_length)
         response.content = to_send
@@ -29,12 +30,17 @@
     # Error the request if the range goes beyond the length
     if length <= 0 or end > total_length:
         response.set_error(416, u"Range Not Satisfiable")
+        # set_error sets the MIME type to application/json, which - for a
+        # no-cors media request - will be blocked by ORB. We'll just force
+        # the expected MIME type here, whichfixes the test, but doesn't make
+        # sense in general.
+        response.headers = [(b"Content-Type", content_type)]
         response.write()
         return
 
     # Generate a partial response of the requested length
     to_send = ''.zfill(length)
-    response.headers.set(b"Content-Type", b"text/plain")
+    response.headers.set(b"Content-Type", content_type)
     response.headers.set(b"Accept-Ranges", b"bytes")
     response.headers.set(b"Cache-Control", b"no-cache")
     response.status = partial_code
diff --git a/third_party/blink/web_tests/external/wpt/fetch/range/sw.https.window.js b/third_party/blink/web_tests/external/wpt/fetch/range/sw.https.window.js
index 42e4ac6..62ad894d 100644
--- a/third_party/blink/web_tests/external/wpt/fetch/range/sw.https.window.js
+++ b/third_party/blink/web_tests/external/wpt/fetch/range/sw.https.window.js
@@ -164,7 +164,7 @@
     const rangeId = Math.random() + '';
     const rangeBroadcast = awaitMessage(w.navigator.serviceWorker, rangeId);
 
-    // Create a bogus audo element to trick the browser into sending
+    // Create a bogus audio element to trick the browser into sending
     // cross-origin range requests that can be manipulated by the service worker.
     const sound_url = new URL('partial-text.py', w.location);
     sound_url.hostname = REMOTE_HOST;
@@ -173,6 +173,7 @@
     sound_url.searchParams.set('size', size);
     sound_url.searchParams.set('partial', partialResponseCode);
     sound_url.searchParams.set('id', rangeId);
+    sound_url.searchParams.set('type', 'audio/mp4');
     appendAudio(w.document, sound_url);
 
     // wait for the range requests to happen
@@ -184,6 +185,7 @@
     const url = new URL('partial-text.py', w.location);
     url.searchParams.set('action', 'use-media-range-request');
     url.searchParams.set('size', size);
+    url.searchParams.set('type', 'audio/mp4');
     counts['size' + size] = 0;
     for (let i = 0; i < count; i++) {
       await preloadImage(url, { doc: w.document });
diff --git a/third_party/blink/web_tests/virtual/orb-v01/README.md b/third_party/blink/web_tests/virtual/orb-v01/README.md
new file mode 100644
index 0000000..e42ddd9
--- /dev/null
+++ b/third_party/blink/web_tests/virtual/orb-v01/README.md
@@ -0,0 +1,5 @@
+# Test suite for OpaqueResponseBlockingV01 (ORB v0.1)
+
+Since this feature is Fetch-related, this suite tests all WPT fetch tests
+with `--enable-features=OpaqueResponseBlockingV01`. Tests which are expected
+to behave differently for ORB v0.1 have separate expectations.
diff --git a/third_party/blink/web_tests/virtual/orb-v01/external/wpt/fetch/README.txt b/third_party/blink/web_tests/virtual/orb-v01/external/wpt/fetch/README.txt
new file mode 100644
index 0000000..c0f4ca6
--- /dev/null
+++ b/third_party/blink/web_tests/virtual/orb-v01/external/wpt/fetch/README.txt
@@ -0,0 +1,6 @@
+Test Expectations for virtual/orb-v01.
+
+corb/response_block.tentative.sub.https.html tests behaviour that is different
+between CORB, ORB v0.1 and ORB v0.2 (and full ORB), and hence has separate
+expecations in this virtual test suite.
+
diff --git a/third_party/blink/web_tests/virtual/orb-v01/external/wpt/fetch/corb/response_block.tentative.sub.https-expected.txt b/third_party/blink/web_tests/virtual/orb-v01/external/wpt/fetch/corb/response_block.tentative.sub.https-expected.txt
new file mode 100644
index 0000000..5f3cd87
--- /dev/null
+++ b/third_party/blink/web_tests/virtual/orb-v01/external/wpt/fetch/corb/response_block.tentative.sub.https-expected.txt
@@ -0,0 +1,5 @@
+This is a testharness.js-based test.
+FAIL ORB: Expect error response. assert_equals: expected "script errored" but got "script loaded"
+FAIL !CORB: CORB would have expected an empty response. assert_not_equals: got disallowed value "script loaded"
+Harness: the test ran to completion.
+
diff --git a/third_party/blink/web_tests/virtual/orb-v02/README.md b/third_party/blink/web_tests/virtual/orb-v02/README.md
new file mode 100644
index 0000000..1ef506e
--- /dev/null
+++ b/third_party/blink/web_tests/virtual/orb-v02/README.md
@@ -0,0 +1,6 @@
+# Test suite for OpaqueResponseBlockingV02 (ORB v0.2)
+
+Since this feature is Fetch-related, this suite tests all WPT fetch tests
+with `--enable-features=OpaqueResponseBlockingV01,OpaqueResponseBlockingV02`.
+Tests which are expected to behave differently for ORB v0.1 have separate
+expectations.
diff --git a/third_party/blink/web_tests/virtual/orb-v02/external/wpt/fetch/README.txt b/third_party/blink/web_tests/virtual/orb-v02/external/wpt/fetch/README.txt
new file mode 100644
index 0000000..24dacfb8
--- /dev/null
+++ b/third_party/blink/web_tests/virtual/orb-v02/external/wpt/fetch/README.txt
@@ -0,0 +1,5 @@
+Test Expectations for virtual/orb-v02.
+
+corb/response_block.tentative.sub.https.html tests behaviour that is different
+between CORB, ORB v0.1 and ORB v0.2 (and full ORB), and hence has separate
+expecations in this virtual test suite.
diff --git a/third_party/blink/web_tests/virtual/orb-v02/external/wpt/fetch/corb/response_block.tentative.sub.https-expected.txt b/third_party/blink/web_tests/virtual/orb-v02/external/wpt/fetch/corb/response_block.tentative.sub.https-expected.txt
new file mode 100644
index 0000000..d9bfbc7
--- /dev/null
+++ b/third_party/blink/web_tests/virtual/orb-v02/external/wpt/fetch/corb/response_block.tentative.sub.https-expected.txt
@@ -0,0 +1,5 @@
+This is a testharness.js-based test.
+PASS ORB: Expect error response.
+PASS !CORB: CORB would have expected an empty response.
+Harness: the test ran to completion.
+