CVE-2019-16771: Improper Neutralization of Special Elements in Output Used by a Downstream Component ('Injection') (CWE-74)
value: Versions of Armeria 0.85.0 through and including 0.96.0 are vulnerable to HTTP response splitting, which allows remote attackers to inject arbitrary HTTP headers via CRLF sequences when unsanitized data is used to populate the headers of an HTTP response. This vulnerability has been patched in 0.97.0. Potential impacts of this vulnerability include cross-user defacement, cache poisoning, Cross-site scripting (XSS), and page hijacking.
CWE description: The product constructs all or part of a command, data structure, or record using externally-influenced input from an upstream component, but it does not neutralize or incorrectly neutralizes special elements that could modify how it is parsed or interpreted when it is sent to a downstream component.
CVSS v3 base score: 6.5
Published: 2019-12-06T19:15Z
Repository: https://github.com/line/armeria
Language: Java
Files changed: core/src/main/java/com/linecorp/armeria/common/HttpHeaderNames.java, core/src/main/java/com/linecorp/armeria/common/HttpHeadersBase.java, core/src/main/java/com/linecorp/armeria/internal/ArmeriaHttpUtil.java, core/src/test/java/com/linecorp/armeria/common/HttpHeaderNamesTest.java, core/src/test/java/com/linecorp/armeria/common/HttpHeadersBaseTest.java, core/src/test/java/com/linecorp/armeria/server/HttpServerHeaderValidationTest.java
Commit date: 2019-12-05 18:35:47 +0900
Commit message: Merge pull request from GHSA-35fr-h7jr-hh86

Motivation:

An `HttpService` can produce a malformed HTTP response when a user
specified a malformed HTTP header values, such as:

    ResponseHeaders.of(HttpStatus.OK
                       "my-header", "foo\r\nbad-header: bar");

Modification:

- Add strict header value validation to `HttpHeadersBase`
- Add strict header name validation to `HttpHeaderNames.of()`, which is
  used by `HttpHeadersBase`.

Result:

- It is not possible anymore to send a bad header value which can be
  misused for sending additional headers or injecting arbitrary content.
Diff: diff --git a/core/src/main/java/com/linecorp/armeria/common/HttpHeaderNames.java b/core/src/main/java/com/linecorp/armeria/common/HttpHeaderNames.java
index a020e8ba..dc3cca97 100644
--- a/core/src/main/java/com/linecorp/armeria/common/HttpHeaderNames.java
+++ b/core/src/main/java/com/linecorp/armeria/common/HttpHeaderNames.java
@@ -30,14 +30,16 @@ package com.linecorp.armeria.common;
 
 import static java.util.Objects.requireNonNull;
 
 import java.lang.reflect.Field;
 import java.lang.reflect.Modifier;
+import java.util.BitSet;
 import java.util.Map;
 
 import com.google.common.base.Ascii;
 import com.google.common.collect.ImmutableMap;
+import com.google.common.math.IntMath;
 
 import io.netty.util.AsciiString;
 
 /**
  * Contains constant definitions for the HTTP header field names.
@@ -63,10 +65,39 @@ public final class HttpHeaderNames {
     //   - Sec-Fetch-Mode
     //   - Sec-Fetch-Site
     //   - Sec-Fetch-User
     //   - Sec-Metadata
 
+    private static final int PROHIBITED_NAME_CHAR_MASK = ~63;
+    private static final BitSet PROHIBITED_NAME_CHARS = new BitSet(~PROHIBITED_NAME_CHAR_MASK + 1);
+    private static final String[] PROHIBITED_NAME_CHAR_NAMES = new String[~PROHIBITED_NAME_CHAR_MASK + 1];
+
+    static {
+        PROHIBITED_NAME_CHARS.set(0);
+        PROHIBITED_NAME_CHARS.set('\t');
+        PROHIBITED_NAME_CHARS.set('\n');
+        PROHIBITED_NAME_CHARS.set(0xB);
+        PROHIBITED_NAME_CHARS.set('\f');
+        PROHIBITED_NAME_CHARS.set('\r');
+        PROHIBITED_NAME_CHARS.set(' ');
+        PROHIBITED_NAME_CHARS.set(',');
+        PROHIBITED_NAME_CHARS.set(':');
+        PROHIBITED_NAME_CHARS.set(';');
+        PROHIBITED_NAME_CHARS.set('=');
+        PROHIBITED_NAME_CHAR_NAMES[0] = "<NUL>";
+        PROHIBITED_NAME_CHAR_NAMES['\t'] = "<TAB>";
+        PROHIBITED_NAME_CHAR_NAMES['\n'] = "<LF>";
+        PROHIBITED_NAME_CHAR_NAMES[0xB] = "<VT>";
+        PROHIBITED_NAME_CHAR_NAMES['\f'] = "<FF>";
+        PROHIBITED_NAME_CHAR_NAMES['\r'] = "<CR>";
+        PROHIBITED_NAME_CHAR_NAMES[' '] = "<SP>";
+        PROHIBITED_NAME_CHAR_NAMES[','] = ",";
+        PROHIBITED_NAME_CHAR_NAMES[':'] = ":";
+        PROHIBITED_NAME_CHAR_NAMES[';'] = ";";
+        PROHIBITED_NAME_CHAR_NAMES['='] = "=";
+    }
+
     // Pseudo-headers
 
     /**
      * The HTTP {@code ":method"} pseudo header field name.
      */
@@ -562,37 +593,92 @@ public final class HttpHeaderNames {
             }
         }
         map = builder.build();
     }
 
+    private static AsciiString create(String name) {
+        return AsciiString.cached(Ascii.toLowerCase(name));
+    }
+
     /**
      * Lower-cases and converts the specified header name into an {@link AsciiString}. If {@code "name"} is
      * a known header name, this method will return a pre-instantiated {@link AsciiString} to reduce
      * the allocation rate of {@link AsciiString}.
+     *
+     * @throws IllegalArgumentException if the specified {@code name} is not a valid header name.
      */
     public static AsciiString of(CharSequence name) {
         if (name instanceof AsciiString) {
             return of((AsciiString) name);
         }
 
         final String lowerCased = Ascii.toLowerCase(requireNonNull(name, "name"));
         final AsciiString cached = map.get(lowerCased);
-        return cached != null ? cached : AsciiString.cached(lowerCased);
+        if (cached != null) {
+            return cached;
+        }
+
+        return validate(AsciiString.cached(lowerCased));
     }
 
     /**
      * Lower-cases and converts the specified header name into an {@link AsciiString}. If {@code "name"} is
      * a known header name, this method will return a pre-instantiated {@link AsciiString} to reduce
      * the allocation rate of {@link AsciiString}.
+     *
+     * @throws IllegalArgumentException if the specified {@code name} is not a valid header name.
      */
     public static AsciiString of(AsciiString name) {
         final AsciiString lowerCased = name.toLowerCase();
         final AsciiString cached = map.get(lowerCased);
-        return cached != null ? cached : lowerCased;
+        if (cached != null) {
+            return cached;
+        }
+
+        return validate(lowerCased);
     }
 
-    private static AsciiString create(String name) {
-        return AsciiString.cached(Ascii.toLowerCase(name));
+    private static AsciiString validate(AsciiString name) {
+        if (name.isEmpty()) {
+            throw new IllegalArgumentException("malformed header name: <EMPTY>");
+        }
+
+        final int lastIndex;
+        try {
+            lastIndex = name.forEachByte(value -> {
+                if ((value & PROHIBITED_NAME_CHAR_MASK) != 0) { // value >= 64
+                    return true;
+                }
+
+                // value < 64
+                return !PROHIBITED_NAME_CHARS.get(value);
+            });
+        } catch (Exception e) {
+            throw new Error(e);
+        }
+
+        if (lastIndex >= 0) {
+            throw new IllegalArgumentException(malformedHeaderNameMessage(name));
+        }
+
+        return name;
+    }
+
+    private static String malformedHeaderNameMessage(AsciiString name) {
+        final StringBuilder buf = new StringBuilder(IntMath.saturatedAdd(name.length(), 64));
+        buf.append("malformed header name: ");
+
+        final int nameLength = name.length();
+        for (int i = 0; i < nameLength; i++) {
+            final char ch = name.charAt(i);
+            if (PROHIBITED_NAME_CHARS.get(ch)) {
+                buf.append(PROHIBITED_NAME_CHAR_NAMES[ch]);
+            } else {
+                buf.append(ch);
+            }
+        }
+
+        return buf.toString();
     }
 
     private HttpHeaderNames() {}
 }
diff --git a/core/src/main/java/com/linecorp/armeria/common/HttpHeadersBase.java b/core/src/main/java/com/linecorp/armeria/common/HttpHeadersBase.java
index 2c05dd0d..fdefda78 100644
--- a/core/src/main/java/com/linecorp/armeria/common/HttpHeadersBase.java
+++ b/core/src/main/java/com/linecorp/armeria/common/HttpHeadersBase.java
@@ -27,11 +27,10 @@
  * or implied. See the License for the specific language governing permissions and limitations under
  * the License.
  */
 package com.linecorp.armeria.common;
 
-import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkState;
 import static com.linecorp.armeria.internal.ArmeriaHttpUtil.isAbsoluteUri;
 import static io.netty.handler.codec.http2.Http2Headers.PseudoHeaderName.hasPseudoHeaderFormat;
 import static io.netty.util.internal.MathUtil.findNextPositivePowerOfTwo;
 import static java.lang.Math.max;
@@ -39,10 +38,11 @@ import static java.lang.Math.min;
 import static java.util.Objects.requireNonNull;
 
 import java.net.URI;
 import java.net.URISyntaxException;
 import java.util.Arrays;
+import java.util.BitSet;
 import java.util.Date;
 import java.util.Iterator;
 import java.util.List;
 import java.util.Map;
 import java.util.Map.Entry;
@@ -56,19 +56,37 @@ import javax.annotation.Nullable;
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterators;
+import com.google.common.math.IntMath;
 
 import io.netty.handler.codec.DateFormatter;
 import io.netty.util.AsciiString;
 
 /**
  * The base container implementation of HTTP/2 headers.
  */
 class HttpHeadersBase implements HttpHeaderGetters {
 
+    private static final int PROHIBITED_VALUE_CHAR_MASK = ~15;
+    private static final BitSet PROHIBITED_VALUE_CHARS = new BitSet(~PROHIBITED_VALUE_CHAR_MASK + 1);
+    private static final String[] PROHIBITED_VALUE_CHAR_NAMES = new String[~PROHIBITED_VALUE_CHAR_MASK + 1];
+
+    static {
+        PROHIBITED_VALUE_CHARS.set(0);
+        PROHIBITED_VALUE_CHARS.set('\n');
+        PROHIBITED_VALUE_CHARS.set(0xB);
+        PROHIBITED_VALUE_CHARS.set('\f');
+        PROHIBITED_VALUE_CHARS.set('\r');
+        PROHIBITED_VALUE_CHAR_NAMES[0] = "<NUL>";
+        PROHIBITED_VALUE_CHAR_NAMES['\n'] = "<LF>";
+        PROHIBITED_VALUE_CHAR_NAMES[0xB] = "<VT>";
+        PROHIBITED_VALUE_CHAR_NAMES['\f'] = "<FF>";
+        PROHIBITED_VALUE_CHAR_NAMES['\r'] = "<CR>";
+    }
+
     static final int DEFAULT_SIZE_HINT = 16;
 
     /**
      * Constant used to seed the hash code generation. Could be anything but this was borrowed from murmur3.
      */
@@ -543,30 +561,30 @@ class HttpHeadersBase implements HttpHeaderGetters {
         final Long v = getTimeMillisAndRemove(name);
         return v != null ? v : defaultValue;
     }
 
     final void add(CharSequence name, String value) {
-        final AsciiString normalizedName = normalizeName(name);
+        final AsciiString normalizedName = HttpHeaderNames.of(name);
         requireNonNull(value, "value");
         final int h = normalizedName.hashCode();
         final int i = index(h);
         add0(h, i, normalizedName, value);
     }
 
     final void add(CharSequence name, Iterable<String> values) {
-        final AsciiString normalizedName = normalizeName(name);
+        final AsciiString normalizedName = HttpHeaderNames.of(name);
         requireNonNull(values, "values");
         final int h = normalizedName.hashCode();
         final int i = index(h);
         for (String v : values) {
             requireNonNullElement(values, v);
             add0(h, i, normalizedName, v);
         }
     }
 
     final void add(CharSequence name, String... values) {
-        final AsciiString normalizedName = normalizeName(name);
+        final AsciiString normalizedName = HttpHeaderNames.of(name);
         requireNonNull(values, "values");
         final int h = normalizedName.hashCode();
         final int i = index(h);
         for (String v : values) {
             requireNonNullElement(values, v);
@@ -588,20 +606,20 @@ class HttpHeadersBase implements HttpHeaderGetters {
         requireNonNull(value, "value");
         add(name, fromObject(value));
     }
 
     final void addObject(CharSequence name, Iterable<?> values) {
-        final AsciiString normalizedName = normalizeName(name);
+        final AsciiString normalizedName = HttpHeaderNames.of(name);
         requireNonNull(values, "values");
         for (Object v : values) {
             requireNonNullElement(values, v);
             addObject(normalizedName, v);
         }
     }
 
     final void addObject(CharSequence name, Object... values) {
-        final AsciiString normalizedName = normalizeName(name);
+        final AsciiString normalizedName = HttpHeaderNames.of(name);
         requireNonNull(values, "values");
         for (Object v : values) {
             requireNonNullElement(values, v);
             addObject(normalizedName, v);
         }
@@ -636,20 +654,20 @@ class HttpHeadersBase implements HttpHeaderGetters {
     final void addTimeMillis(CharSequence name, long value) {
         add(name, fromTimeMillis(value));
     }
 
     final void set(CharSequence name, String value) {
-        final AsciiString normalizedName = normalizeName(name);
+        final AsciiString normalizedName = HttpHeaderNames.of(name);
         requireNonNull(value, "value");
         final int h = normalizedName.hashCode();
         final int i = index(h);
         remove0(h, i, normalizedName);
         add0(h, i, normalizedName, value);
     }
 
     final void set(CharSequence name, Iterable<String> values) {
-        final AsciiString normalizedName = normalizeName(name);
+        final AsciiString normalizedName = HttpHeaderNames.of(name);
         requireNonNull(values, "values");
 
         final int h = normalizedName.hashCode();
         final int i = index(h);
 
@@ -659,11 +677,11 @@ class HttpHeadersBase implements HttpHeaderGetters {
             add0(h, i, normalizedName, v);
         }
     }
 
     final void set(CharSequence name, String... values) {
-        final AsciiString normalizedName = normalizeName(name);
+        final AsciiString normalizedName = HttpHeaderNames.of(name);
         requireNonNull(values, "values");
 
         final int h = normalizedName.hashCode();
         final int i = index(h);
 
@@ -737,11 +755,11 @@ class HttpHeadersBase implements HttpHeaderGetters {
         requireNonNull(value, "value");
         set(name, fromObject(value));
     }
 
     final void setObject(CharSequence name, Iterable<?> values) {
-        final AsciiString normalizedName = normalizeName(name);
+        final AsciiString normalizedName = HttpHeaderNames.of(name);
         requireNonNull(values, "values");
 
         final int h = normalizedName.hashCode();
         final int i = index(h);
 
@@ -751,11 +769,11 @@ class HttpHeadersBase implements HttpHeaderGetters {
             add0(h, i, normalizedName, fromObject(v));
         }
     }
 
     final void setObject(CharSequence name, Object... values) {
-        final AsciiString normalizedName = normalizeName(name);
+        final AsciiString normalizedName = HttpHeaderNames.of(name);
         requireNonNull(values, "values");
 
         final int h = normalizedName.hashCode();
         final int i = index(h);
 
@@ -811,15 +829,10 @@ class HttpHeadersBase implements HttpHeaderGetters {
         Arrays.fill(entries, null);
         firstNonPseudo = head.before = head.after = head;
         size = 0;
     }
 
-    private static AsciiString normalizeName(CharSequence name) {
-        checkArgument(requireNonNull(name, "name").length() > 0, "name is empty.");
-        return HttpHeaderNames.of(name);
-    }
-
     private static void requireNonNullElement(Object values, @Nullable Object e) {
         if (e == null) {
             throw new NullPointerException("values contains null: " + values);
         }
     }
@@ -827,15 +840,48 @@ class HttpHeadersBase implements HttpHeaderGetters {
     private int index(int hash) {
         return hash & hashMask;
     }
 
     private void add0(int h, int i, AsciiString name, String value) {
+        validateValue(value);
         // Update the hash table.
         entries[i] = new HeaderEntry(h, name, value, entries[i]);
         ++size;
     }
 
+    private static void validateValue(String value) {
+        final int valueLength = value.length();
+        for (int i = 0; i < valueLength; i++) {
+            final char ch = value.charAt(i);
+            if ((ch & PROHIBITED_VALUE_CHAR_MASK) != 0) { // ch >= 16
+                continue;
+            }
+
+            // ch < 16
+            if (PROHIBITED_VALUE_CHARS.get(ch)) {
+                throw new IllegalArgumentException(malformedHeaderValueMessage(value));
+            }
+        }
+    }
+
+    private static String malformedHeaderValueMessage(String value) {
+        final StringBuilder buf = new StringBuilder(IntMath.saturatedAdd(value.length(), 64));
+        buf.append("malformed header value: ");
+
+        final int valueLength = value.length();
+        for (int i = 0; i < valueLength; i++) {
+            final char ch = value.charAt(i);
+            if (PROHIBITED_VALUE_CHARS.get(ch)) {
+                buf.append(PROHIBITED_VALUE_CHAR_NAMES[ch]);
+            } else {
+                buf.append(ch);
+            }
+        }
+
+        return buf.toString();
+    }
+
     private boolean addFast(Iterable<? extends Entry<? extends CharSequence, ?>> headers) {
         if (!(headers instanceof HttpHeadersBase)) {
             return false;
         }
 
diff --git a/core/src/main/java/com/linecorp/armeria/internal/ArmeriaHttpUtil.java b/core/src/main/java/com/linecorp/armeria/internal/ArmeriaHttpUtil.java
index 3b408409..689affcb 100644
--- a/core/src/main/java/com/linecorp/armeria/internal/ArmeriaHttpUtil.java
+++ b/core/src/main/java/com/linecorp/armeria/internal/ArmeriaHttpUtil.java
@@ -671,11 +671,11 @@ public final class ArmeriaHttpUtil {
     private static CharSequenceMap toLowercaseMap(Iterator<? extends CharSequence> valuesIter,
                                                   int arraySizeHint) {
         final CharSequenceMap result = new CharSequenceMap(arraySizeHint);
 
         while (valuesIter.hasNext()) {
-            final AsciiString lowerCased = HttpHeaderNames.of(valuesIter.next()).toLowerCase();
+            final AsciiString lowerCased = AsciiString.of(valuesIter.next()).toLowerCase();
             try {
                 int index = lowerCased.forEachByte(FIND_COMMA);
                 if (index != -1) {
                     int start = 0;
                     do {
diff --git a/core/src/test/java/com/linecorp/armeria/common/HttpHeaderNamesTest.java b/core/src/test/java/com/linecorp/armeria/common/HttpHeaderNamesTest.java
index 5a4befcd..6c236421 100644
--- a/core/src/test/java/com/linecorp/armeria/common/HttpHeaderNamesTest.java
+++ b/core/src/test/java/com/linecorp/armeria/common/HttpHeaderNamesTest.java
@@ -14,19 +14,20 @@
  * under the License.
  */
 package com.linecorp.armeria.common;
 
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
 
-import org.junit.Test;
+import org.junit.jupiter.api.Test;
 
 import io.netty.util.AsciiString;
 
-public class HttpHeaderNamesTest {
+class HttpHeaderNamesTest {
 
     @Test
-    public void testOfAsciiString() {
+    void testOfAsciiString() {
         // Should produce a lower-cased AsciiString.
         final AsciiString mixedCased = AsciiString.of("Foo");
         assertThat((Object) HttpHeaderNames.of(mixedCased)).isNotSameAs(mixedCased);
         assertThat(HttpHeaderNames.of(mixedCased).toString()).isEqualTo("foo");
 
@@ -37,13 +38,68 @@ public class HttpHeaderNamesTest {
         // Should reuse known header name instances.
         assertThat((Object) HttpHeaderNames.of(AsciiString.of("date"))).isSameAs(HttpHeaderNames.DATE);
     }
 
     @Test
-    public void testOfCharSequence() {
+    void testOfCharSequence() {
         // Should produce a lower-cased AsciiString.
         assertThat((Object) HttpHeaderNames.of("Foo")).isEqualTo(AsciiString.of("foo"));
 
         // Should reuse known header name instances.
         assertThat((Object) HttpHeaderNames.of("date")).isSameAs(HttpHeaderNames.DATE);
     }
+
+    @Test
+    void pseudoHeaderNameValidation() {
+        // Known pseudo header names should pass validation.
+        assertThat((Object) HttpHeaderNames.of(":method")).isSameAs(HttpHeaderNames.METHOD);
+        assertThat((Object) HttpHeaderNames.of(":scheme")).isSameAs(HttpHeaderNames.SCHEME);
+        assertThat((Object) HttpHeaderNames.of(":authority")).isSameAs(HttpHeaderNames.AUTHORITY);
+        assertThat((Object) HttpHeaderNames.of(":path")).isSameAs(HttpHeaderNames.PATH);
+        assertThat((Object) HttpHeaderNames.of(":status")).isSameAs(HttpHeaderNames.STATUS);
+
+        // However, any other headers that start with `:` should fail.
+        assertThatThrownBy(() -> HttpHeaderNames.of(":foo"))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("malformed header name: :foo");
+    }
+
+    @Test
+    void headerNameValidation() {
+        assertThatThrownBy(() -> HttpHeaderNames.of(""))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("malformed header name: <EMPTY>");
+        assertThatThrownBy(() -> HttpHeaderNames.of("\u0000"))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("malformed header name: <NUL>");
+        assertThatThrownBy(() -> HttpHeaderNames.of("\t"))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("malformed header name: <TAB>");
+        assertThatThrownBy(() -> HttpHeaderNames.of("\n"))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("malformed header name: <LF>");
+        assertThatThrownBy(() -> HttpHeaderNames.of("\u000B"))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("malformed header name: <VT>");
+        assertThatThrownBy(() -> HttpHeaderNames.of("\f"))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("malformed header name: <FF>");
+        assertThatThrownBy(() -> HttpHeaderNames.of("\r"))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("malformed header name: <CR>");
+        assertThatThrownBy(() -> HttpHeaderNames.of(" "))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("malformed header name: <SP>");
+        assertThatThrownBy(() -> HttpHeaderNames.of(","))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("malformed header name: ,");
+        assertThatThrownBy(() -> HttpHeaderNames.of(":"))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("malformed header name: :");
+        assertThatThrownBy(() -> HttpHeaderNames.of(";"))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("malformed header name: ;");
+        assertThatThrownBy(() -> HttpHeaderNames.of("="))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("malformed header name: =");
+    }
 }
diff --git a/core/src/test/java/com/linecorp/armeria/common/HttpHeadersBaseTest.java b/core/src/test/java/com/linecorp/armeria/common/HttpHeadersBaseTest.java
index 13c7b1b6..d6898077 100644
--- a/core/src/test/java/com/linecorp/armeria/common/HttpHeadersBaseTest.java
+++ b/core/src/test/java/com/linecorp/armeria/common/HttpHeadersBaseTest.java
@@ -29,40 +29,40 @@
  */
 package com.linecorp.armeria.common;
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
-import static org.junit.Assert.fail;
+import static org.junit.jupiter.api.Assertions.fail;
 
 import java.net.URI;
 import java.util.Iterator;
 import java.util.List;
 import java.util.Map;
 import java.util.NoSuchElementException;
 
-import org.junit.Test;
+import org.junit.jupiter.api.Test;
 
 import com.google.common.collect.ImmutableList;
 
 import io.netty.handler.codec.http2.Http2Headers.PseudoHeaderName;
 import io.netty.util.AsciiString;
 
-public class HttpHeadersBaseTest {
+class HttpHeadersBaseTest {
 
     @Test
-    public void testEqualsInsertionOrderSameHeaderName() {
+    void testEqualsInsertionOrderSameHeaderName() {
         final HttpHeadersBase h1 = newEmptyHeaders();
         h1.add("a", "b");
         h1.add("a", "c");
         final HttpHeadersBase h2 = newEmptyHeaders();
         h2.add("a", "c");
         h2.add("a", "b");
         assertThat(h1).isNotEqualTo(h2);
     }
 
     @Test
-    public void testEqualsInsertionOrderDifferentHeaderNames() {
+    void testEqualsInsertionOrderDifferentHeaderNames() {
         final HttpHeadersBase h1 = newEmptyHeaders();
         h1.add("a", "b");
         h1.add("c", "d");
         final HttpHeadersBase h2 = newEmptyHeaders();
         h2.add("c", "d");
@@ -71,11 +71,11 @@ public class HttpHeadersBaseTest {
     }
 
     // Tests forked from io.netty.handler.codec.DefaultHeadersTest
 
     @Test
-    public void addShouldIncreaseAndRemoveShouldDecreaseTheSize() {
+    void addShouldIncreaseAndRemoveShouldDecreaseTheSize() {
         final HttpHeadersBase headers = newEmptyHeaders();
         assertThat(headers.size()).isEqualTo(0);
         headers.add("name1", "value1", "value2");
         assertThat(headers.size()).isEqualTo(2);
         headers.add("name2", "value3", "value4");
@@ -91,11 +91,11 @@ public class HttpHeadersBaseTest {
         assertThat(headers.size()).isEqualTo(0);
         assertThat(headers.isEmpty()).isTrue();
     }
 
     @Test
-    public void afterClearHeadersShouldBeEmpty() {
+    void afterClearHeadersShouldBeEmpty() {
         final HttpHeadersBase headers = newEmptyHeaders();
         headers.add("name1", "value1");
         headers.add("name2", "value2");
         assertThat(headers.size()).isEqualTo(2);
         headers.clear();
@@ -104,20 +104,20 @@ public class HttpHeadersBaseTest {
         assertThat(headers.contains("name1")).isFalse();
         assertThat(headers.contains("name2")).isFalse();
     }
 
     @Test
-    public void removingANameForASecondTimeShouldReturnFalse() {
+    void removingANameForASecondTimeShouldReturnFalse() {
         final HttpHeadersBase headers = newEmptyHeaders();
         headers.add("name1", "value1");
         headers.add("name2", "value2");
         assertThat(headers.remove("name2")).isTrue();
         assertThat(headers.remove("name2")).isFalse();
     }
 
     @Test
-    public void multipleValuesPerNameShouldBeAllowed() {
+    void multipleValuesPerNameShouldBeAllowed() {
         final HttpHeadersBase headers = newEmptyHeaders();
         headers.add("name", "value1");
         headers.add("name", "value2");
         headers.add("name", "value3");
         assertThat(headers.size()).isEqualTo(3);
@@ -126,11 +126,11 @@ public class HttpHeadersBaseTest {
         assertThat(values).hasSize(3)
                           .containsExactly("value1", "value2", "value3");
     }
 
     @Test
-    public void multipleValuesPerNameIteratorWithOtherNames() {
+    void multipleValuesPerNameIteratorWithOtherNames() {
         final HttpHeadersBase headers = newEmptyHeaders();
         headers.add("name1", "value1");
         headers.add("name1", "value2");
         headers.add("name2", "value4");
         headers.add("name1", "value3");
@@ -144,30 +144,30 @@ public class HttpHeadersBaseTest {
         assertThat(itr.next()).isEqualTo("value4");
         assertThat(itr).isExhausted();
     }
 
     @Test
-    public void multipleValuesPerNameIterator() {
+    void multipleValuesPerNameIterator() {
         final HttpHeadersBase headers = newEmptyHeaders();
         headers.add("name1", "value1");
         headers.add("name1", "value2");
         assertThat(headers.size()).isEqualTo(2);
 
         final List<String> values = ImmutableList.copyOf(headers.valueIterator("name1"));
         assertThat(values).containsExactly("value1", "value2");
     }
 
     @Test
-    public void multipleValuesPerNameIteratorEmpty() {
+    void multipleValuesPerNameIteratorEmpty() {
         final HttpHeadersBase headers = newEmptyHeaders();
         assertThat(headers.valueIterator("name")).isExhausted();
         assertThatThrownBy(() -> headers.valueIterator("name").next())
                 .isInstanceOf(NoSuchElementException.class);
     }
 
     @Test
-    public void testContains() {
+    void testContains() {
         final HttpHeadersBase headers = newEmptyHeaders();
 
         headers.addLong("long", Long.MAX_VALUE);
         assertThat(headers.containsLong("long", Long.MAX_VALUE)).isTrue();
         assertThat(headers.containsLong("long", Long.MIN_VALUE)).isFalse();
@@ -198,11 +198,11 @@ public class HttpHeadersBaseTest {
         assertThat(headers.contains("name", "value")).isTrue();
         assertThat(headers.contains("name", "value1")).isFalse();
     }
 
     @Test
-    public void testCopy() throws Exception {
+    void testCopy() throws Exception {
         HttpHeadersBase headers = newEmptyHeaders();
         headers.addLong("long", Long.MAX_VALUE);
         headers.addInt("int", Integer.MIN_VALUE);
         headers.addDouble("double", Double.MAX_VALUE);
         headers.addFloat("float", Float.MAX_VALUE);
@@ -237,11 +237,11 @@ public class HttpHeadersBaseTest {
         assertThat(headers.contains("name", "value")).isTrue();
         assertThat(headers.contains("name", "value1")).isFalse();
     }
 
     @Test
-    public void canMixConvertedAndNormalValues() {
+    void canMixConvertedAndNormalValues() {
         final HttpHeadersBase headers = newEmptyHeaders();
         headers.add("name", "value");
         headers.addInt("name", 100);
 
         assertThat(headers.size()).isEqualTo(2);
@@ -249,11 +249,11 @@ public class HttpHeadersBaseTest {
         assertThat(headers.contains("name", "value")).isTrue();
         assertThat(headers.containsInt("name", 100)).isTrue();
     }
 
     @Test
-    public void testGetAndRemove() {
+    void testGetAndRemove() {
         final HttpHeadersBase headers = newEmptyHeaders();
         headers.add("name1", "value1");
         headers.add("name2", "value2", "value3");
         headers.add("name3", "value4", "value5", "value6");
 
@@ -265,38 +265,38 @@ public class HttpHeadersBaseTest {
         assertThat(headers.getAndRemove("noname")).isNull();
         assertThat(headers.getAndRemove("noname", "defaultvalue")).isEqualTo("defaultvalue");
     }
 
     @Test
-    public void whenNameContainsMultipleValuesGetShouldReturnTheFirst() {
+    void whenNameContainsMultipleValuesGetShouldReturnTheFirst() {
         final HttpHeadersBase headers = newEmptyHeaders();
         headers.add("name1", "value1", "value2");
         assertThat(headers.get("name1")).isEqualTo("value1");
     }
 
     @Test
-    public void getWithDefaultValueWorks() {
+    void getWithDefaultValueWorks() {
         final HttpHeadersBase headers = newEmptyHeaders();
         headers.add("name1", "value1");
 
         assertThat(headers.get("name1", "defaultvalue")).isEqualTo("value1");
         assertThat(headers.get("noname", "defaultvalue")).isEqualTo("defaultvalue");
     }
 
     @Test
-    public void setShouldOverWritePreviousValue() {
+    void setShouldOverWritePreviousValue() {
         final HttpHeadersBase headers = newEmptyHeaders();
         headers.set("name", "value1");
         headers.set("name", "value2");
         assertThat(headers.size()).isEqualTo(1);
         assertThat(headers.getAll("name").size()).isEqualTo(1);
         assertThat(headers.getAll("name").get(0)).isEqualTo("value2");
         assertThat(headers.get("name")).isEqualTo("value2");
     }
 
     @Test
-    public void setAllShouldOverwriteSomeAndLeaveOthersUntouched() {
+    void setAllShouldOverwriteSomeAndLeaveOthersUntouched() {
         final HttpHeadersBase h1 = newEmptyHeaders();
 
         h1.add("name1", "value1");
         h1.add("name2", "value2");
         h1.add("name2", "value3");
@@ -317,11 +317,11 @@ public class HttpHeadersBaseTest {
 
         assertThat(h1).isEqualTo(expected);
     }
 
     @Test
-    public void headersWithSameNamesAndValuesShouldBeEquivalent() {
+    void headersWithSameNamesAndValuesShouldBeEquivalent() {
         final HttpHeadersBase headers1 = newEmptyHeaders();
         headers1.add("name1", "value1");
         headers1.add("name2", "value2");
         headers1.add("name2", "value3");
 
@@ -338,57 +338,57 @@ public class HttpHeadersBaseTest {
         assertThat(headers1.hashCode()).isEqualTo(headers1.hashCode());
         assertThat(headers2.hashCode()).isEqualTo(headers2.hashCode());
     }
 
     @Test
-    public void emptyHeadersShouldBeEqual() {
+    void emptyHeadersShouldBeEqual() {
         final HttpHeadersBase headers1 = newEmptyHeaders();
         final HttpHeadersBase headers2 = newEmptyHeaders();
         assertThat(headers2).isEqualTo(headers1);
         assertThat(headers2.hashCode()).isEqualTo(headers1.hashCode());
     }
 
     @Test
-    public void headersWithSameNamesButDifferentValuesShouldNotBeEquivalent() {
+    void headersWithSameNamesButDifferentValuesShouldNotBeEquivalent() {
         final HttpHeadersBase headers1 = newEmptyHeaders();
         headers1.add("name1", "value1");
         final HttpHeadersBase headers2 = newEmptyHeaders();
         headers1.add("name1", "value2");
         assertThat(headers1).isNotEqualTo(headers2);
     }
 
     @Test
-    public void subsetOfHeadersShouldNotBeEquivalent() {
+    void subsetOfHeadersShouldNotBeEquivalent() {
         final HttpHeadersBase headers1 = newEmptyHeaders();
         headers1.add("name1", "value1");
         headers1.add("name2", "value2");
         final HttpHeadersBase headers2 = newEmptyHeaders();
         headers1.add("name1", "value1");
         assertThat(headers1).isNotEqualTo(headers2);
     }
 
     @Test
-    public void headersWithDifferentNamesAndValuesShouldNotBeEquivalent() {
+    void headersWithDifferentNamesAndValuesShouldNotBeEquivalent() {
         final HttpHeadersBase h1 = newEmptyHeaders();
         h1.set("name1", "value1");
         final HttpHeadersBase h2 = newEmptyHeaders();
         h2.set("name2", "value2");
         assertThat(h1).isNotEqualTo(h2);
         assertThat(h2).isNotEqualTo(h1);
         assertThat(h1).isEqualTo(h1);
         assertThat(h2).isEqualTo(h2);
     }
 
-    @Test(expected = NoSuchElementException.class)
-    public void iterateEmptyHeadersShouldThrow() {
+    @Test
+    void iterateEmptyHeadersShouldThrow() {
         final Iterator<Map.Entry<AsciiString, String>> iterator = newEmptyHeaders().iterator();
         assertThat(iterator.hasNext()).isFalse();
-        iterator.next();
+        assertThatThrownBy(iterator::next).isInstanceOf(NoSuchElementException.class);
     }
 
     @Test
-    public void iteratorShouldReturnAllNameValuePairs() {
+    void iteratorShouldReturnAllNameValuePairs() {
         final HttpHeadersBase headers1 = newEmptyHeaders();
         headers1.add("name1", "value1", "value2");
         headers1.add("name2", "value3");
         headers1.add("name3", "value4", "value5", "value6");
         headers1.add("name1", "value7", "value8");
@@ -401,22 +401,22 @@ public class HttpHeadersBaseTest {
 
         assertThat(headers2).isEqualTo(headers1);
     }
 
     @Test
-    public void iteratorSetShouldFail() {
+    void iteratorSetShouldFail() {
         final HttpHeadersBase headers = newEmptyHeaders();
         headers.add("name1", "value1", "value2", "value3");
         headers.add("name2", "value4");
         assertThat(headers.size()).isEqualTo(4);
 
         assertThatThrownBy(() -> headers.iterator().next().setValue(""))
                 .isInstanceOf(UnsupportedOperationException.class);
     }
 
     @Test
-    public void testEntryEquals() {
+    void testEntryEquals() {
         final HttpHeadersBase nameValue = newEmptyHeaders();
         nameValue.add("name", "value");
         final HttpHeadersBase nameValueCopy = newEmptyHeaders();
         nameValueCopy.add("name", "value");
         final Map.Entry<AsciiString, String> same1 = nameValue.iterator().next();
@@ -442,17 +442,17 @@ public class HttpHeadersBaseTest {
         assertThat(valueDifferent1).isNotEqualTo(valueDifferent2);
         assertThat(valueDifferent1.hashCode()).isNotEqualTo(valueDifferent2.hashCode());
     }
 
     @Test
-    public void getAllReturnsEmptyListForUnknownName() {
+    void getAllReturnsEmptyListForUnknownName() {
         final HttpHeadersBase headers = newEmptyHeaders();
         assertThat(headers.getAll("noname").size()).isEqualTo(0);
     }
 
     @Test
-    public void setHeadersShouldClearAndOverwrite() {
+    void setHeadersShouldClearAndOverwrite() {
         final HttpHeadersBase headers1 = newEmptyHeaders();
         headers1.add("name", "value");
 
         final HttpHeadersBase headers2 = newEmptyHeaders();
         headers2.add("name", "newvalue");
@@ -461,11 +461,11 @@ public class HttpHeadersBaseTest {
         headers1.set(headers2);
         assertThat(headers2).isEqualTo(headers1);
     }
 
     @Test
-    public void setHeadersShouldOnlyOverwriteHeaders() {
+    void setHeadersShouldOnlyOverwriteHeaders() {
         final HttpHeadersBase headers1 = newEmptyHeaders();
         headers1.add("name", "value");
         headers1.add("name1", "value1");
 
         final HttpHeadersBase headers2 = newEmptyHeaders();
@@ -479,26 +479,26 @@ public class HttpHeadersBaseTest {
 
         headers1.set(headers2);
         assertThat(expected).isEqualTo(headers1);
     }
 
-    @Test(expected = IllegalArgumentException.class)
-    public void testAddSelf() {
+    @Test
+    void testAddSelf() {
         final HttpHeadersBase headers = newEmptyHeaders();
-        headers.add(headers);
+        assertThatThrownBy(() -> headers.add(headers)).isInstanceOf(IllegalArgumentException.class);
     }
 
     @Test
-    public void testSetSelfIsNoOp() {
+    void testSetSelfIsNoOp() {
         final HttpHeadersBase headers = newEmptyHeaders();
         headers.add("name", "value");
         headers.set(headers);
         assertThat(headers.size()).isEqualTo(1);
     }
 
     @Test
-    public void testToString() {
+    void testToString() {
         HttpHeadersBase headers = newEmptyHeaders();
         headers.add("name1", "value1");
         headers.add("name1", "value2");
         headers.add("name2", "value3");
         assertThat(headers.toString()).isEqualTo("[name1=value1, name1=value2, name2=value3]");
@@ -525,11 +525,11 @@ public class HttpHeadersBaseTest {
         headers.endOfStream(true);
         assertThat(headers.toString()).isEqualTo("[EOS]");
     }
 
     @Test
-    public void testNotThrowWhenConvertFails() {
+    void testNotThrowWhenConvertFails() {
         final HttpHeadersBase headers = newEmptyHeaders();
         headers.set("name1", "");
         assertThat(headers.getInt("name1")).isNull();
         assertThat(headers.getInt("name1", 1)).isEqualTo(1);
 
@@ -544,51 +544,73 @@ public class HttpHeadersBaseTest {
 
         assertThat(headers.getTimeMillis("name")).isNull();
         assertThat(headers.getTimeMillis("name1", Long.MAX_VALUE)).isEqualTo(Long.MAX_VALUE);
     }
 
+    @Test
+    void valueValidation() {
+        final HttpHeadersBase headers = newEmptyHeaders();
+        assertThatThrownBy(() -> headers.add("foo", "\u0000"))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("malformed header value: <NUL>");
+        assertThatThrownBy(() -> headers.add("foo", "\n"))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("malformed header value: <LF>");
+        assertThatThrownBy(() -> headers.add("foo", "\u000B"))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("malformed header value: <VT>");
+        assertThatThrownBy(() -> headers.add("foo", "\f"))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("malformed header value: <FF>");
+        assertThatThrownBy(() -> headers.add("foo", "\r"))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("malformed header value: <CR>");
+    }
+
     // Tests forked from io.netty.handler.codec.http.HttpHeadersTest
 
     @Test
-    public void testGetOperations() {
+    void testGetOperations() {
         final HttpHeadersBase headers = newEmptyHeaders();
         headers.add("Foo", "1");
         headers.add("Foo", "2");
 
         assertThat(headers.get("Foo")).isEqualTo("1");
 
         final List<String> values = headers.getAll("Foo");
         assertThat(values).containsExactly("1", "2");
     }
 
-    @Test(expected = NullPointerException.class)
-    public void testSetNullHeaderValue() {
-        final HttpHeadersBase headers = newEmptyHeaders();
-        headers.set("test", (String) null);
+    @Test
+    void testSetNullHeaderValue() {
+        assertThatThrownBy(() -> newEmptyHeaders().set("test", (String) null))
+                .isInstanceOf(NullPointerException.class);
     }
 
     // Tests forked from io.netty.handler.codec.http2.DefaultHttp2HeadersTest
 
-    @Test(expected = NullPointerException.class)
-    public void nullHeaderNameNotAllowed() {
-        newEmptyHeaders().add(null, "foo");
+    @Test
+    void nullHeaderNameNotAllowed() {
+        assertThatThrownBy(() -> newEmptyHeaders().add(null, "foo")).isInstanceOf(NullPointerException.class);
     }
 
-    @Test(expected = IllegalArgumentException.class)
-    public void emptyHeaderNameNotAllowed() {
-        newEmptyHeaders().add("", "foo");
+    @Test
+    void emptyHeaderNameNotAllowed() {
+        assertThatThrownBy(() -> newEmptyHeaders().add("", "foo"))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("malformed header name: <EMPTY>");
     }
 
     @Test
-    public void testPseudoHeadersMustComeFirstWhenIterating() {
+    void testPseudoHeadersMustComeFirstWhenIterating() {
         final HttpHeadersBase headers = newHttp2Headers();
         verifyPseudoHeadersFirst(headers);
         verifyAllPseudoHeadersPresent(headers);
     }
 
     @Test
-    public void testPseudoHeadersWithRemovePreservesPseudoIterationOrder() {
+    void testPseudoHeadersWithRemovePreservesPseudoIterationOrder() {
         final HttpHeadersBase headers = newHttp2Headers();
         final HttpHeadersBase nonPseudoHeaders = newEmptyHeaders();
         for (Map.Entry<AsciiString, String> entry : headers) {
             if (entry.getKey().isEmpty() ||
                 entry.getKey().charAt(0) != ':' && !nonPseudoHeaders.contains(entry.getKey())) {
@@ -612,11 +634,11 @@ public class HttpHeadersBaseTest {
             verifyAllPseudoHeadersPresent(headers);
         }
     }
 
     @Test
-    public void testPseudoHeadersWithClearDoesNotLeak() {
+    void testPseudoHeadersWithClearDoesNotLeak() {
         final HttpHeadersBase headers = newHttp2Headers();
 
         assertThat(headers.isEmpty()).isFalse();
         headers.clear();
         assertThat(headers.isEmpty()).isTrue();
@@ -641,11 +663,11 @@ public class HttpHeadersBaseTest {
         assertThat(headers.scheme()).isEqualTo("nothing");
         assertThat(headers.authority()).isEqualTo("foo");
     }
 
     @Test
-    public void testSetOrdersPseudoHeadersCorrectly() {
+    void testSetOrdersPseudoHeadersCorrectly() {
         final HttpHeadersBase headers = newHttp2Headers();
         final HttpHeadersBase other = newEmptyHeaders();
         other.add("name2", "value2");
         other.add("name3", "value3");
         other.add("name4", "value4");
@@ -661,41 +683,41 @@ public class HttpHeadersBaseTest {
         assertThat(headers.get("name3")).isEqualTo("value3");
         assertThat(headers.get("name4")).isEqualTo("value4");
     }
 
     @Test
-    public void testHeaderNameNormalization() {
+    void testHeaderNameNormalization() {
         final HttpHeadersBase headers = newHttp2Headers();
         headers.add("Foo", "bar");
         assertThat(headers.getAll("foo")).containsExactly("bar");
         assertThat(headers.getAll("fOO")).containsExactly("bar");
         assertThat(headers.names()).contains(HttpHeaderNames.of("foo"))
                                    .doesNotContain(AsciiString.of("Foo"));
     }
 
     @Test
-    public void testClearResetsPseudoHeaderDivision() {
+    void testClearResetsPseudoHeaderDivision() {
         final HttpHeadersBase http2Headers = newHttp2Headers();
         http2Headers.method(HttpMethod.POST);
         http2Headers.set("some", "value");
         http2Headers.clear();
         http2Headers.method(HttpMethod.GET);
         assertThat(http2Headers.names()).containsExactly(HttpHeaderNames.METHOD);
         assertThat(http2Headers.getAll(HttpHeaderNames.METHOD)).containsExactly("GET");
     }
 
     @Test
-    public void testContainsNameAndValue() {
+    void testContainsNameAndValue() {
         final HttpHeadersBase headers = newHttp2Headers();
         assertThat(headers.contains("name1", "value2")).isTrue();
         assertThat(headers.contains("name1", "Value2")).isFalse();
         assertThat(headers.contains("name2", "value3")).isTrue();
         assertThat(headers.contains("name2", "Value3")).isFalse();
     }
 
     @Test
-    public void testUri() {
+    void testUri() {
         final HttpHeadersBase headers = newHttp2Headers();
         assertThat(headers.uri()).isEqualTo(URI.create("https://netty.io/index.html"));
     }
 
     private static void verifyAllPseudoHeadersPresent(HttpHeadersBase headers) {
diff --git a/core/src/test/java/com/linecorp/armeria/server/HttpServerHeaderValidationTest.java b/core/src/test/java/com/linecorp/armeria/server/HttpServerHeaderValidationTest.java
new file mode 100644
index 00000000..807102e7
--- /dev/null
+++ b/core/src/test/java/com/linecorp/armeria/server/HttpServerHeaderValidationTest.java
@@ -0,0 +1,101 @@
+/*
+ * Copyright 2016 LINE Corporation
+ *
+ * LINE Corporation licenses this file to you under the Apache License,
+ * version 2.0 (the "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at:
+ *
+ *   https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations
+ * under the License.
+ */
+package com.linecorp.armeria.server;
+
+import static com.linecorp.armeria.common.SessionProtocol.H1;
+import static com.linecorp.armeria.common.SessionProtocol.H1C;
+import static com.linecorp.armeria.common.SessionProtocol.H2;
+import static com.linecorp.armeria.common.SessionProtocol.H2C;
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.stream.Stream;
+
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.Timeout;
+import org.junit.jupiter.api.extension.ExtensionContext;
+import org.junit.jupiter.api.extension.RegisterExtension;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.ArgumentsProvider;
+import org.junit.jupiter.params.provider.ArgumentsSource;
+
+import com.linecorp.armeria.client.ClientFactory;
+import com.linecorp.armeria.client.WebClient;
+import com.linecorp.armeria.common.AggregatedHttpResponse;
+import com.linecorp.armeria.common.HttpData;
+import com.linecorp.armeria.common.HttpResponse;
+import com.linecorp.armeria.common.HttpStatus;
+import com.linecorp.armeria.common.ResponseHeaders;
+import com.linecorp.armeria.testing.junit.server.ServerExtension;
+
+import io.netty.handler.codec.http.QueryStringDecoder;
+import io.netty.handler.ssl.util.InsecureTrustManagerFactory;
+
+@Timeout(10000)
+class HttpServerHeaderValidationTest {
+
+    static final ClientFactory clientFactory = ClientFactory.builder().sslContextCustomizer(scb -> {
+        scb.trustManager(InsecureTrustManagerFactory.INSTANCE);
+    }).build();
+
+    @RegisterExtension
+    static final ServerExtension server = new ServerExtension() {
+        @Override
+        protected void configure(ServerBuilder sb) throws Exception {
+            sb.http(0);
+            sb.https(0);
+            sb.tlsSelfSigned();
+
+            sb.route().get("/headers-custom")
+              .build((ctx, req) -> {
+                  final String param = new QueryStringDecoder(req.path()).parameters()
+                                                                         .get("param").get(0);
+                  return HttpResponse.of(
+                          ResponseHeaders.of(HttpStatus.OK, "server-header", param),
+                          HttpData.ofUtf8("OK"));
+              });
+        }
+    };
+
+    @AfterAll
+    static void closeClientFactory() {
+        clientFactory.close();
+    }
+
+    @ParameterizedTest
+    @ArgumentsSource(WebClientProvider.class)
+    void malformedHeaderValue(WebClient client) throws Exception {
+        final String payloadRaw = "my-header\r\nnot-a-header: should_be_illegal";
+        final String payload = URLEncoder.encode(payloadRaw, StandardCharsets.US_ASCII.name());
+        final String path = "/headers-custom?param=" + payload;
+        final AggregatedHttpResponse res = client.get(path).aggregate().get();
+        assertThat(res.status()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
+        assertThat(res.headers().get("not-a-header")).isNull();
+    }
+
+    private static class WebClientProvider implements ArgumentsProvider {
+        @Override
+        public Stream<? extends Arguments> provideArguments(ExtensionContext context) {
+            return Stream.of(H1C, H1, H2C, H2)
+                         .map(protocol -> Arguments.of(WebClient.of(
+                                 clientFactory,
+                                 protocol.uriText() + "://127.0.0.1:" +
+                                 (protocol.isTls() ? server.httpsPort() : server.httpPort()))));
+        }
+    }
+}

Vulnerable code:         return cached != null ? cached : AsciiString.cached(lowerCased);
        return cached != null ? cached : lowerCased;
    private static AsciiString create(String name) {
        return AsciiString.cached(Ascii.toLowerCase(name));
import static com.google.common.base.Preconditions.checkArgument;
        final AsciiString normalizedName = normalizeName(name);
        final AsciiString normalizedName = normalizeName(name);
        final AsciiString normalizedName = normalizeName(name);
        final AsciiString normalizedName = normalizeName(name);
        final AsciiString normalizedName = normalizeName(name);
        final AsciiString normalizedName = normalizeName(name);
        final AsciiString normalizedName = normalizeName(name);
        final AsciiString normalizedName = normalizeName(name);
        final AsciiString normalizedName = normalizeName(name);
        final AsciiString normalizedName = normalizeName(name);
    private static AsciiString normalizeName(CharSequence name) {
        checkArgument(requireNonNull(name, "name").length() > 0, "name is empty.");
        return HttpHeaderNames.of(name);
    }

            final AsciiString lowerCased = HttpHeaderNames.of(valuesIter.next()).toLowerCase();
import org.junit.Test;
public class HttpHeaderNamesTest {
    public void testOfAsciiString() {
    public void testOfCharSequence() {
import static org.junit.Assert.fail;
import org.junit.Test;
public class HttpHeadersBaseTest {
    public void testEqualsInsertionOrderSameHeaderName() {
    public void testEqualsInsertionOrderDifferentHeaderNames() {
    public void addShouldIncreaseAndRemoveShouldDecreaseTheSize() {
    public void afterClearHeadersShouldBeEmpty() {
    public void removingANameForASecondTimeShouldReturnFalse() {
    public void multipleValuesPerNameShouldBeAllowed() {
    public void multipleValuesPerNameIteratorWithOtherNames() {
    public void multipleValuesPerNameIterator() {
    public void multipleValuesPerNameIteratorEmpty() {
    public void testContains() {
    public void testCopy() throws Exception {
    public void canMixConvertedAndNormalValues() {
    public void testGetAndRemove() {
    public void whenNameContainsMultipleValuesGetShouldReturnTheFirst() {
    public void getWithDefaultValueWorks() {
    public void setShouldOverWritePreviousValue() {
    public void setAllShouldOverwriteSomeAndLeaveOthersUntouched() {
    public void headersWithSameNamesAndValuesShouldBeEquivalent() {
    public void emptyHeadersShouldBeEqual() {
    public void headersWithSameNamesButDifferentValuesShouldNotBeEquivalent() {
    public void subsetOfHeadersShouldNotBeEquivalent() {
    public void headersWithDifferentNamesAndValuesShouldNotBeEquivalent() {
    @Test(expected = NoSuchElementException.class)
    public void iterateEmptyHeadersShouldThrow() {
        iterator.next();
    public void iteratorShouldReturnAllNameValuePairs() {
    public void iteratorSetShouldFail() {
    public void testEntryEquals() {
    public void getAllReturnsEmptyListForUnknownName() {
    public void setHeadersShouldClearAndOverwrite() {
    public void setHeadersShouldOnlyOverwriteHeaders() {
    @Test(expected = IllegalArgumentException.class)
    public void testAddSelf() {
        headers.add(headers);
    public void testSetSelfIsNoOp() {
    public void testToString() {
    public void testNotThrowWhenConvertFails() {
    public void testGetOperations() {
    @Test(expected = NullPointerException.class)
    public void testSetNullHeaderValue() {
        final HttpHeadersBase headers = newEmptyHeaders();
        headers.set("test", (String) null);
    @Test(expected = NullPointerException.class)
    public void nullHeaderNameNotAllowed() {
        newEmptyHeaders().add(null, "foo");
    @Test(expected = IllegalArgumentException.class)
    public void emptyHeaderNameNotAllowed() {
        newEmptyHeaders().add("", "foo");
    public void testPseudoHeadersMustComeFirstWhenIterating() {
    public void testPseudoHeadersWithRemovePreservesPseudoIterationOrder() {
    public void testPseudoHeadersWithClearDoesNotLeak() {
    public void testSetOrdersPseudoHeadersCorrectly() {
    public void testHeaderNameNormalization() {
    public void testClearResetsPseudoHeaderDivision() {
    public void testContainsNameAndValue() {
    public void testUri() {
Fixed code: import java.util.BitSet;
import com.google.common.math.IntMath;
    private static final int PROHIBITED_NAME_CHAR_MASK = ~63;
    private static final BitSet PROHIBITED_NAME_CHARS = new BitSet(~PROHIBITED_NAME_CHAR_MASK + 1);
    private static final String[] PROHIBITED_NAME_CHAR_NAMES = new String[~PROHIBITED_NAME_CHAR_MASK + 1];

    static {
        PROHIBITED_NAME_CHARS.set(0);
        PROHIBITED_NAME_CHARS.set('\t');
        PROHIBITED_NAME_CHARS.set('\n');
        PROHIBITED_NAME_CHARS.set(0xB);
        PROHIBITED_NAME_CHARS.set('\f');
        PROHIBITED_NAME_CHARS.set('\r');
        PROHIBITED_NAME_CHARS.set(' ');
        PROHIBITED_NAME_CHARS.set(',');
        PROHIBITED_NAME_CHARS.set(':');
        PROHIBITED_NAME_CHARS.set(';');
        PROHIBITED_NAME_CHARS.set('=');
        PROHIBITED_NAME_CHAR_NAMES[0] = "<NUL>";
        PROHIBITED_NAME_CHAR_NAMES['\t'] = "<TAB>";
        PROHIBITED_NAME_CHAR_NAMES['\n'] = "<LF>";
        PROHIBITED_NAME_CHAR_NAMES[0xB] = "<VT>";
        PROHIBITED_NAME_CHAR_NAMES['\f'] = "<FF>";
        PROHIBITED_NAME_CHAR_NAMES['\r'] = "<CR>";
        PROHIBITED_NAME_CHAR_NAMES[' '] = "<SP>";
        PROHIBITED_NAME_CHAR_NAMES[','] = ",";
        PROHIBITED_NAME_CHAR_NAMES[':'] = ":";
        PROHIBITED_NAME_CHAR_NAMES[';'] = ";";
        PROHIBITED_NAME_CHAR_NAMES['='] = "=";
    }

    private static AsciiString create(String name) {
        return AsciiString.cached(Ascii.toLowerCase(name));
    }

     *
     * @throws IllegalArgumentException if the specified {@code name} is not a valid header name.
        if (cached != null) {
            return cached;
        }

        return validate(AsciiString.cached(lowerCased));
     *
     * @throws IllegalArgumentException if the specified {@code name} is not a valid header name.
        if (cached != null) {
            return cached;
        }

        return validate(lowerCased);
    private static AsciiString validate(AsciiString name) {
        if (name.isEmpty()) {
            throw new IllegalArgumentException("malformed header name: <EMPTY>");
        }

        final int lastIndex;
        try {
            lastIndex = name.forEachByte(value -> {
                if ((value & PROHIBITED_NAME_CHAR_MASK) != 0) { // value >= 64
                    return true;
                }

                // value < 64
                return !PROHIBITED_NAME_CHARS.get(value);
            });
        } catch (Exception e) {
            throw new Error(e);
        }

        if (lastIndex >= 0) {
            throw new IllegalArgumentException(malformedHeaderNameMessage(name));
        }

        return name;
    }

    private static String malformedHeaderNameMessage(AsciiString name) {
        final StringBuilder buf = new StringBuilder(IntMath.saturatedAdd(name.length(), 64));
        buf.append("malformed header name: ");

        final int nameLength = name.length();
        for (int i = 0; i < nameLength; i++) {
            final char ch = name.charAt(i);
            if (PROHIBITED_NAME_CHARS.get(ch)) {
                buf.append(PROHIBITED_NAME_CHAR_NAMES[ch]);
            } else {
                buf.append(ch);
            }
        }

        return buf.toString();
import java.util.BitSet;
import com.google.common.math.IntMath;
    private static final int PROHIBITED_VALUE_CHAR_MASK = ~15;
    private static final BitSet PROHIBITED_VALUE_CHARS = new BitSet(~PROHIBITED_VALUE_CHAR_MASK + 1);
    private static final String[] PROHIBITED_VALUE_CHAR_NAMES = new String[~PROHIBITED_VALUE_CHAR_MASK + 1];

    static {
        PROHIBITED_VALUE_CHARS.set(0);
        PROHIBITED_VALUE_CHARS.set('\n');
        PROHIBITED_VALUE_CHARS.set(0xB);
        PROHIBITED_VALUE_CHARS.set('\f');
        PROHIBITED_VALUE_CHARS.set('\r');
        PROHIBITED_VALUE_CHAR_NAMES[0] = "<NUL>";
        PROHIBITED_VALUE_CHAR_NAMES['\n'] = "<LF>";
        PROHIBITED_VALUE_CHAR_NAMES[0xB] = "<VT>";
        PROHIBITED_VALUE_CHAR_NAMES['\f'] = "<FF>";
        PROHIBITED_VALUE_CHAR_NAMES['\r'] = "<CR>";
    }

        final AsciiString normalizedName = HttpHeaderNames.of(name);
        final AsciiString normalizedName = HttpHeaderNames.of(name);
        final AsciiString normalizedName = HttpHeaderNames.of(name);
        final AsciiString normalizedName = HttpHeaderNames.of(name);
        final AsciiString normalizedName = HttpHeaderNames.of(name);
        final AsciiString normalizedName = HttpHeaderNames.of(name);
        final AsciiString normalizedName = HttpHeaderNames.of(name);
        final AsciiString normalizedName = HttpHeaderNames.of(name);
        final AsciiString normalizedName = HttpHeaderNames.of(name);
        final AsciiString normalizedName = HttpHeaderNames.of(name);
        validateValue(value);
    private static void validateValue(String value) {
        final int valueLength = value.length();
        for (int i = 0; i < valueLength; i++) {
            final char ch = value.charAt(i);
            if ((ch & PROHIBITED_VALUE_CHAR_MASK) != 0) { // ch >= 16
                continue;
            }

            // ch < 16
            if (PROHIBITED_VALUE_CHARS.get(ch)) {
                throw new IllegalArgumentException(malformedHeaderValueMessage(value));
            }
        }
    }

    private static String malformedHeaderValueMessage(String value) {
        final StringBuilder buf = new StringBuilder(IntMath.saturatedAdd(value.length(), 64));
        buf.append("malformed header value: ");

        final int valueLength = value.length();
        for (int i = 0; i < valueLength; i++) {
            final char ch = value.charAt(i);
            if (PROHIBITED_VALUE_CHARS.get(ch)) {
                buf.append(PROHIBITED_VALUE_CHAR_NAMES[ch]);
            } else {
                buf.append(ch);
            }
        }

        return buf.toString();
    }

            final AsciiString lowerCased = AsciiString.of(valuesIter.next()).toLowerCase();
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import org.junit.jupiter.api.Test;
class HttpHeaderNamesTest {
    void testOfAsciiString() {
    void testOfCharSequence() {

    @Test
    void pseudoHeaderNameValidation() {
        // Known pseudo header names should pass validation.
        assertThat((Object) HttpHeaderNames.of(":method")).isSameAs(HttpHeaderNames.METHOD);
        assertThat((Object) HttpHeaderNames.of(":scheme")).isSameAs(HttpHeaderNames.SCHEME);
        assertThat((Object) HttpHeaderNames.of(":authority")).isSameAs(HttpHeaderNames.AUTHORITY);
        assertThat((Object) HttpHeaderNames.of(":path")).isSameAs(HttpHeaderNames.PATH);
        assertThat((Object) HttpHeaderNames.of(":status")).isSameAs(HttpHeaderNames.STATUS);

        // However, any other headers that start with `:` should fail.
        assertThatThrownBy(() -> HttpHeaderNames.of(":foo"))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("malformed header name: :foo");
    }

    @Test
    void headerNameValidation() {
        assertThatThrownBy(() -> HttpHeaderNames.of(""))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("malformed header name: <EMPTY>");
        assertThatThrownBy(() -> HttpHeaderNames.of("\u0000"))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("malformed header name: <NUL>");
        assertThatThrownBy(() -> HttpHeaderNames.of("\t"))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("malformed header name: <TAB>");
        assertThatThrownBy(() -> HttpHeaderNames.of("\n"))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("malformed header name: <LF>");
        assertThatThrownBy(() -> HttpHeaderNames.of("\u000B"))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("malformed header name: <VT>");
        assertThatThrownBy(() -> HttpHeaderNames.of("\f"))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("malformed header name: <FF>");
        assertThatThrownBy(() -> HttpHeaderNames.of("\r"))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("malformed header name: <CR>");
        assertThatThrownBy(() -> HttpHeaderNames.of(" "))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("malformed header name: <SP>");
        assertThatThrownBy(() -> HttpHeaderNames.of(","))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("malformed header name: ,");
        assertThatThrownBy(() -> HttpHeaderNames.of(":"))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("malformed header name: :");
        assertThatThrownBy(() -> HttpHeaderNames.of(";"))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("malformed header name: ;");
        assertThatThrownBy(() -> HttpHeaderNames.of("="))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("malformed header name: =");
    }
import static org.junit.jupiter.api.Assertions.fail;
import org.junit.jupiter.api.Test;
class HttpHeadersBaseTest {
    void testEqualsInsertionOrderSameHeaderName() {
    void testEqualsInsertionOrderDifferentHeaderNames() {
    void addShouldIncreaseAndRemoveShouldDecreaseTheSize() {
    void afterClearHeadersShouldBeEmpty() {
    void removingANameForASecondTimeShouldReturnFalse() {
    void multipleValuesPerNameShouldBeAllowed() {
    void multipleValuesPerNameIteratorWithOtherNames() {
    void multipleValuesPerNameIterator() {
    void multipleValuesPerNameIteratorEmpty() {
    void testContains() {
    void testCopy() throws Exception {
    void canMixConvertedAndNormalValues() {
    void testGetAndRemove() {
    void whenNameContainsMultipleValuesGetShouldReturnTheFirst() {
    void getWithDefaultValueWorks() {
    void setShouldOverWritePreviousValue() {
    void setAllShouldOverwriteSomeAndLeaveOthersUntouched() {
    void headersWithSameNamesAndValuesShouldBeEquivalent() {
    void emptyHeadersShouldBeEqual() {
    void headersWithSameNamesButDifferentValuesShouldNotBeEquivalent() {
    void subsetOfHeadersShouldNotBeEquivalent() {
    void headersWithDifferentNamesAndValuesShouldNotBeEquivalent() {
    @Test
    void iterateEmptyHeadersShouldThrow() {
        assertThatThrownBy(iterator::next).isInstanceOf(NoSuchElementException.class);
    void iteratorShouldReturnAllNameValuePairs() {
    void iteratorSetShouldFail() {
    void testEntryEquals() {
    void getAllReturnsEmptyListForUnknownName() {
    void setHeadersShouldClearAndOverwrite() {
    void setHeadersShouldOnlyOverwriteHeaders() {
    @Test
    void testAddSelf() {
        assertThatThrownBy(() -> headers.add(headers)).isInstanceOf(IllegalArgumentException.class);
    void testSetSelfIsNoOp() {
    void testToString() {
    void testNotThrowWhenConvertFails() {
    @Test
    void valueValidation() {
        final HttpHeadersBase headers = newEmptyHeaders();
        assertThatThrownBy(() -> headers.add("foo", "\u0000"))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("malformed header value: <NUL>");
        assertThatThrownBy(() -> headers.add("foo", "\n"))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("malformed header value: <LF>");
        assertThatThrownBy(() -> headers.add("foo", "\u000B"))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("malformed header value: <VT>");
        assertThatThrownBy(() -> headers.add("foo", "\f"))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("malformed header value: <FF>");
        assertThatThrownBy(() -> headers.add("foo", "\r"))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("malformed header value: <CR>");
    }

    void testGetOperations() {
    @Test
    void testSetNullHeaderValue() {
        assertThatThrownBy(() -> newEmptyHeaders().set("test", (String) null))
                .isInstanceOf(NullPointerException.class);
    @Test
    void nullHeaderNameNotAllowed() {
        assertThatThrownBy(() -> newEmptyHeaders().add(null, "foo")).isInstanceOf(NullPointerException.class);
    @Test
    void emptyHeaderNameNotAllowed() {
        assertThatThrownBy(() -> newEmptyHeaders().add("", "foo"))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("malformed header name: <EMPTY>");
    void testPseudoHeadersMustComeFirstWhenIterating() {
    void testPseudoHeadersWithRemovePreservesPseudoIterationOrder() {
    void testPseudoHeadersWithClearDoesNotLeak() {
    void testSetOrdersPseudoHeadersCorrectly() {
    void testHeaderNameNormalization() {
    void testClearResetsPseudoHeaderDivision() {
    void testContainsNameAndValue() {
    void testUri() {
/*
 * Copyright 2016 LINE Corporation
 *
 * LINE Corporation licenses this file to you under the Apache License,
 * version 2.0 (the "License"); you may not use this file except in compliance
 * with the License. You may obtain a copy of the License at:
 *
 *   https://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
 * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
 * License for the specific language governing permissions and limitations
 * under the License.
 */
package com.linecorp.armeria.server;

import static com.linecorp.armeria.common.SessionProtocol.H1;
import static com.linecorp.armeria.common.SessionProtocol.H1C;
import static com.linecorp.armeria.common.SessionProtocol.H2;
import static com.linecorp.armeria.common.SessionProtocol.H2C;
import static org.assertj.core.api.Assertions.assertThat;

import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.stream.Stream;

import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.Timeout;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.api.extension.RegisterExtension;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.ArgumentsProvider;
import org.junit.jupiter.params.provider.ArgumentsSource;

import com.linecorp.armeria.client.ClientFactory;
import com.linecorp.armeria.client.WebClient;
import com.linecorp.armeria.common.AggregatedHttpResponse;
import com.linecorp.armeria.common.HttpData;
import com.linecorp.armeria.common.HttpResponse;
import com.linecorp.armeria.common.HttpStatus;
import com.linecorp.armeria.common.ResponseHeaders;
import com.linecorp.armeria.testing.junit.server.ServerExtension;

import io.netty.handler.codec.http.QueryStringDecoder;
import io.netty.handler.ssl.util.InsecureTrustManagerFactory;

@Timeout(10000)
class HttpServerHeaderValidationTest {

    static final ClientFactory clientFactory = ClientFactory.builder().sslContextCustomizer(scb -> {
        scb.trustManager(InsecureTrustManagerFactory.INSTANCE);
    }).build();

    @RegisterExtension
    static final ServerExtension server = new ServerExtension() {
        @Override
        protected void configure(ServerBuilder sb) throws Exception {
            sb.http(0);
            sb.https(0);
            sb.tlsSelfSigned();

            sb.route().get("/headers-custom")
              .build((ctx, req) -> {
                  final String param = new QueryStringDecoder(req.path()).parameters()
                                                                         .get("param").get(0);
                  return HttpResponse.of(
                          ResponseHeaders.of(HttpStatus.OK, "server-header", param),
                          HttpData.ofUtf8("OK"));
              });
        }
    };

    @AfterAll
    static void closeClientFactory() {
        clientFactory.close();
    }

    @ParameterizedTest
    @ArgumentsSource(WebClientProvider.class)
    void malformedHeaderValue(WebClient client) throws Exception {
        final String payloadRaw = "my-header\r\nnot-a-header: should_be_illegal";
        final String payload = URLEncoder.encode(payloadRaw, StandardCharsets.US_ASCII.name());
        final String path = "/headers-custom?param=" + payload;
        final AggregatedHttpResponse res = client.get(path).aggregate().get();
        assertThat(res.status()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
        assertThat(res.headers().get("not-a-header")).isNull();
    }

    private static class WebClientProvider implements ArgumentsProvider {
        @Override
        public Stream<? extends Arguments> provideArguments(ExtensionContext context) {
            return Stream.of(H1C, H1, H2C, H2)
                         .map(protocol -> Arguments.of(WebClient.of(
                                 clientFactory,
                                 protocol.uriText() + "://127.0.0.1:" +
                                 (protocol.isTls() ? server.httpsPort() : server.httpPort()))));
        }
    }
}