blob: a724075620ae2a44f8f15a52c3aa2c794bee4ad1 [file]
/** @file
Catch-based unit tests for ChunkedHandler::read_size() and read_trailer().
These tests drive the real chunk size line and trailer parsers through a small
subclass fixture and a backing IOBuffer, covering hex size parsing, chunk
extensions, quoted-string extension values per RFC 9110 Section 5.6.4,
quoted-pair escapes, the trailer terminating line per RFC 9112 Section 7.1, and
strict versus non-strict line termination.
@section license License
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF 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
http://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.
*/
#include <catch2/catch_test_macros.hpp>
#include <catch2/generators/catch_generators.hpp>
#include <cstring>
#include "proxy/http/HttpTunnel.h"
#include "iocore/eventsystem/IOBuffer.h"
// Subclass fixture that drives the otherwise private read_size() parser and lets
// a test seed the parsing state. ChunkedHandler names this class as a friend, so
// it must live at global scope to match that declaration (not in an anonymous
// namespace).
class TestableChunkedHandler : public ChunkedHandler
{
public:
// Drive the private size parser. Returns the number of input bytes consumed.
int64_t
parse()
{
return read_size();
}
// Drive the private trailer parser. Returns the number of input bytes consumed.
int64_t
parse_trailer()
{
return read_trailer();
}
void
reset(IOBufferReader *reader, bool strict)
{
chunked_reader = reader;
state = ChunkedState::READ_SIZE;
strict_chunk_parsing = strict;
running_sum = 0;
num_digits = 0;
num_cr = 0;
prev_is_cr = false;
in_quoted_string = false;
in_escape = false;
}
// Seed the handler at the start of the trailer section, which is where
// read_size() leaves it after the final zero-size chunk (state
// READ_TRAILER_BLANK). drop_chunked_trailers stays false so read_trailer()
// does not need a chunked_buffer.
void
reset_for_trailer(IOBufferReader *reader, bool strict)
{
chunked_reader = reader;
state = ChunkedState::READ_TRAILER_BLANK;
strict_chunk_parsing = strict;
drop_chunked_trailers = false;
}
};
namespace
{
using State = ChunkedHandler::ChunkedState;
// Result of parsing a chunk size line: the terminal state, the parsed size, the
// number of input bytes read_size() consumed, and how many bytes were left
// unconsumed in the reader. The consumed/remaining counts are what distinguish a
// parser that keeps a quoted-string value intact (consumes the whole line) from
// one that stops at the first embedded CRLF.
struct ParseResult {
State state;
int64_t size;
int64_t bytes_left; // cur_chunk_bytes_left: body bytes the dechunker will read for this chunk.
int64_t consumed;
int64_t remaining;
};
// Feed a complete chunk size line into read_size() in a single buffer.
ParseResult
parse_chunk_size_line(const char *input, bool strict = true)
{
MIOBuffer *buffer = new_MIOBuffer(BUFFER_SIZE_INDEX_4K);
IOBufferReader *reader = buffer->alloc_reader();
buffer->write(input, std::strlen(input));
TestableChunkedHandler handler;
handler.reset(reader, strict);
int64_t consumed = handler.parse();
ParseResult result{handler.state, handler.cur_chunk_size, handler.cur_chunk_bytes_left, consumed, reader->read_avail()};
free_MIOBuffer(buffer);
return result;
}
// Result of parsing a trailer section: the terminal state, the number of input
// bytes read_trailer() consumed, and how many bytes were left unconsumed. A
// parser that ends the trailers at a bare LF consumes only up to that LF and
// leaves any following bytes (a smuggled request) behind; one that requires CRLF
// rejects (READ_ERROR) instead.
struct TrailerResult {
State state;
int64_t consumed;
int64_t remaining;
};
// Feed a trailer section into read_trailer() in a single buffer. The input begins
// where read_size() hands off after the final zero-size chunk, so it does not
// include the leading "0\r\n".
TrailerResult
parse_chunk_trailer(const char *input, bool strict = true)
{
MIOBuffer *buffer = new_MIOBuffer(BUFFER_SIZE_INDEX_4K);
IOBufferReader *reader = buffer->alloc_reader();
buffer->write(input, std::strlen(input));
TestableChunkedHandler handler;
handler.reset_for_trailer(reader, strict);
int64_t consumed = handler.parse_trailer();
TrailerResult result{handler.state, consumed, reader->read_avail()};
free_MIOBuffer(buffer);
return result;
}
// A single row of the size-line matrix: a label, the raw size-line bytes, the
// parsing mode, the expected terminal state, and the expected chunk size
// (negative means do not check the size, used for the malformed rows).
struct SizeCase {
const char *name;
const char *input;
bool strict;
State expect_state;
int64_t expect_size;
};
// A single row of the trailer matrix: a label, the trailer-section bytes (which
// begin after read_size() has consumed the final "0\r\n"), the parsing mode, and
// the expected terminal state.
struct TrailerCase {
const char *name;
const char *input;
bool strict;
State expect_state;
};
} // namespace
TEST_CASE("ChunkedHandler parses chunk sizes", "[chunked]")
{
SECTION("single hex digit")
{
auto r = parse_chunk_size_line("a\r\n");
CHECK(r.state == State::READ_CHUNK);
CHECK(r.size == 10);
}
SECTION("multiple hex digits")
{
auto r = parse_chunk_size_line("ff\r\n");
CHECK(r.state == State::READ_CHUNK);
CHECK(r.size == 255);
}
SECTION("final chunk of size zero")
{
auto r = parse_chunk_size_line("0\r\n");
CHECK(r.state == State::READ_TRAILER_BLANK);
CHECK(r.size == 0);
}
}
TEST_CASE("ChunkedHandler parses chunk extensions", "[chunked]")
{
SECTION("token extension value")
{
auto r = parse_chunk_size_line("a;ext=value\r\n");
CHECK(r.state == State::READ_CHUNK);
CHECK(r.size == 10);
}
SECTION("quoted-string extension value")
{
auto r = parse_chunk_size_line("a;ext=\"hello world\"\r\n");
CHECK(r.state == State::READ_CHUNK);
CHECK(r.size == 10);
}
SECTION("multiple extensions")
{
auto r = parse_chunk_size_line("a;ext1=val1;ext2=\"val2\"\r\n");
CHECK(r.state == State::READ_CHUNK);
CHECK(r.size == 10);
}
SECTION("whitespace before the semicolon (BWS) still parses the extension")
{
auto r = parse_chunk_size_line("a ;ext=value\r\n");
CHECK(r.state == State::READ_CHUNK);
CHECK(r.size == 10);
}
}
// RFC 9110 Section 5.6.4: a quoted-string cannot contain a bare CR or LF (neither
// qdtext nor quoted-pair permits them). A chunk extension whose quoted value
// embeds CR/LF is therefore malformed, and the parser must reject it (READ_ERROR)
// rather than forward a request a downstream parser could frame differently.
TEST_CASE("ChunkedHandler rejects CR or LF inside a quoted extension value", "[chunked]")
{
SECTION("a single embedded CRLF is rejected")
{
CHECK(parse_chunk_size_line("1;a=\"\r\nfoo\"\r\n").state == State::READ_ERROR);
}
SECTION("several embedded CRLFs are rejected")
{
CHECK(parse_chunk_size_line("1;ext=\"line1\r\nline2\r\nline3\"\r\n").state == State::READ_ERROR);
}
SECTION("a bare LF inside the quoted value is rejected in both modes")
{
CHECK(parse_chunk_size_line("1;a=\"x\ny\"\r\n", true).state == State::READ_ERROR);
CHECK(parse_chunk_size_line("1;a=\"x\ny\"\r\n", false).state == State::READ_ERROR);
}
SECTION("an embedded CRLF is rejected even when whitespace (BWS) precedes the semicolon")
{
CHECK(parse_chunk_size_line("1 ;a=\"\r\nfoo\"\r\n").state == State::READ_ERROR);
}
SECTION("a quoted-pair cannot escape a CR")
{
CHECK(parse_chunk_size_line("1;a=\"\\\rx\"\r\n").state == State::READ_ERROR);
}
}
// A quoted-string value with no CR/LF is well formed and parses to a normal chunk.
TEST_CASE("ChunkedHandler accepts a valid quoted extension value", "[chunked]")
{
SECTION("an escaped DQUOTE does not close the quoted string")
{
auto r = parse_chunk_size_line("1;ext=\"value\\\"more\"\r\n");
CHECK(r.state == State::READ_CHUNK);
CHECK(r.size == 1);
CHECK(r.bytes_left == 1); // the dechunker reads exactly the 1 declared body byte
CHECK(r.remaining == 0);
}
SECTION("an escaped backslash is consumed as a quoted-pair")
{
auto r = parse_chunk_size_line("1;ext=\"path\\\\file\"\r\n");
CHECK(r.state == State::READ_CHUNK);
CHECK(r.size == 1);
CHECK(r.remaining == 0);
}
}
// A chunk extension can be split across socket reads. read_size() must suspend
// in READ_EXTENSION when the reader empties mid-extension and resume cleanly on
// the next call. (process_chunked_content() also routes READ_EXTENSION back to
// read_size(); without that, a split extension would crash the dispatcher.)
TEST_CASE("ChunkedHandler resumes a chunk extension split across reads", "[chunked]")
{
MIOBuffer *buffer = new_MIOBuffer(BUFFER_SIZE_INDEX_4K);
IOBufferReader *reader = buffer->alloc_reader();
TestableChunkedHandler handler;
handler.reset(reader, true);
// First read ends in the middle of a quoted-string extension value.
buffer->write("5;ext=\"ab", 9);
handler.parse();
CHECK(handler.state == State::READ_EXTENSION);
CHECK(reader->read_avail() == 0);
// The rest of the line arrives in a later read and completes the chunk.
buffer->write("cd\"\r\nhello\r\n", 12);
handler.parse();
CHECK(handler.state == State::READ_CHUNK);
CHECK(handler.cur_chunk_size == 5);
free_MIOBuffer(buffer);
}
TEST_CASE("ChunkedHandler rejects malformed size lines under strict parsing", "[chunked]")
{
SECTION("non hex, non delimiter character after the size")
{
CHECK(parse_chunk_size_line("ax\r\n", true).state == State::READ_ERROR);
}
SECTION("extension with no preceding size digits")
{
CHECK(parse_chunk_size_line(";\r\n", true).state == State::READ_ERROR);
}
SECTION("a second CR before the LF is a protocol error")
{
CHECK(parse_chunk_size_line("a\r\r\n", true).state == State::READ_ERROR);
}
SECTION("a second CR after an extension is a protocol error")
{
CHECK(parse_chunk_size_line("a;ext=value\r\r\n", true).state == State::READ_ERROR);
}
}
TEST_CASE("ChunkedHandler honors strict versus non-strict line termination", "[chunked]")
{
SECTION("a bare LF after an extension is rejected in strict mode")
{
// A chunk size line ending in a bare LF after an extension, with body bytes
// following, must be rejected rather than accepted with the bytes as data.
auto r = parse_chunk_size_line("7;x\nabcwxyz\r\n", true);
CHECK(r.state == State::READ_ERROR);
}
SECTION("a bare LF after an extension is accepted in non-strict mode")
{
auto lenient = parse_chunk_size_line("a;ext=value\n", false);
CHECK(lenient.state == State::READ_CHUNK);
CHECK(lenient.size == 10);
}
SECTION("a proper CRLF terminator works in both modes")
{
CHECK(parse_chunk_size_line("a\r\n", true).state == State::READ_CHUNK);
CHECK(parse_chunk_size_line("a\r\n", false).state == State::READ_CHUNK);
}
}
// RFC 9112 Section 7.1: the trailer section ends with an empty line, "CRLF". A
// bare LF blank line is not a valid terminator. read_trailer() must enforce CRLF
// under strict parsing, the same as the chunk size line does: a bare LF blank
// line ends the trailers on a lenient parser but a strict peer keeps reading, so
// any bytes after the bare LF can be framed by the two parsers as different
// requests (a request boundary desync). Under strict parsing the bare LF must be
// a protocol error so the ambiguous bytes are never forwarded.
TEST_CASE("ChunkedHandler honors strict versus non-strict trailer termination", "[chunked]")
{
SECTION("a bare LF blank line does not terminate the trailers in strict mode")
{
// Bytes after the bare LF look like a smuggled request. The strict parser
// must reject rather than end the trailers here and forward the trailing GET.
auto r = parse_chunk_trailer("\nGET /smuggled HTTP/1.1\r\n\r\n", true);
CHECK(r.state == State::READ_ERROR);
}
SECTION("a bare LF blank line still terminates the trailers in non-strict mode")
{
auto r = parse_chunk_trailer("\n", false);
CHECK(r.state == State::READ_DONE);
}
SECTION("a CRLF blank line terminates the trailers in both modes")
{
CHECK(parse_chunk_trailer("\r\n", true).state == State::READ_DONE);
CHECK(parse_chunk_trailer("\r\n", false).state == State::READ_DONE);
}
SECTION("a bare LF after a trailer field does not terminate the trailers in strict mode")
{
// The trailer field line ends, returning to a blank line, and the following
// bare LF must not be accepted as the terminator under strict parsing.
auto r = parse_chunk_trailer("X-Trailer: v\r\n\nGET /smuggled HTTP/1.1\r\n\r\n", true);
CHECK(r.state == State::READ_ERROR);
}
SECTION("a trailer field followed by a CRLF blank line terminates in strict mode")
{
auto r = parse_chunk_trailer("X-Trailer: v\r\n\r\n", true);
CHECK(r.state == State::READ_DONE);
}
}
// Broad regression matrix for the chunk size line parser. Each row is one input
// and the terminal state the parser must reach, exercised across strict and
// non-strict modes. New variations are added by appending a row, so the matrix
// grows without new boilerplate.
TEST_CASE("ChunkedHandler size-line parsing matrix", "[chunked]")
{
auto c = GENERATE(values<SizeCase>({
// label input strict expected state size
{"size zero hands off to trailers", "0\r\n", true, State::READ_TRAILER_BLANK, 0 },
{"single hex digit", "1\r\n", true, State::READ_CHUNK, 1 },
{"lowercase hex", "a\r\n", true, State::READ_CHUNK, 10 },
{"uppercase hex", "A\r\n", true, State::READ_CHUNK, 10 },
{"multiple hex digits", "ff\r\n", true, State::READ_CHUNK, 255 },
{"four hex digits", "1000\r\n", true, State::READ_CHUNK, 4096},
{"leading zeros", "00a\r\n", true, State::READ_CHUNK, 10 },
{"token extension", "a;ext=value\r\n", true, State::READ_CHUNK, 10 },
{"extension token, no value", "a;ext\r\n", true, State::READ_CHUNK, 10 },
{"quoted-string extension", "a;ext=\"hello world\"\r\n", true, State::READ_CHUNK, 10 },
{"multiple extensions", "a;e1=v1;e2=\"v2\"\r\n", true, State::READ_CHUNK, 10 },
{"BWS before the semicolon", "a ;ext=value\r\n", true, State::READ_CHUNK, 10 },
{"escaped DQUOTE in quoted value", "1;e=\"a\\\"b\"\r\n", true, State::READ_CHUNK, 1 },
{"escaped backslash in quoted value", "1;e=\"a\\\\b\"\r\n", true, State::READ_CHUNK, 1 },
// Malformed: an embedded CR/LF in a quoted-string extension value.
{"embedded CRLF in quoted value, strict", "1;a=\"\r\nfoo\"\r\n", true, State::READ_ERROR, -1 },
{"embedded CRLF in quoted value, lenient", "1;a=\"\r\nfoo\"\r\n", false, State::READ_ERROR, -1 },
{"embedded bare LF in quoted value, strict", "1;a=\"x\ny\"\r\n", true, State::READ_ERROR, -1 },
{"embedded bare LF in quoted value, lenient", "1;a=\"x\ny\"\r\n", false, State::READ_ERROR, -1 },
{"quoted-pair cannot escape a CR", "1;a=\"\\\rx\"\r\n", true, State::READ_ERROR, -1 },
{"embedded CRLF in quoted value after BWS", "1 ;a=\"\r\nx\"\r\n", true, State::READ_ERROR, -1 },
// Malformed: bad size syntax.
{"non-hex character after the size", "ax\r\n", true, State::READ_ERROR, -1 },
{"non-hex first character", "g\r\n", true, State::READ_ERROR, -1 },
{"extension with no size digits", ";\r\n", true, State::READ_ERROR, -1 },
{"a second CR before the LF", "a\r\r\n", true, State::READ_ERROR, -1 },
{"a second CR after an extension", "a;ext=value\r\r\n", true, State::READ_ERROR, -1 },
{"control character after the size", "a\x01\r\n", true, State::READ_ERROR, -1 },
// Line termination: bare LF gated on strict parsing.
{"bare LF after a plain size, strict", "a\nbody", true, State::READ_ERROR, -1 },
{"bare LF after extension, strict", "7;x\nabcwxyz\r\n", true, State::READ_ERROR, -1 },
{"bare LF after extension, lenient", "a;ext=value\n", false, State::READ_CHUNK, 10 },
{"CRLF terminator, strict", "a\r\n", true, State::READ_CHUNK, 10 },
{"CRLF terminator, lenient", "a\r\n", false, State::READ_CHUNK, 10 },
}));
DYNAMIC_SECTION(c.name << (c.strict ? " [strict]" : " [lenient]"))
{
auto r = parse_chunk_size_line(c.input, c.strict);
CHECK(r.state == c.expect_state);
if (c.expect_size >= 0) {
CHECK(r.size == c.expect_size);
}
}
}
// Broad regression matrix for the trailer parser. The input begins where
// read_size() hands off after the final zero-size chunk, so it does not include
// the leading "0\r\n". The terminating empty line must be a full CRLF under
// strict parsing; a bare LF blank line terminates only in non-strict mode.
TEST_CASE("ChunkedHandler trailer parsing matrix", "[chunked]")
{
auto c = GENERATE(values<TrailerCase>({
// label input strict expected state
{"CRLF terminator, strict", "\r\n", true, State::READ_DONE },
{"CRLF terminator, lenient", "\r\n", false, State::READ_DONE },
{"bare LF terminator, strict", "\n", true, State::READ_ERROR},
{"bare LF terminator, lenient", "\n", false, State::READ_DONE },
{"bare LF then smuggled request, strict", "\nGET /x HTTP/1.1\r\n\r\n", true, State::READ_ERROR},
{"trailer field then CRLF terminator, strict", "X-T: v\r\n\r\n", true, State::READ_DONE },
{"trailer field then CRLF terminator, lenient", "X-T: v\r\n\r\n", false, State::READ_DONE },
{"trailer field then bare LF terminator, strict", "X-T: v\r\n\nGET /x HTTP/1.1\r\n\r\n", true, State::READ_ERROR},
{"two trailer fields then CRLF, strict", "X-T: v\r\nY-T: w\r\n\r\n", true, State::READ_DONE },
{"two trailer fields then CRLF, lenient", "X-T: v\r\nY-T: w\r\n\r\n", false, State::READ_DONE },
// Only the terminating empty line is gated. A bare LF ending a non-blank
// trailer field line is still tolerated under strict parsing (it does not end
// the message, so it creates no request boundary the peers can disagree on).
{"bare LF ends a field line, tolerated, strict", "X-T: v\nY-T: w\r\n\r\n", true, State::READ_DONE },
}));
DYNAMIC_SECTION(c.name << (c.strict ? " [strict]" : " [lenient]"))
{
auto r = parse_chunk_trailer(c.input, c.strict);
CHECK(r.state == c.expect_state);
}
}
// A trailer can be split across socket reads. read_trailer() must suspend when
// the reader empties mid-trailer and resume cleanly, still rejecting a bare-LF
// terminator that only arrives in a later read under strict parsing.
TEST_CASE("ChunkedHandler resumes a trailer split across reads", "[chunked]")
{
MIOBuffer *buffer = new_MIOBuffer(BUFFER_SIZE_INDEX_4K);
IOBufferReader *reader = buffer->alloc_reader();
TestableChunkedHandler handler;
handler.reset_for_trailer(reader, true);
// First read ends after a complete trailer field line, parking at a blank line.
buffer->write("X-Trailer: v\r\n", 14);
handler.parse_trailer();
CHECK(handler.state == State::READ_TRAILER_BLANK);
CHECK(reader->read_avail() == 0);
// The bare-LF terminator arrives in a later read and must still be rejected.
buffer->write("\n", 1);
handler.parse_trailer();
CHECK(handler.state == State::READ_ERROR);
free_MIOBuffer(buffer);
}