Skip to content

Commit 965d805

Browse files
authored
fix(http): reject incomplete chunked transfers (#9)
* fix(http): reject incomplete chunked transfers * chore(deps): refresh mcpp lockfile
1 parent 626c9d0 commit 965d805

5 files changed

Lines changed: 130 additions & 10 deletions

File tree

.xlings.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
22
"workspace": {
3-
"mcpp": { "linux": "0.0.13" }
3+
"mcpp": "0.0.87"
44
}
55
}

mcpp.lock

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
# Auto-generated by mcpp. Do not edit by hand.
2-
version = 1
2+
version = 2
33

4-
[package."mbedtls"]
4+
[package."compat.mbedtls"]
5+
namespace = "compat"
56
version = "3.6.1"
6-
source = "mcpp-index+https://github.com/mcpp-community/mcpp-index.git"
7-
hash = "sha256:<from-xlings>"
7+
source = "index+compat@3.6.1"
8+
hash = "fnv1a:83dea67ac1379be3"
89

mcpp.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
[package]
22
namespace = "mcpplibs"
33
name = "tinyhttps"
4-
version = "0.2.8"
4+
version = "0.2.9"
55
description = "Minimal C++23 HTTP/HTTPS client with SSE streaming support"
66
license = "Apache-2.0"
77
repo = "https://github.com/mcpplibs/tinyhttps"

src/http.cppm

Lines changed: 93 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@ export struct DownloadToFileResult {
4949
int statusCode { 0 };
5050
std::string error;
5151
std::int64_t bytesWritten { 0 };
52+
std::optional<std::int64_t> expectedBytes;
53+
std::string finalUrl;
54+
std::string etag;
55+
std::string lastModified;
5256
bool ok() const { return statusCode >= 200 && statusCode < 300 && error.empty(); }
5357
};
5458

@@ -186,6 +190,28 @@ static std::string read_line(TlsSocket& sock, int timeoutMs) {
186190
return line;
187191
}
188192

193+
static std::expected<std::string, std::string>
194+
read_complete_line(TlsSocket& sock, int timeoutMs) {
195+
std::string line;
196+
char c {};
197+
while (true) {
198+
if (!sock.wait_readable(timeoutMs)) {
199+
return std::unexpected("timeout or EOF before CRLF");
200+
}
201+
int ret = sock.read(&c, 1);
202+
if (ret <= 0) {
203+
return std::unexpected("EOF before CRLF");
204+
}
205+
line += c;
206+
if (line.size() >= 2
207+
&& line[line.size() - 2] == '\r'
208+
&& line[line.size() - 1] == '\n') {
209+
line.resize(line.size() - 2);
210+
return line;
211+
}
212+
}
213+
}
214+
189215
// Write all data to socket
190216
static bool write_all(TlsSocket& sock, const std::string& data) {
191217
int total = 0;
@@ -216,6 +242,20 @@ static int parse_hex(std::string_view s) {
216242
return result;
217243
}
218244

245+
export std::optional<std::int64_t>
246+
parse_chunk_size_line(std::string_view line) {
247+
if (line.empty()) return std::nullopt;
248+
std::uint64_t value {};
249+
auto [end, error] = std::from_chars(
250+
line.data(), line.data() + line.size(), value, 16);
251+
if (error != std::errc{} || end != line.data() + line.size()
252+
|| value > static_cast<std::uint64_t>(
253+
std::numeric_limits<int>::max())) {
254+
return std::nullopt;
255+
}
256+
return static_cast<std::int64_t>(value);
257+
}
258+
219259
// Case-insensitive string comparison
220260
static bool iequals(std::string_view a, std::string_view b) {
221261
if (a.size() != b.size()) return false;
@@ -870,6 +910,8 @@ private:
870910
std::int64_t contentLength = -1;
871911
bool connectionClose = false;
872912
std::string location;
913+
std::string etag;
914+
std::string lastModified;
873915

874916
while (true) {
875917
std::string line = read_line(*sock, config_.readTimeoutMs);
@@ -894,6 +936,10 @@ private:
894936
connectionClose = true;
895937
if (iequals(key, "Location"))
896938
location = valStr;
939+
if (iequals(key, "ETag"))
940+
etag = valStr;
941+
if (iequals(key, "Last-Modified"))
942+
lastModified = valStr;
897943
}
898944

899945
// Follow redirects
@@ -920,6 +966,11 @@ private:
920966
return result;
921967
}
922968

969+
result.finalUrl = url;
970+
result.etag = std::move(etag);
971+
result.lastModified = std::move(lastModified);
972+
if (contentLength >= 0) result.expectedBytes = contentLength;
973+
923974
// Open output file
924975
std::error_code ec;
925976
std::filesystem::create_directories(destFile.parent_path(), ec);
@@ -950,15 +1001,45 @@ private:
9501001
if (chunked) {
9511002
while (true) {
9521003
if (cancelled()) return result;
953-
std::string sizeLine = read_line(*sock, config_.readTimeoutMs);
1004+
auto sizeResult = read_complete_line(
1005+
*sock, config_.readTimeoutMs);
1006+
if (!sizeResult) {
1007+
result.error = "Invalid chunk size line: "
1008+
+ std::move(sizeResult).error();
1009+
result.bytesWritten = downloaded;
1010+
sock->close();
1011+
pool_.erase(poolKey);
1012+
return result;
1013+
}
1014+
std::string sizeLine = std::move(*sizeResult);
9541015
auto semi = sizeLine.find(';');
9551016
if (semi != std::string::npos) sizeLine = sizeLine.substr(0, semi);
9561017
while (!sizeLine.empty() && (sizeLine.back() == ' ' || sizeLine.back() == '\t'))
9571018
sizeLine.pop_back();
9581019

959-
int chunkSize = parse_hex(sizeLine);
1020+
auto parsedChunkSize = parse_chunk_size_line(sizeLine);
1021+
if (!parsedChunkSize) {
1022+
result.error = "Invalid chunk size: " + sizeLine;
1023+
result.bytesWritten = downloaded;
1024+
sock->close();
1025+
pool_.erase(poolKey);
1026+
return result;
1027+
}
1028+
int chunkSize = static_cast<int>(*parsedChunkSize);
9601029
if (chunkSize == 0) {
961-
read_line(*sock, config_.readTimeoutMs);
1030+
for (;;) {
1031+
auto trailer = read_complete_line(
1032+
*sock, config_.readTimeoutMs);
1033+
if (!trailer) {
1034+
result.error = "Invalid chunk trailer: "
1035+
+ std::move(trailer).error();
1036+
result.bytesWritten = downloaded;
1037+
sock->close();
1038+
pool_.erase(poolKey);
1039+
return result;
1040+
}
1041+
if (trailer->empty()) break;
1042+
}
9621043
break;
9631044
}
9641045

@@ -979,7 +1060,15 @@ private:
9791060
remaining -= toRead;
9801061
if (onProgress) onProgress(totalBytes, downloaded);
9811062
}
982-
read_line(*sock, config_.readTimeoutMs);
1063+
auto delimiter = read_complete_line(
1064+
*sock, config_.readTimeoutMs);
1065+
if (!delimiter || !delimiter->empty()) {
1066+
result.error = "Missing CRLF after chunk data";
1067+
result.bytesWritten = downloaded;
1068+
sock->close();
1069+
pool_.erase(poolKey);
1070+
return result;
1071+
}
9831072
}
9841073
} else if (contentLength > 0) {
9851074
char buf[8192];

tests/test_download.cpp

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,36 @@ import std;
66

77
namespace https = mcpplibs::tinyhttps;
88

9+
TEST(ChunkedProtocol, RejectsEmptyInvalidAndOverflowSizeLines) {
10+
EXPECT_FALSE(https::parse_chunk_size_line("").has_value());
11+
EXPECT_FALSE(https::parse_chunk_size_line("xyz").has_value());
12+
EXPECT_FALSE(https::parse_chunk_size_line("1g").has_value());
13+
EXPECT_FALSE(https::parse_chunk_size_line("FFFFFFFFFFFFFFFF").has_value());
14+
}
15+
16+
TEST(ChunkedProtocol, AcceptsValidSizeAndTerminalChunk) {
17+
ASSERT_TRUE(https::parse_chunk_size_line("1a").has_value());
18+
EXPECT_EQ(*https::parse_chunk_size_line("1a"), 26);
19+
ASSERT_TRUE(https::parse_chunk_size_line("0").has_value());
20+
EXPECT_EQ(*https::parse_chunk_size_line("0"), 0);
21+
}
22+
23+
TEST(DownloadResultContract, CarriesTransferAndResponseMetadata) {
24+
https::DownloadToFileResult result;
25+
result.bytesWritten = 42;
26+
result.expectedBytes = 42;
27+
result.finalUrl = "https://example.test/final";
28+
result.etag = "\"abc\"";
29+
result.lastModified = "Wed, 21 Oct 2015 07:28:00 GMT";
30+
31+
EXPECT_EQ(result.bytesWritten, 42);
32+
ASSERT_TRUE(result.expectedBytes.has_value());
33+
EXPECT_EQ(*result.expectedBytes, 42);
34+
EXPECT_EQ(result.finalUrl, "https://example.test/final");
35+
EXPECT_EQ(result.etag, "\"abc\"");
36+
EXPECT_FALSE(result.lastModified.empty());
37+
}
38+
939
// Test download_to_file against a real HTTPS endpoint.
1040
// Uses httpbin.org which returns known-size responses.
1141

0 commit comments

Comments
 (0)