Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ add_library(internal
${CMAKE_CURRENT_SOURCE_DIR}/url.cpp
${CMAKE_CURRENT_SOURCE_DIR}/headers.cpp
${CMAKE_CURRENT_SOURCE_DIR}/http/request_serializer.cpp
${CMAKE_CURRENT_SOURCE_DIR}/http/chunked_decoder.cpp
${CMAKE_CURRENT_SOURCE_DIR}/http/response_parser.cpp
${CMAKE_CURRENT_SOURCE_DIR}/platform/native_socket.cpp
${CMAKE_CURRENT_SOURCE_DIR}/platform/network_runtime.cpp
Expand Down
243 changes: 243 additions & 0 deletions src/http/chunked_decoder.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
#include "http/chunked_decoder.hpp"

#include <algorithm>
#include <charconv>
#include <cstddef>
#include <string>
#include <string_view>
#include <system_error>

#include <cpp_request/error.hpp>

namespace cpp_request::detail::http {
namespace {

[[nodiscard]] constexpr char ascii_lower(char ch) noexcept {
return ch >= 'A' && ch <= 'Z'
? static_cast<char>(ch + ('a' - 'A'))
: ch;
}

[[nodiscard]] bool ascii_iequals(
std::string_view lhs,
std::string_view rhs) noexcept {
if (lhs.size() != rhs.size()) {
return false;
}

for (std::size_t index = 0; index < lhs.size(); ++index) {
if (ascii_lower(lhs[index]) != ascii_lower(rhs[index])) {
return false;
}
}
return true;
}

[[nodiscard]] constexpr bool is_tchar(unsigned char ch) noexcept {
if ((ch >= '0' && ch <= '9')
|| (ch >= 'A' && ch <= 'Z')
|| (ch >= 'a' && ch <= 'z')) {
return true;
}

switch (ch) {
case '!':
case '#':
case '$':
case '%':
case '&':
case '\'':
case '*':
case '+':
case '-':
case '.':
case '^':
case '_':
case '`':
case '|':
case '~':
return true;
default:
return false;
}
}

[[nodiscard]] bool valid_field_name(std::string_view name) noexcept {
if (name.empty()) {
return false;
}

for (const unsigned char ch : name) {
if (!is_tchar(ch)) {
return false;
}
}
return true;
}

[[nodiscard]] bool valid_field_value(std::string_view value) noexcept {
for (const unsigned char ch : value) {
if (ch == '\t') {
continue;
}
if (ch < 0x20 || ch == 0x7f) {
return false;
}
}
return true;
}

[[nodiscard]] bool valid_chunk_extension(std::string_view extension) noexcept {
for (const unsigned char ch : extension) {
if (ch == '\t') {
continue;
}
if (ch < 0x20 || ch == 0x7f) {
Comment on lines +91 to +95

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject syntactically invalid chunk extensions

When a malformed peer sends an extension such as 4;\r\n or 4;foo="unterminated\r\n, this validator accepts it because it only rejects control bytes, and the response completes successfully. Chunk extensions require a nonempty token name and, when present, a token or valid quoted-string value, so the decoder needs to validate that grammar to satisfy its malformed-framing error contract.

Useful? React with 👍 / 👎.

return false;
}
}
return true;
}

[[nodiscard]] Result<std::size_t> parse_chunk_size(std::string_view line) noexcept {
const std::size_t semicolon = line.find(';');
const std::string_view size_text = line.substr(0, semicolon);
Comment on lines +103 to +104

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Strip BWS before parsing an extended chunk size

When a server sends a chunk line such as 4 ;foo=bar\r\n, the HTTP chunk-extension grammar permits the whitespace before ; as BWS and requires recipients to tolerate it, but size_text remains "4 ", so from_chars rejects an otherwise parseable response as InvalidChunkSize. Remove the permitted trailing BWS from the size portion before parsing it.

Useful? React with 👍 / 👎.

if (size_text.empty()) {
return Error{ErrorCode::InvalidChunkSize};
}

if (semicolon != std::string_view::npos
&& !valid_chunk_extension(line.substr(semicolon + 1))) {
return Error{ErrorCode::InvalidChunkFraming};
}

std::size_t value = 0;
const char* const first = size_text.data();
const char* const last = size_text.data() + size_text.size();
const auto parsed = std::from_chars(first, last, value, 16);
if (parsed.ec != std::errc{} || parsed.ptr != last) {
return Error{ErrorCode::InvalidChunkSize};
}
return value;
}

[[nodiscard]] bool valid_trailer_line(std::string_view line) noexcept {
if (line.empty() || line.front() == ' ' || line.front() == '\t') {
return false;
}

const std::size_t colon = line.find(':');
if (colon == std::string_view::npos || colon == 0) {
return false;
}

const std::string_view name = line.substr(0, colon);
const std::string_view value = line.substr(colon + 1);
if (!valid_field_name(name) || !valid_field_value(value)) {
return false;
}

return !ascii_iequals(name, "Content-Length")
&& !ascii_iequals(name, "Transfer-Encoding");
}

void consume_prefix(std::string& input, std::size_t count) {
if (count != 0) {
input.erase(0, count);
}
}

} // namespace

Result<ChunkDecodeProgress> ChunkedDecoder::process(
std::string& input,
std::string& output) {
std::size_t cursor = 0;

while (true) {
switch (stage_) {
case Stage::SizeLine: {
const std::size_t line_end = input.find("\r\n", cursor);
if (line_end == std::string::npos) {
consume_prefix(input, cursor);
return ChunkDecodeProgress::NeedMore;
}

const std::string_view line{
input.data() + cursor,
line_end - cursor};
auto size = parse_chunk_size(line);
if (!size) {
return size.error();
}

cursor = line_end + 2;
chunk_remaining_ = size.value();
stage_ = chunk_remaining_ == 0
? Stage::Trailers
: Stage::Data;
continue;
}

case Stage::Data: {
const std::size_t available = input.size() - cursor;
const std::size_t amount = std::min(chunk_remaining_, available);
if (amount != 0) {
output.append(input.data() + cursor, amount);
cursor += amount;
chunk_remaining_ -= amount;
}

if (chunk_remaining_ != 0) {
consume_prefix(input, cursor);
return ChunkDecodeProgress::NeedMore;
}

stage_ = Stage::DataCrlf;
continue;
}

case Stage::DataCrlf:
if (input.size() - cursor < 2) {
consume_prefix(input, cursor);
return ChunkDecodeProgress::NeedMore;
}
if (input[cursor] != '\r' || input[cursor + 1] != '\n') {
return Error{ErrorCode::InvalidChunkFraming};
}
cursor += 2;
stage_ = Stage::SizeLine;
continue;

case Stage::Trailers: {
const std::size_t line_end = input.find("\r\n", cursor);
if (line_end == std::string::npos) {
consume_prefix(input, cursor);
return ChunkDecodeProgress::NeedMore;
}

const std::string_view line{
input.data() + cursor,
line_end - cursor};
cursor = line_end + 2;

if (line.empty()) {
stage_ = Stage::Complete;
consume_prefix(input, cursor);
return ChunkDecodeProgress::Complete;
}

if (!valid_trailer_line(line)) {
return Error{ErrorCode::InvalidHeader};
}
continue;
}

case Stage::Complete:
consume_prefix(input, cursor);
return ChunkDecodeProgress::Complete;
}
}
}

} // namespace cpp_request::detail::http
34 changes: 34 additions & 0 deletions src/http/chunked_decoder.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
#pragma once

#include <cstddef>
#include <string>

#include <cpp_request/result.hpp>

namespace cpp_request::detail::http {

enum class ChunkDecodeProgress {
NeedMore,
Complete
};

class ChunkedDecoder final {
public:
[[nodiscard]] Result<ChunkDecodeProgress> process(
std::string& input,
std::string& output);

private:
enum class Stage {
SizeLine,
Data,
DataCrlf,
Trailers,
Complete
};

Stage stage_{Stage::SizeLine};
std::size_t chunk_remaining_{0};
};

} // namespace cpp_request::detail::http
22 changes: 15 additions & 7 deletions src/http/response_parser.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -267,8 +267,7 @@ Result<ResponseParseProgress> ResponseParser::process_buffer() {
return parsed.error();
}

const std::size_t consumed = head_end + 4;
buffer_.erase(0, consumed);
buffer_.erase(0, head_end + 4);

ParsedHead head = std::move(parsed).value();
if (is_interim_status(head.status_code)) {
Expand Down Expand Up @@ -305,7 +304,7 @@ Result<ResponseParseProgress> ResponseParser::process_buffer() {
std::size_t token_begin = 0;
while (token_begin <= field.value.size()) {
const std::size_t comma = field.value.find(',', token_begin);
const std::size_t token_end = comma == std::string::npos
const std::size_t token_end = comma == std::string_view::npos
? field.value.size()
: comma;
const std::string_view token = trim_ows(
Expand All @@ -319,7 +318,7 @@ Result<ResponseParseProgress> ResponseParser::process_buffer() {
if (!ascii_iequals(token, "chunked")) {
transfer_encoding_is_chunked = false;
}
if (comma == std::string::npos) {
if (comma == std::string_view::npos) {
break;
}
token_begin = comma + 1;
Expand Down Expand Up @@ -347,7 +346,7 @@ Result<ResponseParseProgress> ResponseParser::process_buffer() {
return Error{ErrorCode::MalformedResponse};
}
stage_ = Stage::ChunkedBody;
return ResponseParseProgress::NeedsChunkedDecoder;
continue;
}

if (content_length_count == 1) {
Expand Down Expand Up @@ -390,8 +389,17 @@ Result<ResponseParseProgress> ResponseParser::process_buffer() {
}
return ResponseParseProgress::NeedMore;

case Stage::ChunkedBody:
return ResponseParseProgress::NeedsChunkedDecoder;
case Stage::ChunkedBody: {
auto decoded = chunked_decoder_.process(buffer_, response_.body_);
if (!decoded) {
return decoded.error();
}
if (decoded.value() == ChunkDecodeProgress::NeedMore) {
return ResponseParseProgress::NeedMore;
}
stage_ = Stage::Complete;
return ResponseParseProgress::Complete;
}

case Stage::Complete:
return ResponseParseProgress::Complete;
Expand Down
6 changes: 4 additions & 2 deletions src/http/response_parser.hpp
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
#pragma once

#include "http/chunked_decoder.hpp"

#include <cstddef>
#include <string>
#include <string_view>
Expand All @@ -12,8 +14,7 @@ namespace cpp_request::detail::http {

enum class ResponseParseProgress {
NeedMore,
Complete,
NeedsChunkedDecoder
Complete
};

class ResponseParser final {
Expand Down Expand Up @@ -45,6 +46,7 @@ class ResponseParser final {
Stage stage_{Stage::Head};
Response response_;
std::string buffer_;
ChunkedDecoder chunked_decoder_;
std::size_t content_length_remaining_{0};
bool connection_close_requested_{false};
bool close_delimited_{false};
Expand Down
1 change: 1 addition & 0 deletions tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ add_executable(cpp_request_tests
${CMAKE_CURRENT_SOURCE_DIR}/headers_test.cpp
${CMAKE_CURRENT_SOURCE_DIR}/request_test.cpp
${CMAKE_CURRENT_SOURCE_DIR}/request_serializer_test.cpp
${CMAKE_CURRENT_SOURCE_DIR}/chunked_decoder_test.cpp
${CMAKE_CURRENT_SOURCE_DIR}/response_parser_test.cpp
${CMAKE_CURRENT_SOURCE_DIR}/response_upgrade_test.cpp
${CMAKE_CURRENT_SOURCE_DIR}/native_socket_test.cpp
Expand Down
Loading
Loading