CVE-2019-16771 (Java, published 2019-12-06T19:15Z)
Description: 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.
Severity: MEDIUM (CVSS v3 base score: 6.5)
Weakness: CWE-74 \u2014 Improper Neutralization of Special Elements in Output Used by a Downstream Component ('Injection')
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.
Repository: https://github.com/line/armeria
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 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.

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()))));
        }
    }
}