Fix parsing algorithm for Access Control Expose Headers (ACEH)

This CL fixes the implementation for parsing ACEH values to pass the two
failed WPT.

```
{
  "input": "Access-Control-Expose-Headers:\r\nAccess-Control-Expose-Headers: bb-8",
  "exposed": true
},
{
  "input": "Access-Control-Expose-Headers: ,bb-8",
  "exposed": true
},
```

According to the Fetch Standard [1], the first test case is interpreted
as below and it's the same as the second test case.

```
Access-Control-Expose-Headers: ,bb-8",
```

The Fetch Standard says the parsing ACEH algorithm follows the RFC 7230
[2].  And in RFC 7230, the empty element must be accepted and ignored
for legacy list rules [3].

> For compatibility with legacy list rules, a recipient MUST parse and
> ignore a reasonable number of empty list elements

[1] https://fetch.spec.whatwg.org/#example-header-list-get-decode-split
[2] https://fetch.spec.whatwg.org/#concept-response-cors-exposed-header-name-list
[3] https://datatracker.ietf.org/doc/html/rfc7230#section-7

Bug: 978146
Change-Id: Ic7a379c4bb0189299d0d64156173c3cc02a80323
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/3422206
Reviewed-by: Takashi Toyoshima <toyoshim@chromium.org>
Commit-Queue: Takashi Toyoshima <toyoshim@chromium.org>
Cr-Commit-Position: refs/heads/main@{#966572}
diff --git a/third_party/blink/renderer/platform/loader/cors/cors.cc b/third_party/blink/renderer/platform/loader/cors/cors.cc
index 3062b4b..4e3c4c80 100644
--- a/third_party/blink/renderer/platform/loader/cors/cors.cc
+++ b/third_party/blink/renderer/platform/loader/cors/cors.cc
@@ -6,6 +6,8 @@
 
 #include <string>
 
+#include "base/bind.h"
+#include "base/callback.h"
 #include "net/http/http_util.h"
 #include "services/network/public/cpp/cors/cors.h"
 #include "third_party/blink/public/mojom/fetch/fetch_api_request.mojom-blink.h"
@@ -36,9 +38,15 @@
 
     while (true) {
       ConsumeSpaces();
+      // In RFC 7230, the parser must ignore a reasonable number of empty list
+      // elements for compatibility with legacy list rules.
+      // See: https://datatracker.ietf.org/doc/html/rfc7230#section-7
+      if (value_[pos_] == ',') {
+        ConsumeComma();
+        continue;
+      }
 
-      if (pos_ == value_.length() && !output.empty()) {
-        output.insert(std::string());
+      if (pos_ == value_.length()) {
         return;
       }
 
@@ -57,7 +65,8 @@
         return;
 
       if (value_[pos_] == ',') {
-        ++pos_;
+        if (pos_ < value_.length())
+          ++pos_;
       } else {
         output.clear();
         return;
@@ -66,30 +75,33 @@
   }
 
  private:
-  // Consumes zero or more spaces (SP and HTAB) from value_.
-  void ConsumeSpaces() {
+  void ConsumePermittedCharacters(
+      base::RepeatingCallback<bool(UChar)> is_permitted) {
     while (true) {
       if (pos_ == value_.length())
         return;
 
-      UChar c = value_[pos_];
-      if (c != ' ' && c != '\t')
+      if (!is_permitted.Run(value_[pos_]))
         return;
       ++pos_;
     }
   }
+  // Consumes zero or more spaces (SP and HTAB) from value_.
+  void ConsumeSpaces() {
+    ConsumePermittedCharacters(
+        base::BindRepeating([](UChar c) { return c == ' ' || c == '\t'; }));
+  }
+
+  // Consumes zero or more comma from value_.
+  void ConsumeComma() {
+    ConsumePermittedCharacters(
+        base::BindRepeating([](UChar c) { return c == ','; }));
+  }
 
   // Consumes zero or more tchars from value_.
   void ConsumeTokenChars() {
-    while (true) {
-      if (pos_ == value_.length())
-        return;
-
-      UChar c = value_[pos_];
-      if (c > 0x7F || !net::HttpUtil::IsTokenChar(c))
-        return;
-      ++pos_;
-    }
+    ConsumePermittedCharacters(base::BindRepeating(
+        [](UChar c) { return c <= 0x7F && net::HttpUtil::IsTokenChar(c); }));
   }
 
   const String value_;
diff --git a/third_party/blink/renderer/platform/loader/cors/cors_test.cc b/third_party/blink/renderer/platform/loader/cors/cors_test.cc
index 33a8264..0bc8f071 100644
--- a/third_party/blink/renderer/platform/loader/cors/cors_test.cc
+++ b/third_party/blink/renderer/platform/loader/cors/cors_test.cc
@@ -36,7 +36,8 @@
   EXPECT_EQ(Parse(CredentialsMode::kOmit, " \t   \t\t a"),
             HTTPHeaderSet({"a"}));
 
-  EXPECT_EQ(Parse(CredentialsMode::kOmit, "a , "), HTTPHeaderSet({"a", ""}));
+  EXPECT_EQ(Parse(CredentialsMode::kOmit, "a , "), HTTPHeaderSet({"a"}));
+  EXPECT_EQ(Parse(CredentialsMode::kOmit, " , a"), HTTPHeaderSet({"a"}));
 }
 
 TEST_F(CorsExposedHeadersTest, DuplicatedEntries) {
@@ -57,8 +58,6 @@
 
   EXPECT_TRUE(Parse(CredentialsMode::kOmit, " , ").empty());
 
-  EXPECT_TRUE(Parse(CredentialsMode::kOmit, " , a").empty());
-
   EXPECT_TRUE(Parse(CredentialsMode::kOmit, "").empty());
 
   EXPECT_TRUE(Parse(CredentialsMode::kOmit, " ").empty());
@@ -69,6 +68,16 @@
           .empty());
 }
 
+TEST_F(CorsExposedHeadersTest, WithEmptyElements) {
+  EXPECT_EQ(Parse(CredentialsMode::kOmit, ", bb-8"), HTTPHeaderSet({"bb-8"}));
+
+  EXPECT_EQ(Parse(CredentialsMode::kOmit, ", , , bb-8"),
+            HTTPHeaderSet({"bb-8"}));
+
+  EXPECT_EQ(Parse(CredentialsMode::kOmit, ", , , bb-8,"),
+            HTTPHeaderSet({"bb-8"}));
+}
+
 TEST_F(CorsExposedHeadersTest, Wildcard) {
   ResourceResponse response;
   response.AddHttpHeaderField("access-control-expose-headers", "a, b, *");
diff --git a/third_party/blink/web_tests/external/wpt/cors/access-control-expose-headers-parsing.window-expected.txt b/third_party/blink/web_tests/external/wpt/cors/access-control-expose-headers-parsing.window-expected.txt
deleted file mode 100644
index 0fe27f2f..0000000
--- a/third_party/blink/web_tests/external/wpt/cors/access-control-expose-headers-parsing.window-expected.txt
+++ /dev/null
@@ -1,19 +0,0 @@
-This is a testharness.js-based test.
-PASS Loading JSON…
-PASS Parsing: access-control-expose-headers%3A%20BB-8
-PASS Parsing: Access-Control-Expose-Headers%3A%20bb-8%2C%2C%40%23%24%23%25%25%26%5E%26%5E*()()11!
-PASS Parsing: Access-Control-Expose-Headers%3A%20bb-8%2C%20no%20no
-PASS Parsing: Access-Control-Expose-Headers%3A%20%40%23%24%23%25%25%26%5E%26%5E*()()11!%2Cbb-8
-PASS Parsing: Access-Control-Expose-Headers%3A%20bb-8%0D%0AAccess-Control-Expose-Headers%3A%20no
-PASS Parsing: Access-Control-Expose-Headers%3A%20bb-8%0D%0AAccess-Control-Expose-Headers%3A%20no%20no
-PASS Parsing: Access-Control-Expose-Headers%3A%20no%0D%0AAccess-Control-Expose-Headers%3A%20bb-8
-FAIL Parsing: Access-Control-Expose-Headers%3A%0D%0AAccess-Control-Expose-Headers%3A%20bb-8 assert_equals: expected (string) "hey" but got (object) null
-FAIL Parsing: Access-Control-Expose-Headers%3A%20%2Cbb-8 assert_equals: expected (string) "hey" but got (object) null
-PASS Parsing: Access-Control-Expose-Headers%3A%20bb-8%0C
-PASS Parsing: Access-Control-Expose-Headers%3A%20bb-8%0B
-PASS Parsing: Access-Control-Expose-Headers%3A%20bb-8%0B%2Cbb-8
-PASS Parsing: Access-Control-Expose-Headers%3A%20'bb-8'
-PASS Parsing: Access-Control-Expose-Headers%3A%20'bb-8'%2Cbb-8
-PASS Parsing: Access-Control-Expose-Headers%3A%20%22bb-8%22%2Cbb-8
-Harness: the test ran to completion.
-