Adding UMA Histogram reporting when nosniff header is present.

This UMA statistic should help us understand why we are seeing so many
responses blocked without being sniffed in the CacheHeuristic +
ProtectedMimeType category. For completeness, we also report
which responses have a nosniff header in the CORSHeuristic +
ProtectedMimeType category.

Bug: 815290
Change-Id: Idf5c9f3a65b19e681c8eab51f376e02ec2eb10a6
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/1709669
Commit-Queue: Kristina Nelson <krstnmnlsn@google.com>
Reviewed-by: Steven Holte <holte@chromium.org>
Reviewed-by: Charlie Reis <creis@chromium.org>
Reviewed-by: Ɓukasz Anforowicz <lukasza@chromium.org>
Cr-Commit-Position: refs/heads/master@{#680686}
diff --git a/content/browser/loader/cross_site_document_resource_handler_unittest.cc b/content/browser/loader/cross_site_document_resource_handler_unittest.cc
index c312f83..b82ca19 100644
--- a/content/browser/loader/cross_site_document_resource_handler_unittest.cc
+++ b/content/browser/loader/cross_site_document_resource_handler_unittest.cc
@@ -2568,6 +2568,9 @@
   const bool seems_sensitive_from_cache_heuristic =
       network::CrossOriginReadBlocking::ResponseAnalyzer::
           SeemsSensitiveFromCacheHeuristic(response->head);
+  const bool expect_nosniff =
+      network::CrossOriginReadBlocking::ResponseAnalyzer::HasNoSniff(
+          response->head);
 
   // Call OnResponseStarted.
   ASSERT_EQ(MockResourceLoader::Status::IDLE,
@@ -2675,6 +2678,15 @@
             testing::ElementsAre(base::Bucket(supports_range_requests, 1)));
         expected_counts[cors_protected + blocked_with_range_support] = 1;
       }
+      if (scenario.mime_type_bucket == MimeTypeBucket::kProtected &&
+          scenario.protection_decision ==
+              CrossOriginProtectionDecision::kBlock) {
+        EXPECT_THAT(histograms.GetAllSamples(
+                        cors_protected + ".BlockedWithoutSniffing.HasNoSniff"),
+                    testing::ElementsAre(base::Bucket(expect_nosniff, 1)));
+        expected_counts[cors_protected + ".BlockedWithoutSniffing.HasNoSniff"] =
+            1;
+      }
     }
     if (seems_sensitive_from_cache_heuristic) {
       expected_counts[cache_base + mime_type_bucket] = 1;
@@ -2689,6 +2701,15 @@
             testing::ElementsAre(base::Bucket(supports_range_requests, 1)));
         expected_counts[cache_protected + blocked_with_range_support] = 1;
       }
+      if (scenario.mime_type_bucket == MimeTypeBucket::kProtected &&
+          scenario.protection_decision ==
+              CrossOriginProtectionDecision::kBlock) {
+        EXPECT_THAT(histograms.GetAllSamples(
+                        cache_protected + ".BlockedWithoutSniffing.HasNoSniff"),
+                    testing::ElementsAre(base::Bucket(expect_nosniff, 1)));
+        expected_counts[cache_protected +
+                        ".BlockedWithoutSniffing.HasNoSniff"] = 1;
+      }
     }
 
     EXPECT_THAT(
diff --git a/services/network/cross_origin_read_blocking.cc b/services/network/cross_origin_read_blocking.cc
index 4c244aa4..18667282 100644
--- a/services/network/cross_origin_read_blocking.cc
+++ b/services/network/cross_origin_read_blocking.cc
@@ -601,6 +601,7 @@
       seems_sensitive_from_cache_heuristic_(
           SeemsSensitiveFromCacheHeuristic(response)),
       supports_range_requests_(SupportsRangeRequests(response)),
+      has_nosniff_header_(HasNoSniff(response)),
       content_length_(response.content_length),
       http_response_code_(response.headers ? response.headers->response_code()
                                            : 0) {
@@ -747,18 +748,6 @@
     }
   }
 
-  // We intend to block the response at this point.  However, we will usually
-  // sniff the contents to confirm the MIME type, to avoid blocking incorrectly
-  // labeled JavaScript, JSONP, etc files.
-  //
-  // Note: if there is a nosniff header, it means we should honor the response
-  // mime type without trying to confirm it.
-  std::string nosniff_header;
-  response.headers->GetNormalizedHeader("x-content-type-options",
-                                        &nosniff_header);
-  bool has_nosniff_header =
-      base::LowerCaseEqualsASCII(nosniff_header, "nosniff");
-
   // Some types (e.g. ZIP) are protected without any confirmation sniffing.
   if (canonical_mime_type == MimeType::kNeverSniffed)
     return kBlock;
@@ -820,13 +809,20 @@
     }
   }
 
+  // We intend to block the response at this point.  However, we will usually
+  // sniff the contents to confirm the MIME type, to avoid blocking incorrectly
+  // labeled JavaScript, JSONP, etc files.
+  //
+  // Note: if there is a nosniff header, it means we should honor the response
+  // mime type without trying to confirm it.
+  //
   // Decide whether to block based on the MIME type.
   switch (canonical_mime_type) {
     case MimeType::kHtml:
     case MimeType::kXml:
     case MimeType::kJson:
     case MimeType::kPlain:
-      if (has_nosniff_header)
+      if (HasNoSniff(response))
         return kBlock;
       else
         return kNeedToSniffMore;
@@ -851,6 +847,17 @@
 }
 
 // static
+bool CrossOriginReadBlocking::ResponseAnalyzer::HasNoSniff(
+    const ResourceResponseInfo& response) {
+  if (!response.headers)
+    return false;
+  std::string nosniff_header;
+  response.headers->GetNormalizedHeader("x-content-type-options",
+                                        &nosniff_header);
+  return base::LowerCaseEqualsASCII(nosniff_header, "nosniff");
+}
+
+// static
 bool CrossOriginReadBlocking::ResponseAnalyzer::SeemsSensitiveFromCORSHeuristic(
     const ResourceResponseInfo& response) {
   // Check if the response has an Access-Control-Allow-Origin with a value other
@@ -1165,6 +1172,10 @@
               "SiteIsolation.CORBProtection.CORSHeuristic.ProtectedMimeType."
               "BlockedWithRangeSupport",
               supports_range_requests_);
+          UMA_HISTOGRAM_BOOLEAN(
+              "SiteIsolation.CORBProtection.CORSHeuristic.ProtectedMimeType."
+              "BlockedWithoutSniffing.HasNoSniff",
+              has_nosniff_header_);
         } else if (protection_decision ==
                    CrossOriginProtectionDecision::kBlockedAfterSniffing) {
           UMA_HISTOGRAM_BOOLEAN(
@@ -1195,6 +1206,10 @@
               "SiteIsolation.CORBProtection.CacheHeuristic.ProtectedMimeType."
               "BlockedWithRangeSupport",
               supports_range_requests_);
+          UMA_HISTOGRAM_BOOLEAN(
+              "SiteIsolation.CORBProtection.CacheHeuristic.ProtectedMimeType."
+              "BlockedWithoutSniffing.HasNoSniff",
+              has_nosniff_header_);
         } else if (protection_decision ==
                    CrossOriginProtectionDecision::kBlockedAfterSniffing) {
           UMA_HISTOGRAM_BOOLEAN(
diff --git a/services/network/cross_origin_read_blocking.h b/services/network/cross_origin_read_blocking.h
index f4b499e..e7a8aaa 100644
--- a/services/network/cross_origin_read_blocking.h
+++ b/services/network/cross_origin_read_blocking.h
@@ -185,6 +185,9 @@
         const base::Optional<url::Origin>& request_initiator_site_lock,
         MimeType canonical_mime_type);
 
+    // Returns true if the response has a nosniff header.
+    static bool HasNoSniff(const ResourceResponseInfo& response);
+
     // Checks if the response seems sensitive for CORB protection logging.
     // Returns true if the Access-Control-Allow-Origin header has a value other
     // than *.
@@ -239,6 +242,7 @@
     const bool seems_sensitive_from_cors_heuristic_;
     const bool seems_sensitive_from_cache_heuristic_;
     const bool supports_range_requests_;
+    const bool has_nosniff_header_;
     // |hypothetical_sniffing_mode_| is true if we need to sniff only because of
     // the CORB protection logging (and otherwise, CORB would not sniff).
     bool hypothetical_sniffing_mode_ = false;
diff --git a/services/network/cross_origin_read_blocking_unittest.cc b/services/network/cross_origin_read_blocking_unittest.cc
index 658d687..e1c78e7 100644
--- a/services/network/cross_origin_read_blocking_unittest.cc
+++ b/services/network/cross_origin_read_blocking_unittest.cc
@@ -1862,6 +1862,113 @@
         Verdict::kAllowBecauseOutOfData,  // verdict
         0,                                // verdict_packet
     },
+
+    // These responses confirm we are correctly reporting when a nosniff header
+    // is present. This should *only* be reported when a blocked, sensitive,
+    // protected mime-type response has a nosniff header.
+    //
+    // This first response satisfies all criteria except it does not have a
+    // nosniff header.
+    {
+        "Cache heuristic with no nosniff header",
+        __LINE__,
+        "http://a.com/resource.html",  // target_url
+        "http://a.com/",               // initiator_origin
+        "http://a.com/",               // initiator_site_lock
+        "HTTP/1.1 200 OK\n"
+        "Cache-Control: private\n"
+        "Vary: origin",                             // response_headers
+        "text/html",                                // response_content_type
+        MimeType::kHtml,                            // canonical_mime_type
+        MimeTypeBucket::kProtected,                 // mime_type_bucket
+        {"<html><head>this should sniff as HTML"},  // packets
+        true,                                       // resource_is_sensitive
+        CrossOriginProtectionDecision::
+            kBlockedAfterSniffing,  // protection_decision
+        Verdict::kAllow,            // verdict
+        0,                          // verdict_packet
+    },
+    // These next responses have nosniff headers but are missing one of the
+    // other criteria.
+    {
+        "Cache heuristic with nosniff header but not a protected type",
+        __LINE__,
+        "http://a.com/resource.js",  // target_url
+        "http://a.com/",             // initiator_origin
+        "http://a.com/",             // initiator_site_lock
+        "HTTP/1.1 200 OK\n"
+        "X-Content-Type-Options: nosniff\n"
+        "Cache-Control: private\n"
+        "Vary: origin",            // response_headers
+        "application/javascript",  // response_content_type
+        MimeType::kOthers,         // canonical_mime_type
+        MimeTypeBucket::kPublic,   // mime_type_bucket
+        {"var x=3;"},              // packets
+        true,                      // resource_is_sensitive
+        CrossOriginProtectionDecision::
+            kAllowedAfterSniffing,  // protection_decision
+        Verdict::kAllow,            // verdict
+        0,                          // verdict_packet
+    },
+    {
+        "Cache heuristic with nosniff header but protection decision != kBlock",
+        __LINE__,
+        "http://a.com/resource.html",  // target_url
+        "http://a.com/",               // initiator_origin
+        "http://a.com/",               // initiator_site_lock
+        "HTTP/1.1 200 OK\n"
+        "X-Content-Type-Options: nosniff\n"
+        "Access-Control-Allow-Origin: *\n"
+        "Cache-Control: private\n"
+        "Vary: origin",                             // response_headers
+        "text/html",                                // response_content_type
+        MimeType::kHtml,                            // canonical_mime_type
+        MimeTypeBucket::kProtected,                 // mime_type_bucket
+        {"<html><head>this should sniff as HTML"},  // packets
+        true,                                       // resource_is_sensitive
+        CrossOriginProtectionDecision::kAllow,      // protection_decision
+        Verdict::kAllow,                            // verdict
+        kVerdictPacketForHeadersBasedVerdict,       // verdict_packet
+    },
+    // These responses satisfies all criteria and should report its nosniff
+    // header.
+    {
+        "Nosniff header and satisfies the CORS heuristic",
+        __LINE__,
+        "http://a.com/resource.html",  // target_url
+        "http://a.com/",               // initiator_origin
+        "http://a.com/",               // initiator_site_lock
+        "HTTP/1.1 200 OK\n"
+        "X-Content-Type-Options: nosniff\n"
+        "Access-Control-Allow-Origin: http://www.a.com/",  // response_headers
+        "text/html",                                // response_content_type
+        MimeType::kHtml,                            // canonical_mime_type
+        MimeTypeBucket::kProtected,                 // mime_type_bucket
+        {"<html><head>this should sniff as HTML"},  // packets
+        true,                                       // resource_is_sensitive
+        CrossOriginProtectionDecision::kBlock,      // protection_decision
+        Verdict::kAllow,                            // verdict
+        kVerdictPacketForHeadersBasedVerdict,       // verdict_packet
+    },
+    {
+        "Nosniff header and satisfies the Cache heuristic",
+        __LINE__,
+        "http://a.com/resource.html",  // target_url
+        "http://a.com/",               // initiator_origin
+        "http://a.com/",               // initiator_site_lock
+        "HTTP/1.1 200 OK\n"
+        "X-Content-Type-Options: nosniff\n"
+        "Cache-Control: private\n"
+        "Vary: origin",                            // response_headers
+        "text/html",                               // response_content_type
+        MimeType::kHtml,                           // canonical_mime_type
+        MimeTypeBucket::kProtected,                // mime_type_bucket
+        {/* empty body doesn't sniff as html */},  // packets
+        true,                                      // resource_is_sensitive
+        CrossOriginProtectionDecision::kBlock,     // protection_decision
+        Verdict::kAllow,                           // verdict
+        kVerdictPacketForHeadersBasedVerdict,      // verdict_packet
+    },
 };
 
 }  // namespace
@@ -2142,6 +2249,8 @@
   const bool supports_range_requests =
       network::CrossOriginReadBlocking::ResponseAnalyzer::SupportsRangeRequests(
           response);
+  const bool expect_nosniff =
+      network::CrossOriginReadBlocking::ResponseAnalyzer::HasNoSniff(response);
 
   // Run the analyzer and confirm it allows/blocks correctly.
   RunAnalyzerOnScenario(response);
@@ -2193,6 +2302,7 @@
     std::string cache_base = "SiteIsolation.CORBProtection.CacheHeuristic";
     std::string cors_protected = cors_base + ".ProtectedMimeType";
     std::string cache_protected = cache_base + ".ProtectedMimeType";
+    std::string blocked_nosniff = ".BlockedWithoutSniffing.HasNoSniff";
     if (seems_sensitive_from_cors_heuristic) {
       expected_counts[cors_base + mime_type_bucket] = 1;
       EXPECT_THAT(histograms.GetAllSamples(cors_base + mime_type_bucket),
@@ -2206,6 +2316,13 @@
             testing::ElementsAre(base::Bucket(supports_range_requests, 1)));
         expected_counts[cors_protected + blocked_with_range_support] = 1;
       }
+      if (scenario.mime_type_bucket == MimeTypeBucket::kProtected &&
+          scenario.protection_decision ==
+              CrossOriginProtectionDecision::kBlock) {
+        EXPECT_THAT(histograms.GetAllSamples(cors_protected + blocked_nosniff),
+                    testing::ElementsAre(base::Bucket(expect_nosniff, 1)));
+        expected_counts[cors_protected + blocked_nosniff] = 1;
+      }
     }
     if (seems_sensitive_from_cache_heuristic) {
       expected_counts[cache_base + mime_type_bucket] = 1;
@@ -2220,6 +2337,13 @@
             testing::ElementsAre(base::Bucket(supports_range_requests, 1)));
         expected_counts[cache_protected + blocked_with_range_support] = 1;
       }
+      if (scenario.mime_type_bucket == MimeTypeBucket::kProtected &&
+          scenario.protection_decision ==
+              CrossOriginProtectionDecision::kBlock) {
+        EXPECT_THAT(histograms.GetAllSamples(cache_protected + blocked_nosniff),
+                    testing::ElementsAre(base::Bucket(expect_nosniff, 1)));
+        expected_counts[cache_protected + blocked_nosniff] = 1;
+      }
     }
 
     EXPECT_THAT(
diff --git a/tools/metrics/histograms/enums.xml b/tools/metrics/histograms/enums.xml
index 25482361..6786f87 100644
--- a/tools/metrics/histograms/enums.xml
+++ b/tools/metrics/histograms/enums.xml
@@ -5990,6 +5990,11 @@
   <int value="1" label="Has distilled data"/>
 </enum>
 
+<enum name="BooleanHasNoSniff">
+  <int value="0" label="Does not have nosniff"/>
+  <int value="1" label="Has nosniff"/>
+</enum>
+
 <enum name="BooleanHasPath">
   <int value="0" label="No path"/>
   <int value="1" label="Has path"/>
diff --git a/tools/metrics/histograms/histograms.xml b/tools/metrics/histograms/histograms.xml
index 1df6349..605f782 100644
--- a/tools/metrics/histograms/histograms.xml
+++ b/tools/metrics/histograms/histograms.xml
@@ -127750,6 +127750,20 @@
   </summary>
 </histogram>
 
+<histogram base="true"
+    name="SiteIsolation.CORBProtection.ProtectedMimeType.BlockedWithoutSniffing.HasNoSniff"
+    enum="BooleanHasNoSniff" expires_after="M79">
+  <owner>krstnmnlsn@chromium.org</owner>
+  <owner>creis@chromium.org</owner>
+  <summary>
+    True if the response has a nosniff header. If the nosniff header is not
+    present, then CORB must have decided to block without sniffing due to a
+    partial response, or because the MIME type was a never sniff type. Only
+    reported on resources CORB would have blocked/protected without sniffing
+    that have a protected MIME type.
+  </summary>
+</histogram>
+
 <histogram name="SiteIsolation.CORBProtection.SensitiveResource"
     enum="BooleanSensitive" expires_after="M79">
   <owner>krstnmnlsn@chromium.org</owner>
@@ -159158,6 +159172,15 @@
       name="Net.QuicSession.ConnectionCloseErrorCodeServerGoogle"/>
 </histogram_suffixes>
 
+<histogram_suffixes name="HeuristicType" separator="." ordering="prefix,2">
+  <suffix name="CacheHeuristic"
+      label="In this case the response satisfies the Cache heuristic."/>
+  <suffix name="CORSHeuristic"
+      label="In this case the response satisfies the CORS heuristic."/>
+  <affected-histogram
+      name="SiteIsolation.CORBProtection.ProtectedMimeType.BlockedWithoutSniffing.HasNoSniff"/>
+</histogram_suffixes>
+
 <histogram_suffixes name="HighDownloadBandwidth" separator=".">
   <suffix name="HighDownloadBandwidth" label="download with high bandwidth."/>
   <affected-histogram name="Download.Parallelizable.DownloadTime"/>