Affects: 6.0.3


According to https://www.rfc-editor.org/rfc/rfc9110.html#section-5.5-8, the need of having , commas (and " double-quotes) in header values introduces the need of double-quoting header values, so that a comma in a header value can be distinguished from a comma delimiter for list-based fields.

However for example HttpHeaders.getValuesAsList(), which was introduced for #18797:

@RestController
class DemoController {
    @GetMapping("/headers")
    List<String> getHeaders(@RequestHeader HttpHeaders headers) {
        return headers.getValuesAsList("Example-Dates");
    }
}

If I send the example header from the RFC to it:

$ curl http://127.0.0.1:8080/headers -H 'Example-Dates: "Sat, 04 May 1996", "Wed, 14 Sep 2005"'
["\"Sat","04 May 1996\"","\"Wed","14 Sep 2005\""]

I think this should return ["Sat, 04 May 1996", "Wed, 14 Sep 2005"] instead.

Comment From: Frederick888

It's getting late here and I somehow slapped together a patch for this half-asleep.

It's very, very messy... but seems to tackle all the weird cases I could come up with. In retrospect it's very similar to CSV files, so there may be some methods that I could've reused?

diff --git a/spring-core/src/main/java/org/springframework/util/StringUtils.java b/spring-core/src/main/java/org/springframework/util/StringUtils.java
index f2eb263234..64e38dcab6 100644
--- a/spring-core/src/main/java/org/springframework/util/StringUtils.java
+++ b/spring-core/src/main/java/org/springframework/util/StringUtils.java
@@ -75,8 +75,12 @@ public abstract class StringUtils {
    private static final String CURRENT_PATH = ".";

    private static final char EXTENSION_SEPARATOR = '.';

+   private static final String SPACE_LIKE_CHARACTERS = " \t";
+
+   private static final String LINE_BREAK_LIKE_CHARACTERS = "\r\n";
+

    //---------------------------------------------------------------------
    // General convenience methods for working with Strings
    //---------------------------------------------------------------------
@@ -1237,8 +1241,84 @@ public abstract class StringUtils {
        }
        return toStringArray(result);
    }

+   public static String[] tokenizeQuotedToStringArray(@Nullable String str, String delimiters, boolean trimTokens,
+           boolean ignoreEmptyTokens) {
+       if (str == null) {
+           return EMPTY_STRING_ARRAY;
+       }
+       if (str.indexOf('\0') > -1) {
+           throw new IllegalArgumentException("Invalid character NUL");
+       }
+
+       List<String> tokens = new ArrayList<>();
+       boolean quoting = false, escaping = false, quoteClosed = false;
+       StringBuilder sb = new StringBuilder(str.length());
+       for (int i = 0; i < str.length(); ++i) {
+           char c = str.charAt(i);
+           if (escaping) {
+               if (LINE_BREAK_LIKE_CHARACTERS.indexOf(c) > -1) {
+                   throw new IllegalArgumentException("Invalid character line feed or carriage return");
+               }
+               sb.append(c);
+               escaping = false;
+               continue;
+           }
+           if (c == '"') {
+               if (sb.isEmpty()) {
+                   quoting = true;
+               } else if (quoting) {
+                   quoting = false;
+                   quoteClosed = true;
+               } else {
+                   sb.append(c);
+               }
+               continue;
+           }
+           if (quoting && c == '\\') {
+               escaping = true;
+               continue;
+           }
+           if (!quoting && (sb.isEmpty() || quoteClosed)
+                   && (SPACE_LIKE_CHARACTERS.indexOf(c) > -1 || LINE_BREAK_LIKE_CHARACTERS.indexOf(c) > -1)) {
+               continue;
+           }
+           if (!quoting && delimiters.indexOf(c) > -1) {
+               String token = (trimTokens || !quoteClosed) ? sb.toString().trim() : sb.toString();
+               if (!ignoreEmptyTokens || !token.isEmpty()) {
+                   tokens.add(token);
+               }
+               sb.setLength(0);
+               quoteClosed = false;
+
+               if (i == str.length() - 1 && !ignoreEmptyTokens) {
+                   tokens.add("");
+               }
+
+               continue;
+           }
+           if (quoteClosed) {
+               throw new IllegalArgumentException("Partially quoted token");
+           }
+           if (LINE_BREAK_LIKE_CHARACTERS.indexOf(c) > -1) {
+               throw new IllegalArgumentException("Invalid character line feed or carriage return");
+           }
+           sb.append(c);
+       }
+       if (!sb.isEmpty()) {
+           if (quoting || escaping) {
+               throw new IllegalArgumentException("Incomplete quoted token");
+           }
+           String token = (trimTokens || !quoteClosed) ? sb.toString().trim() : sb.toString();
+           if (!ignoreEmptyTokens || !token.isEmpty()) {
+               tokens.add(token);
+           }
+       }
+
+       return toStringArray(tokens);
+   }
+
    /**
     * Convert a comma delimited list (e.g., a row from a CSV file) into an
     * array of strings.
     * @param str the input {@code String} (potentially {@code null} or empty)
diff --git a/spring-core/src/test/java/org/springframework/util/StringUtilsTests.java b/spring-core/src/test/java/org/springframework/util/StringUtilsTests.java
index 0893412146..076d9e2953 100644
--- a/spring-core/src/test/java/org/springframework/util/StringUtilsTests.java
+++ b/spring-core/src/test/java/org/springframework/util/StringUtilsTests.java
@@ -507,8 +507,42 @@ class StringUtilsTests {
        assertThat(sa).hasSize(3);
        assertThat(sa[0].equals("a") && sa[1].equals("b ") && sa[2].equals("c")).as("components are correct").isTrue();
    }

+   @Test
+   void tokenizeQuotedToStringArrayWithQuotedDates() {
+       String[] sa = StringUtils.tokenizeQuotedToStringArray("\"Sat, 04 May 1996\", \"Wed, 14 Sep 2005\"", ",", true, false);
+       assertThat(sa).hasSize(2);
+       assertThat(sa[0].equals("Sat, 04 May 1996") && sa[1].equals("Wed, 14 Sep 2005")).as("components are correct").isTrue();
+   }
+
+   @Test
+   void tokenizeQuotedToStringArrayWithUnquotedWords() {
+       String[] sa = StringUtils.tokenizeQuotedToStringArray("Foo, Bar,\tHello,", ",", true, false);
+       assertThat(sa).hasSize(4);
+       assertThat(sa[0].equals("Foo")
+               && sa[1].equals("Bar")
+               && sa[2].equals("Hello")
+               && sa[3].equals("")).as("components are correct").isTrue();
+   }
+
+   @Test
+   void tokenizeQuotedToStringArrayWithUnquotedQuotes() {
+       String[] sa = StringUtils.tokenizeQuotedToStringArray(" Hello \"World\\!\", \"\tHello, world! \"" , ",", false, false);
+       assertThat(sa).hasSize(2);
+       assertThat(sa[0].equals("Hello \"World\\!\"")
+               && sa[1].equals("\tHello, world! ")).as("components are correct").isTrue();
+   }
+
+   @Test
+   void tokenizeQuotedToStringArrayWithEscaping() {
+       String[] sa = StringUtils.tokenizeQuotedToStringArray("Foo,\r\n\" Bar \", \"Hello, \\\"!\", \" \",", ",", true, true);
+       assertThat(sa).hasSize(3);
+       assertThat(sa[0].equals("Foo")
+               && sa[1].equals("Bar")
+               && sa[2].equals("Hello, \"!")).as("components are correct").isTrue();
+   }
+
    @Test
    void commaDelimitedListToStringArrayWithNullProducesEmptyArray() {
        String[] sa = StringUtils.commaDelimitedListToStringArray(null);
        assertThat(sa != null).as("String array isn't null with null input").isTrue();
diff --git a/spring-web/src/main/java/org/springframework/http/HttpHeaders.java b/spring-web/src/main/java/org/springframework/http/HttpHeaders.java
index cd5662fb73..174354dd54 100644
--- a/spring-web/src/main/java/org/springframework/http/HttpHeaders.java
+++ b/spring-web/src/main/java/org/springframework/http/HttpHeaders.java
@@ -1548,9 +1548,9 @@ public class HttpHeaders implements MultiValueMap<String, String>, Serializable
        if (values != null) {
            List<String> result = new ArrayList<>();
            for (String value : values) {
                if (value != null) {
-                   Collections.addAll(result, StringUtils.tokenizeToStringArray(value, ","));
+                   Collections.addAll(result, StringUtils.tokenizeQuotedToStringArray(value, ",", false, true));
                }
            }
            return result;
        }

Comment From: poutsma

@Frederick888 Thanks for your patch, after some refactoring I resolved the issue in 207c99e1.