| /** @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); | |
| } |