Skip to content

Commit f5ed688

Browse files
reubent-googcopybara-github
authored andcommitted
Add HttpValidationPolicy controlling semicolon delimitation of chunk-exts
Balsa currently accepts requests where `chunk-size` contains illegal whitespace (SP or HTAB). For example, Balsa parses an `1 A` chunk-size as `1` and the chunk extension as ` A`. This is slightly incorrect. There are two primary cases to consider: 1. `chunk-size` followed by `chunk-ext` + `CRLF` framing 2. `chunk-size` followed only by `CRLF` framing For `chunk-size` without a `chunk-ext`, any trailing `SP/HTAB` is always illegal. For `chunk-size` with a `chunk-ext`, there actually is a case where it’s valid to have whitespace after `chunk-size`. If that `chunk-ext` begins with `BWS`, it is perfectly legal to have `SP` or `HTAB` following the `chunk-size`. However, that is legal if and only if, the `BWS` is part of a `chunk-ext` and not superfluous before a `CRLF`. Unfortunately, Balsa does not have any enforcement that `chunk-ext` contain a `;` which makes it difficult to differentiate between the `SP` / `HTAB` being `BWS` in a chunk-ext or erroneous trailing whitespace so we add enforcement that the `chunk-ext` contains a `;` and, if it is present, it is only preceded by `SP` or `HTAB`. Protected by unused http validation policy. PiperOrigin-RevId: 874812254
1 parent f17e473 commit f5ed688

File tree

4 files changed

+234
-6
lines changed

4 files changed

+234
-6
lines changed

quiche/balsa/balsa_frame.cc

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1237,10 +1237,10 @@ size_t BalsaFrame::ProcessInput(const char* input, size_t size) {
12371237
continue;
12381238

12391239
case BalsaFrameEnums::READING_CHUNK_EXTENSION: {
1240-
// TODO(phython): Convert this scanning to be 16 bytes at a time if
1241-
// there is data to be read.
12421240
const char* extensions_start = current;
12431241
size_t extensions_length = 0;
1242+
bool found_semicolon = false;
1243+
bool found_non_bws_before_semicolon = false;
12441244
QUICHE_DCHECK_LE(current, end);
12451245
while (true) {
12461246
if (current == end) {
@@ -1251,6 +1251,12 @@ size_t BalsaFrame::ProcessInput(const char* input, size_t size) {
12511251
return current - input;
12521252
}
12531253
const char c = *current;
1254+
if (c == ';') {
1255+
found_semicolon = true;
1256+
}
1257+
if (!found_semicolon && (c != ' ' && c != '\t')) {
1258+
found_non_bws_before_semicolon = true;
1259+
}
12541260
if (!IsValidChunkExtensionCharacter(c, current, input, end)) {
12551261
HandleError(BalsaFrameEnums::INVALID_CHUNK_EXTENSION);
12561262
return current - input;
@@ -1270,6 +1276,14 @@ size_t BalsaFrame::ProcessInput(const char* input, size_t size) {
12701276
}
12711277
}
12721278

1279+
if (http_validation_policy_
1280+
.require_semicolon_delimited_chunk_extension &&
1281+
extensions_length > 0 &&
1282+
(!found_semicolon || found_non_bws_before_semicolon)) {
1283+
HandleError(BalsaFrameEnums::INVALID_CHUNK_EXTENSION);
1284+
return current - input;
1285+
}
1286+
12731287
chunk_length_character_extracted_ = false;
12741288
visitor_->OnChunkExtensionInput(
12751289
absl::string_view(extensions_start, extensions_length));

quiche/balsa/balsa_frame_test.cc

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
#include "absl/strings/str_format.h"
2525
#include "absl/strings/str_replace.h"
2626
#include "absl/strings/string_view.h"
27+
#include "absl/types/span.h"
2728
#include "quiche/balsa/balsa_enums.h"
2829
#include "quiche/balsa/balsa_headers.h"
2930
#include "quiche/balsa/balsa_visitor_interface.h"
@@ -47,6 +48,7 @@ using ::testing::NiceMock;
4748
using ::testing::Pointee;
4849
using ::testing::Property;
4950
using ::testing::Range;
51+
using ::testing::Sequence;
5052
using ::testing::StrEq;
5153
using ::testing::StrictMock;
5254

@@ -5101,6 +5103,207 @@ TEST_F(HTTPBalsaFrameTest, ObsTextInReasonPhraseAllowed) {
51015103
EXPECT_EQ(BalsaFrameEnums::BALSA_NO_ERROR, balsa_frame_.ErrorCode());
51025104
}
51035105

5106+
TEST_F(HTTPBalsaFrameTest, MoreChunkExtensions) {
5107+
struct TestCase {
5108+
absl::string_view chunks; // chunks to process.
5109+
absl::Span<const size_t> expected_chunk_sizes;
5110+
absl::Span<const absl::string_view> expected_chunk_extensions;
5111+
HttpValidationPolicy policy;
5112+
BalsaFrameEnums::ErrorCode expected_error;
5113+
};
5114+
5115+
HttpValidationPolicy strict_chunks_policy;
5116+
strict_chunks_policy.disallow_stray_data_after_chunk = true;
5117+
strict_chunks_policy.disallow_lone_lf_in_chunk_extension = true;
5118+
strict_chunks_policy.disallow_lone_cr_in_chunk_extension = true;
5119+
strict_chunks_policy.require_chunked_body_end_with_crlf_crlf = true;
5120+
5121+
HttpValidationPolicy strict_chunk_and_ext_validation;
5122+
strict_chunk_and_ext_validation.disallow_stray_data_after_chunk = true;
5123+
strict_chunk_and_ext_validation.disallow_lone_lf_in_chunk_extension = true;
5124+
strict_chunk_and_ext_validation.disallow_lone_cr_in_chunk_extension = true;
5125+
strict_chunk_and_ext_validation.require_chunked_body_end_with_crlf_crlf =
5126+
true;
5127+
strict_chunk_and_ext_validation.require_semicolon_delimited_chunk_extension =
5128+
true;
5129+
5130+
std::vector<TestCase> cases = {
5131+
// no body, just the last-chunk
5132+
{"0\r\n"
5133+
"\r\n",
5134+
{0},
5135+
{""},
5136+
strict_chunks_policy,
5137+
BalsaFrameEnums::BALSA_NO_ERROR},
5138+
5139+
// no body, just last chunk with valid extension
5140+
{"0;chunked_extension=\"foobar\"quote\"\"\r\n"
5141+
"\r\n",
5142+
{0},
5143+
{";chunked_extension=\"foobar\"quote\"\""},
5144+
strict_chunks_policy,
5145+
BalsaFrameEnums::BALSA_NO_ERROR},
5146+
5147+
// Two valid chunks, last chunk has non-delimited extension
5148+
{"1;ext1\r\n"
5149+
"A\r\n"
5150+
"1;ext2\r\n"
5151+
"B\r\n"
5152+
"0 ext3\r\n" // non-semicolon delimited extension
5153+
"\r\n",
5154+
{1, 1, 0},
5155+
{";ext1", ";ext2"}, // last-chunks's extension is rejected
5156+
strict_chunk_and_ext_validation,
5157+
BalsaFrameEnums::INVALID_CHUNK_EXTENSION},
5158+
5159+
// Two valid chunks, last chunk has non-delimited extension
5160+
{"1;ext1\r\n"
5161+
"A\r\n"
5162+
"1;ext2\r\n"
5163+
"B\r\n"
5164+
"0 ext3\r\n" // non-semicolon delimited extension
5165+
"\r\n",
5166+
{1, 1, 0},
5167+
{";ext1", ";ext2", " ext3"},
5168+
strict_chunks_policy,
5169+
BalsaFrameEnums::BALSA_NO_ERROR},
5170+
5171+
// Trailing WS before semicolon
5172+
{"1 \t;ext\r\n" // BWS before semicolon is valid
5173+
"Q\r\n"
5174+
"0\r\n"
5175+
"\r\n",
5176+
{1, 0},
5177+
{" \t;ext", ""},
5178+
strict_chunks_policy,
5179+
BalsaFrameEnums::BALSA_NO_ERROR},
5180+
5181+
// Non BWS before semicolon
5182+
{"1 \tnon-bws;ext\r\n"
5183+
"Q\r\n"
5184+
"0\r\n"
5185+
"\r\n",
5186+
{1, 0},
5187+
{" \tnon-bws;ext", ""},
5188+
strict_chunks_policy,
5189+
BalsaFrameEnums::BALSA_NO_ERROR},
5190+
5191+
// Non BWS before semicolon
5192+
{"1 \tnon-bws;ext\r\n"
5193+
"Q\r\n"
5194+
"0\r\n"
5195+
"\r\n",
5196+
{1},
5197+
{},
5198+
strict_chunk_and_ext_validation,
5199+
BalsaFrameEnums::INVALID_CHUNK_EXTENSION},
5200+
5201+
// BWS with no extension
5202+
{"1 \t \r\n"
5203+
"Q\r\n"
5204+
"0\r\n"
5205+
"\r\n",
5206+
{1},
5207+
{},
5208+
strict_chunk_and_ext_validation,
5209+
BalsaFrameEnums::INVALID_CHUNK_EXTENSION},
5210+
5211+
// BWS with no extension is invalid
5212+
{"1 \t \r\n"
5213+
"Q\r\n"
5214+
"0\r\n"
5215+
"\r\n",
5216+
{1, 0},
5217+
{" \t ", ""},
5218+
strict_chunks_policy,
5219+
BalsaFrameEnums::BALSA_NO_ERROR},
5220+
5221+
// Trailing BWS after semicolon
5222+
{"1; \t ext\r\n"
5223+
"Q\r\n"
5224+
"0\r\n"
5225+
"\r\n",
5226+
{1, 0},
5227+
{"; \t ext", ""},
5228+
strict_chunks_policy,
5229+
BalsaFrameEnums::BALSA_NO_ERROR},
5230+
5231+
// Trailing illegal characters before semicolon
5232+
{"1ERROR;ext\r\n" // not valid hex
5233+
"Q\r\n"
5234+
"0\r\n"
5235+
"\r\n",
5236+
{},
5237+
{},
5238+
strict_chunks_policy,
5239+
BalsaFrameEnums::INVALID_CHUNK_LENGTH},
5240+
5241+
// Extension with semicolon & CRLF
5242+
{"1;ext=\";foo\"\r\n"
5243+
"Q\r\n"
5244+
"0\r\n"
5245+
"\r\n",
5246+
{1, 0},
5247+
{";ext=\";foo\"", ""},
5248+
strict_chunks_policy,
5249+
BalsaFrameEnums::BALSA_NO_ERROR},
5250+
5251+
// Bare semicolon
5252+
{"A;\r\n"
5253+
"0123456789\r\n"
5254+
"0\r\n"
5255+
"\r\n",
5256+
{10, 0},
5257+
{";", ""},
5258+
strict_chunks_policy,
5259+
BalsaFrameEnums::BALSA_NO_ERROR},
5260+
};
5261+
5262+
for (const TestCase& test : cases) {
5263+
SCOPED_TRACE(absl::StrCat("Testing chunks: ", absl::CEscape(test.chunks)));
5264+
NiceMock<BalsaVisitorMock> visitor_mock;
5265+
BalsaFrame balsa_frame;
5266+
BalsaHeaders headers;
5267+
balsa_frame.set_is_request(true);
5268+
balsa_frame.set_balsa_headers(&headers);
5269+
balsa_frame.set_http_validation_policy(test.policy);
5270+
balsa_frame.set_balsa_visitor(&visitor_mock);
5271+
5272+
std::string message_headers =
5273+
"POST / HTTP/1.1\r\n"
5274+
"Transfer-Encoding: chunked\r\n"
5275+
"\r\n";
5276+
5277+
ASSERT_EQ(balsa_frame.ProcessInput(message_headers.data(),
5278+
message_headers.size()),
5279+
message_headers.size());
5280+
ASSERT_FALSE(balsa_frame.Error());
5281+
5282+
Sequence size_sequence, extension_sequence;
5283+
for (size_t chunk_size : test.expected_chunk_sizes) {
5284+
EXPECT_CALL(visitor_mock, OnChunkLength(chunk_size))
5285+
.InSequence(size_sequence);
5286+
}
5287+
5288+
for (absl::string_view extension : test.expected_chunk_extensions) {
5289+
EXPECT_CALL(visitor_mock, OnChunkExtensionInput(extension))
5290+
.InSequence(extension_sequence);
5291+
}
5292+
5293+
size_t bytes_consumed =
5294+
balsa_frame.ProcessInput(test.chunks.data(), test.chunks.size());
5295+
EXPECT_EQ(balsa_frame.ErrorCode(), test.expected_error);
5296+
5297+
if (test.expected_error == BalsaFrameEnums::BALSA_NO_ERROR) {
5298+
EXPECT_EQ(bytes_consumed, test.chunks.size());
5299+
EXPECT_TRUE(balsa_frame.MessageFullyRead());
5300+
EXPECT_FALSE(balsa_frame.Error());
5301+
}
5302+
5303+
Mock::VerifyAndClearExpectations(&visitor_mock);
5304+
}
5305+
}
5306+
51045307
TEST_F(HTTPBalsaFrameTest, MostRestrictiveTest) {
51055308
BalsaFrame balsa_frame;
51065309
BalsaHeaders headers;

quiche/balsa/balsa_fuzz_util.cc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ fuzztest::Domain<HttpValidationPolicy> ArbitraryHttpValidationPolicy() {
2323
fuzztest::Arbitrary<bool>(), fuzztest::Arbitrary<bool>(),
2424
fuzztest::Arbitrary<bool>(), ArbitraryFirstLineValidationOption(),
2525
fuzztest::Arbitrary<bool>(), fuzztest::Arbitrary<bool>(),
26-
fuzztest::Arbitrary<bool>());
26+
fuzztest::Arbitrary<bool>(), fuzztest::Arbitrary<bool>());
2727
}
2828

2929
fuzztest::Domain<HttpValidationPolicy::FirstLineValidationOption>

quiche/balsa/http_validation_policy.h

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,14 @@ struct QUICHE_EXPORT HttpValidationPolicy {
116116
// https://datatracker.ietf.org/doc/html/rfc9110#section-5.6.2
117117
bool disallow_invalid_request_methods = false;
118118

119+
// Chunk extensions are optional but, if they are present, they MUST be
120+
// preceded by a semicolon and follow the grammar:
121+
// chunk-ext = *( BWS ";" BWS chunk-ext-name [ BWS "=" BWS chunk-ext-val ] )
122+
// where BWS is SP or HTAB. See
123+
// https://datatracker.ietf.org/doc/html/rfc9112#name-chunk-extensions for
124+
// more information
125+
bool require_semicolon_delimited_chunk_extension = false;
126+
119127
template <typename Sink>
120128
friend void AbslStringify(Sink& sink, FirstLineValidationOption option) {
121129
switch (option) {
@@ -153,7 +161,8 @@ struct QUICHE_EXPORT HttpValidationPolicy {
153161
"sanitize_firstline_spaces=%v, "
154162
"sanitize_obs_fold_in_header_values=%v, "
155163
"disallow_stray_data_after_chunk=%v, "
156-
"disallow_invalid_request_methods=%v}",
164+
"disallow_invalid_request_methods=%v, "
165+
"require_semicolon_delimited_chunk_extension=%v}",
157166
policy.disallow_header_continuation_lines,
158167
policy.require_header_colon,
159168
policy.disallow_multiple_content_length,
@@ -172,7 +181,8 @@ struct QUICHE_EXPORT HttpValidationPolicy {
172181
policy.sanitize_firstline_spaces,
173182
policy.sanitize_obs_fold_in_header_values,
174183
policy.disallow_stray_data_after_chunk,
175-
policy.disallow_invalid_request_methods);
184+
policy.disallow_invalid_request_methods,
185+
policy.require_semicolon_delimited_chunk_extension);
176186
}
177187
};
178188

@@ -197,7 +207,8 @@ static constexpr HttpValidationPolicy kMostStrictHttpValidationPolicy = {
197207
quiche::HttpValidationPolicy::FirstLineValidationOption::SANITIZE,
198208
.sanitize_obs_fold_in_header_values = true,
199209
.disallow_stray_data_after_chunk = true,
200-
.disallow_invalid_request_methods = true};
210+
.disallow_invalid_request_methods = true,
211+
.require_semicolon_delimited_chunk_extension = true};
201212

202213
} // namespace quiche
203214

0 commit comments

Comments
 (0)