haproxy/src/qpack-dec.c
Willy Tarreau a8598a2eb1 BUG/CRITICAL: http: properly reject empty http header field names
The HTTP header parsers surprizingly accepts empty header field names,
and this is a leftover from the original code that was agnostic to this.

When muxes were introduced, for H2 first, the HPACK decompressor needed
to feed headers lists, and since empty header names were strictly
forbidden by the protocol, the lists of headers were purposely designed
to be terminated by an empty header field name (a principle that is
similar to H1's empty line termination). This principle was preserved
and generalized to other protocols migrated to muxes (H1/FCGI/H3 etc)
without anyone ever noticing that the H1 parser was still able to deliver
empty header field names to this list. In addition to this it turns out
that the HPACK decompressor, despite a comment in the code, may
successfully decompress an empty header field name, and this mistake
was propagated to the QPACK decompressor as well.

The impact is that an empty header field name may be used to truncate
the list of headers and thus make some headers disappear. While for
H2/H3 the impact is limited as haproxy sees a request with missing
headers, and headers are not used to delimit messages, in the case of
HTTP/1, the impact is significant because the presence (and sometimes
contents) of certain sensitive headers is detected during the parsing.
Thus, some of these headers may be seen, marked as present, their value
extracted, but never delivered to upper layers and obviously not
forwarded to the other side either. This can have for consequence that
certain important header fields such as Connection, Upgrade, Host,
Content-length, Transfer-Encoding etc are possibly seen as different
between what haproxy uses to parse/forward/route and what is observed
in http-request rules and of course, forwarded. One direct consequence
is that it is possible to exploit this property in HTTP/1 to make
affected versions of haproxy forward more data than is advertised on
the other side, and bypass some access controls or routing rules by
crafting extraneous requests.  Note, however, that responses to such
requests will normally not be passed back to the client, but this can
still cause some harm.

This specific risk can be mostly worked around in configuration using
the following rule that will rely on the bug's impact to precisely
detect the inconsistency between the known body size and the one
expected to be advertised to the server (the rule works from 2.0 to
2.8-dev):

  http-request deny if { fc_http_major 1 } !{ req.body_size 0 } !{ req.hdr(content-length) -m found } !{ req.hdr(transfer-encoding) -m found } !{ method CONNECT }

This will exclusively block such carefully crafted requests delivered
over HTTP/1. HTTP/2 and HTTP/3 do not need content-length, and a body
that arrives without being announced with a content-length will be
forwarded using transfer-encoding, hence will not cause discrepancies.
In HAProxy 2.0 in legacy mode ("no option http-use-htx"), this rule will
simply have no effect but will not cause trouble either.

A clean solution would consist in modifying the loops iterating over
these headers lists to check the header name's pointer instead of its
length (since both are zero at the end of the list), but this requires
to touch tens of places and it's very easy to miss one. Functions such
as htx_add_header(), htx_add_trailer(), htx_add_all_headers() would be
good starting points for such a possible future change.

Instead the current fix focuses on blocking empty headers where they
are first inserted, hence in the H1/HPACK/QPACK decoders. One benefit
of the current solution (for H1) is that it allows "show errors" to
report a precise diagnostic when facing such invalid HTTP/1 requests,
with the exact location of the problem and the originating address:

  $ printf "GET / HTTP/1.1\r\nHost: localhost\r\n:empty header\r\n\r\n" | nc 0 8001
  HTTP/1.1 400 Bad request
  Content-length: 90
  Cache-Control: no-cache
  Connection: close
  Content-Type: text/html

  <html><body><h1>400 Bad request</h1>
  Your browser sent an invalid request.
  </body></html>

  $ socat /var/run/haproxy.stat <<< "show errors"
  Total events captured on [10/Feb/2023:16:29:37.530] : 1

  [10/Feb/2023:16:29:34.155] frontend decrypt (#2): invalid request
    backend <NONE> (#-1), server <NONE> (#-1), event #0, src 127.0.0.1:31092
    buffer starts at 0 (including 0 out), 16334 free,
    len 50, wraps at 16336, error at position 33
    H1 connection flags 0x00000000, H1 stream flags 0x00000810
    H1 msg state MSG_HDR_NAME(17), H1 msg flags 0x00001410
    H1 chunk len 0 bytes, H1 body len 0 bytes :

    00000  GET / HTTP/1.1\r\n
    00016  Host: localhost\r\n
    00033  :empty header\r\n
    00048  \r\n

I want to address sincere and warm thanks for their great work to the
team composed of the following security researchers who found the issue
together and reported it: Bahruz Jabiyev, Anthony Gavazzi, and Engin
Kirda from Northeastern University, Kaan Onarlioglu from Akamai
Technologies, Adi Peleg and Harvey Tuch from Google. And kudos to Amaury
Denoyelle from HAProxy Technologies for spotting that the HPACK and
QPACK decoders would let this pass despite the comment explicitly
saying otherwise.

This fix must be backported as far as 2.0. The QPACK changes can be
dropped before 2.6. In 2.0 there is also the equivalent code for legacy
mode, which doesn't suffer from the list truncation, but it would better
be fixed regardless.

CVE-2023-25725 was assigned to this issue.
2023-02-14 08:48:54 +01:00

563 lines
16 KiB
C

/*
* QPACK decompressor
*
* Copyright 2021 HAProxy Technologies, Frederic Lecaille <flecaille@haproxy.com>
*
* This library is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation, version 2.1
* exclusively.
*
* This library is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public
* License along with this library; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
*/
#include <inttypes.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <import/ist.h>
#include <haproxy/buf.h>
#include <haproxy/chunk.h>
#include <haproxy/h3.h>
#include <haproxy/mux_quic.h>
#include <haproxy/qpack-t.h>
#include <haproxy/qpack-dec.h>
#include <haproxy/qpack-tbl.h>
#include <haproxy/hpack-huff.h>
#include <haproxy/hpack-tbl.h>
#include <haproxy/http-hdr.h>
#include <haproxy/tools.h>
#if defined(DEBUG_QPACK)
#define qpack_debug_printf fprintf
#define qpack_debug_hexdump debug_hexdump
#else
#define qpack_debug_printf(...) do { } while (0)
#define qpack_debug_hexdump(...) do { } while (0)
#endif
/* Encoded field line bitmask */
#define QPACK_EFL_BITMASK 0xf0
#define QPACK_LFL_WPBNM 0x00 // Literal field line with post-base name reference
#define QPACK_IFL_WPBI 0x10 // Indexed field line with post-based index
#define QPACK_LFL_WLN_BIT 0x20 // Literal field line with literal name
#define QPACK_LFL_WNR_BIT 0x40 // Literal field line with name reference
#define QPACK_IFL_BIT 0x80 // Indexed field line
/* reads a varint from <raw>'s lowest <b> bits and <len> bytes max (raw included).
* returns the 64-bit value on success after updating buf and len_in. Forces
* len_in to (uint64_t)-1 on truncated input.
* Note that this function is similar to the one used for HPACK (except that is supports
* up to 62-bits integers).
*/
static uint64_t qpack_get_varint(const unsigned char **buf, uint64_t *len_in, int b)
{
uint64_t ret = 0;
int len = *len_in;
const uint8_t *raw = *buf;
uint8_t shift = 0;
len--;
ret = *raw++ & ((1ULL << b) - 1);
if (ret != (uint64_t)((1ULL << b) - 1))
goto end;
while (len && (*raw & 128)) {
ret += ((uint64_t)*raw++ & 127) << shift;
shift += 7;
len--;
}
/* last 7 bits */
if (!len)
goto too_short;
len--;
ret += ((uint64_t)*raw++ & 127) << shift;
end:
*buf = raw;
*len_in = len;
return ret;
too_short:
*len_in = (uint64_t)-1;
return 0;
}
/* Decode an encoder stream.
*
* Returns 0 on success else non-zero.
*/
int qpack_decode_enc(struct buffer *buf, int fin, void *ctx)
{
struct qcs *qcs = ctx;
size_t len;
unsigned char inst;
/* RFC 9204 4.2. Encoder and Decoder Streams
*
* The sender MUST NOT close either of these streams, and the receiver
* MUST NOT request that the sender close either of these streams.
* Closure of either unidirectional stream type MUST be treated as a
* connection error of type H3_CLOSED_CRITICAL_STREAM.
*/
if (fin) {
qcc_emit_cc_app(qcs->qcc, H3_CLOSED_CRITICAL_STREAM, 1);
return -1;
}
len = b_data(buf);
qpack_debug_hexdump(stderr, "[QPACK-DEC-ENC] ", b_head(buf), 0, len);
if (!len) {
qpack_debug_printf(stderr, "[QPACK-DEC-ENC] empty stream\n");
return 0;
}
inst = (unsigned char)*b_head(buf) & QPACK_ENC_INST_BITMASK;
if (inst == QPACK_ENC_INST_DUP) {
/* Duplicate */
}
else if (inst & QPACK_ENC_INST_IWNR_BIT) {
/* Insert With Name Reference */
}
else if (inst & QPACK_ENC_INST_IWLN_BIT) {
/* Insert with literal name */
}
else if (inst & QPACK_ENC_INST_SDTC_BIT) {
/* Set dynamic table capacity */
}
return 0;
}
/* Decode an decoder stream.
*
* Returns 0 on success else non-zero.
*/
int qpack_decode_dec(struct buffer *buf, int fin, void *ctx)
{
struct qcs *qcs = ctx;
size_t len;
unsigned char inst;
/* RFC 9204 4.2. Encoder and Decoder Streams
*
* The sender MUST NOT close either of these streams, and the receiver
* MUST NOT request that the sender close either of these streams.
* Closure of either unidirectional stream type MUST be treated as a
* connection error of type H3_CLOSED_CRITICAL_STREAM.
*/
if (fin) {
qcc_emit_cc_app(qcs->qcc, H3_CLOSED_CRITICAL_STREAM, 1);
return -1;
}
len = b_data(buf);
qpack_debug_hexdump(stderr, "[QPACK-DEC-DEC] ", b_head(buf), 0, len);
if (!len) {
qpack_debug_printf(stderr, "[QPACK-DEC-DEC] empty stream\n");
return 0;
}
inst = (unsigned char)*b_head(buf) & QPACK_DEC_INST_BITMASK;
if (inst == QPACK_DEC_INST_ICINC) {
/* Insert count increment */
}
else if (inst & QPACK_DEC_INST_SACK) {
/* Section Acknowledgment */
}
else if (inst & QPACK_DEC_INST_SCCL) {
/* Stream cancellation */
}
return 0;
}
/* Decode a field section prefix made of <enc_ric> and <db> two varints.
* Also set the 'S' sign bit for <db>.
* Return a negative error if failed, 0 if not.
*/
static int qpack_decode_fs_pfx(uint64_t *enc_ric, uint64_t *db, int *sign_bit,
const unsigned char **raw, uint64_t *len)
{
*enc_ric = qpack_get_varint(raw, len, 8);
if (*len == (uint64_t)-1)
return -QPACK_ERR_RIC;
*sign_bit = **raw & 0x8;
*db = qpack_get_varint(raw, len, 7);
if (*len == (uint64_t)-1)
return -QPACK_ERR_DB;
return 0;
}
/* Decode a field section from the <raw> buffer of <len> bytes. Each parsed
* header is inserted into <list> of <list_size> entries max and uses <tmp> as
* a storage for some elements pointing into it. An end marker is inserted at
* the end of the list with empty strings as name/value.
*
* Returns the number of headers inserted into list excluding the end marker.
* In case of error, a negative code QPACK_ERR_* is returned.
*/
int qpack_decode_fs(const unsigned char *raw, uint64_t len, struct buffer *tmp,
struct http_hdr *list, int list_size)
{
struct ist name, value;
uint64_t enc_ric, db;
int s;
unsigned int efl_type;
int ret;
int hdr_idx = 0;
qpack_debug_hexdump(stderr, "[QPACK-DEC-FS] ", (const char *)raw, 0, len);
/* parse field section prefix */
ret = qpack_decode_fs_pfx(&enc_ric, &db, &s, &raw, &len);
if (ret < 0) {
qpack_debug_printf(stderr, "##ERR@%d(%d)\n", __LINE__, ret);
goto out;
}
chunk_reset(tmp);
qpack_debug_printf(stderr, "enc_ric: %llu db: %llu s=%d\n",
(unsigned long long)enc_ric, (unsigned long long)db, !!s);
/* Decode field lines */
while (len) {
if (hdr_idx >= list_size) {
qpack_debug_printf(stderr, "##ERR@%d\n", __LINE__);
ret = -QPACK_ERR_TOO_LARGE;
goto out;
}
/* parse field line representation */
efl_type = *raw & QPACK_EFL_BITMASK;
qpack_debug_printf(stderr, "efl_type=0x%02x\n", efl_type);
if (efl_type == QPACK_LFL_WPBNM) {
/* Literal field line with post-base name reference
* TODO adjust this when dynamic table support is implemented.
*/
#if 0
uint64_t index __maybe_unused, length;
unsigned int n __maybe_unused, h __maybe_unused;
qpack_debug_printf(stderr, "literal field line with post-base name reference:");
n = *raw & 0x08;
index = qpack_get_varint(&raw, &len, 3);
if (len == (uint64_t)-1) {
qpack_debug_printf(stderr, "##ERR@%d\n", __LINE__);
ret = -QPACK_ERR_TRUNCATED;
goto out;
}
qpack_debug_printf(stderr, " n=%d index=%llu", !!n, (unsigned long long)index);
h = *raw & 0x80;
length = qpack_get_varint(&raw, &len, 7);
if (len == (uint64_t)-1) {
qpack_debug_printf(stderr, "##ERR@%d\n", __LINE__);
ret = -QPACK_ERR_TRUNCATED;
goto out;
}
qpack_debug_printf(stderr, " h=%d length=%llu", !!h, (unsigned long long)length);
if (len < length) {
qpack_debug_printf(stderr, "##ERR@%d\n", __LINE__);
ret = -QPACK_ERR_TRUNCATED;
goto out;
}
raw += length;
len -= length;
#endif
/* RFC9204 2.2.3 Invalid References
*
* If the decoder encounters a reference in a field line representation
* to a dynamic table entry that has already been evicted or that has an
* absolute index greater than or equal to the declared Required Insert
* Count (Section 4.5.1), it MUST treat this as a connection error of
* type QPACK_DECOMPRESSION_FAILED.
*/
return -QPACK_DECOMPRESSION_FAILED;
}
else if (efl_type == QPACK_IFL_WPBI) {
/* Indexed field line with post-base index
* TODO adjust this when dynamic table support is implemented.
*/
#if 0
uint64_t index __maybe_unused;
qpack_debug_printf(stderr, "indexed field line with post-base index:");
index = qpack_get_varint(&raw, &len, 4);
if (len == (uint64_t)-1) {
qpack_debug_printf(stderr, "##ERR@%d\n", __LINE__);
ret = -QPACK_ERR_TRUNCATED;
goto out;
}
qpack_debug_printf(stderr, " index=%llu", (unsigned long long)index);
#endif
/* RFC9204 2.2.3 Invalid References
*
* If the decoder encounters a reference in a field line representation
* to a dynamic table entry that has already been evicted or that has an
* absolute index greater than or equal to the declared Required Insert
* Count (Section 4.5.1), it MUST treat this as a connection error of
* type QPACK_DECOMPRESSION_FAILED.
*/
return -QPACK_DECOMPRESSION_FAILED;
}
else if (efl_type & QPACK_IFL_BIT) {
/* Indexed field line */
uint64_t index;
unsigned int static_tbl;
qpack_debug_printf(stderr, "indexed field line:");
static_tbl = efl_type & 0x40;
index = qpack_get_varint(&raw, &len, 6);
if (len == (uint64_t)-1) {
qpack_debug_printf(stderr, "##ERR@%d\n", __LINE__);
ret = -QPACK_ERR_TRUNCATED;
goto out;
}
if (static_tbl) {
name = qpack_sht[index].n;
value = qpack_sht[index].v;
}
else {
/* RFC9204 2.2.3 Invalid References
*
* If the decoder encounters a reference in a field line representation
* to a dynamic table entry that has already been evicted or that has an
* absolute index greater than or equal to the declared Required Insert
* Count (Section 4.5.1), it MUST treat this as a connection error of
* type QPACK_DECOMPRESSION_FAILED.
*
* TODO adjust this when dynamic table support is implemented.
*/
return -QPACK_DECOMPRESSION_FAILED;
}
qpack_debug_printf(stderr, " t=%d index=%llu", !!static_tbl, (unsigned long long)index);
}
else if (efl_type & QPACK_LFL_WNR_BIT) {
/* Literal field line with name reference */
uint64_t index, length;
unsigned int static_tbl, n __maybe_unused, h;
qpack_debug_printf(stderr, "Literal field line with name reference:");
n = efl_type & 0x20;
static_tbl = efl_type & 0x10;
index = qpack_get_varint(&raw, &len, 4);
if (len == (uint64_t)-1) {
qpack_debug_printf(stderr, "##ERR@%d\n", __LINE__);
ret = -QPACK_ERR_TRUNCATED;
goto out;
}
if (static_tbl) {
name = qpack_sht[index].n;
}
else {
/* RFC9204 2.2.3 Invalid References
*
* If the decoder encounters a reference in a field line representation
* to a dynamic table entry that has already been evicted or that has an
* absolute index greater than or equal to the declared Required Insert
* Count (Section 4.5.1), it MUST treat this as a connection error of
* type QPACK_DECOMPRESSION_FAILED.
*
* TODO adjust this when dynamic table support is implemented.
*/
return -QPACK_DECOMPRESSION_FAILED;
}
qpack_debug_printf(stderr, " n=%d t=%d index=%llu", !!n, !!static_tbl, (unsigned long long)index);
h = *raw & 0x80;
length = qpack_get_varint(&raw, &len, 7);
if (len == (uint64_t)-1) {
qpack_debug_printf(stderr, "##ERR@%d\n", __LINE__);
ret = -QPACK_ERR_TRUNCATED;
goto out;
}
qpack_debug_printf(stderr, " h=%d length=%llu", !!h, (unsigned long long)length);
if (h) {
char *trash;
int nlen;
trash = chunk_newstr(tmp);
if (!trash) {
qpack_debug_printf(stderr, "##ERR@%d\n", __LINE__);
ret = -QPACK_DECOMPRESSION_FAILED;
goto out;
}
nlen = huff_dec(raw, length, trash, tmp->size - tmp->data);
if (nlen == (uint32_t)-1) {
qpack_debug_printf(stderr, " can't decode huffman.\n");
ret = -QPACK_ERR_HUFFMAN;
goto out;
}
qpack_debug_printf(stderr, " [name huff %d->%d '%s']", (int)length, (int)nlen, trash);
/* makes an ist from tmp storage */
b_add(tmp, nlen);
value = ist2(trash, nlen);
}
else {
value = ist2(raw, length);
}
if (len < length) {
qpack_debug_printf(stderr, "##ERR@%d\n", __LINE__);
ret = -QPACK_ERR_TRUNCATED;
goto out;
}
raw += length;
len -= length;
}
else if (efl_type & QPACK_LFL_WLN_BIT) {
/* Literal field line with literal name */
unsigned int n __maybe_unused, hname, hvalue;
uint64_t name_len, value_len;
qpack_debug_printf(stderr, "Literal field line with literal name:");
n = *raw & 0x10;
hname = *raw & 0x08;
name_len = qpack_get_varint(&raw, &len, 3);
if (len == (uint64_t)-1) {
qpack_debug_printf(stderr, "##ERR@%d\n", __LINE__);
ret = -QPACK_ERR_TRUNCATED;
goto out;
}
qpack_debug_printf(stderr, " n=%d hname=%d name_len=%llu", !!n, !!hname, (unsigned long long)name_len);
/* Name string */
if (len < name_len) {
qpack_debug_printf(stderr, "##ERR@%d\n", __LINE__);
ret = -QPACK_ERR_TRUNCATED;
goto out;
}
if (hname) {
char *trash;
int nlen;
trash = chunk_newstr(tmp);
if (!trash) {
qpack_debug_printf(stderr, "##ERR@%d\n", __LINE__);
ret = -QPACK_DECOMPRESSION_FAILED;
goto out;
}
nlen = huff_dec(raw, name_len, trash, tmp->size - tmp->data);
if (nlen == (uint32_t)-1) {
qpack_debug_printf(stderr, " can't decode huffman.\n");
ret = -QPACK_ERR_HUFFMAN;
goto out;
}
qpack_debug_printf(stderr, " [name huff %d->%d '%s']", (int)name_len, (int)nlen, trash);
/* makes an ist from tmp storage */
b_add(tmp, nlen);
name = ist2(trash, nlen);
}
else {
name = ist2(raw, name_len);
}
raw += name_len;
len -= name_len;
hvalue = *raw & 0x80;
value_len = qpack_get_varint(&raw, &len, 7);
if (len == (uint64_t)-1) {
qpack_debug_printf(stderr, "##ERR@%d\n", __LINE__);
ret = -QPACK_ERR_TRUNCATED;
goto out;
}
qpack_debug_printf(stderr, " hvalue=%d value_len=%llu", !!hvalue, (unsigned long long)value_len);
if (len < value_len) {
qpack_debug_printf(stderr, "##ERR@%d\n", __LINE__);
ret = -QPACK_ERR_TRUNCATED;
goto out;
}
if (hvalue) {
char *trash;
int nlen;
trash = chunk_newstr(tmp);
if (!trash) {
qpack_debug_printf(stderr, "##ERR@%d\n", __LINE__);
ret = -QPACK_DECOMPRESSION_FAILED;
goto out;
}
nlen = huff_dec(raw, value_len, trash, tmp->size - tmp->data);
if (nlen == (uint32_t)-1) {
qpack_debug_printf(stderr, " can't decode huffman.\n");
ret = -QPACK_ERR_HUFFMAN;
goto out;
}
qpack_debug_printf(stderr, " [name huff %d->%d '%s']", (int)value_len, (int)nlen, trash);
/* makes an ist from tmp storage */
b_add(tmp, nlen);
value = ist2(trash, nlen);
}
else {
value = ist2(raw, value_len);
}
raw += value_len;
len -= value_len;
}
/* We must not accept empty header names (forbidden by the spec and used
* as a list termination).
*/
if (!name.len) {
qpack_debug_printf(stderr, "##ERR@%d\n", __LINE__);
ret = -QPACK_DECOMPRESSION_FAILED;
goto out;
}
list[hdr_idx].n = name;
list[hdr_idx].v = value;
++hdr_idx;
qpack_debug_printf(stderr, "\n");
}
if (hdr_idx >= list_size) {
qpack_debug_printf(stderr, "##ERR@%d\n", __LINE__);
ret = -QPACK_ERR_TOO_LARGE;
goto out;
}
/* put an end marker */
list[hdr_idx].n = list[hdr_idx].v = IST_NULL;
ret = hdr_idx;
out:
qpack_debug_printf(stderr, "-- done: ret=%d\n", ret);
return ret;
}