Skip to content

Commit c795ad1

Browse files
committed
Fix #2259. Add query string normalization to preserve parameter order in requests
1 parent 3e0fa33 commit c795ad1

File tree

2 files changed

+88
-5
lines changed

2 files changed

+88
-5
lines changed

httplib.h

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5717,6 +5717,38 @@ inline void parse_query_text(const std::string &s, Params &params) {
57175717
parse_query_text(s.data(), s.size(), params);
57185718
}
57195719

5720+
// Normalize a query string by decoding and re-encoding each key/value pair
5721+
// while preserving the original parameter order. This avoids double-encoding
5722+
// and ensures consistent encoding without reordering (unlike Params which
5723+
// uses std::multimap and sorts keys).
5724+
inline std::string normalize_query_string(const std::string &query) {
5725+
std::string result;
5726+
split(query.data(), query.data() + query.size(), '&',
5727+
[&](const char *b, const char *e) {
5728+
std::string key;
5729+
std::string val;
5730+
divide(b, static_cast<std::size_t>(e - b), '=',
5731+
[&](const char *lhs_data, std::size_t lhs_size,
5732+
const char *rhs_data, std::size_t rhs_size) {
5733+
key.assign(lhs_data, lhs_size);
5734+
val.assign(rhs_data, rhs_size);
5735+
});
5736+
5737+
if (!key.empty()) {
5738+
auto dec_key = decode_query_component(key);
5739+
auto dec_val = decode_query_component(val);
5740+
5741+
if (!result.empty()) { result += '&'; }
5742+
result += encode_query_component(dec_key);
5743+
if (!val.empty() || std::find(b, e, '=') != e) {
5744+
result += '=';
5745+
result += encode_query_component(dec_val);
5746+
}
5747+
}
5748+
});
5749+
return result;
5750+
}
5751+
57205752
inline bool parse_multipart_boundary(const std::string &content_type,
57215753
std::string &boundary) {
57225754
auto boundary_keyword = "boundary=";
@@ -10204,13 +10236,32 @@ inline bool ClientImpl::write_request(Stream &strm, Request &req,
1020410236
query_part = "";
1020510237
}
1020610238

10207-
// Encode path and query
10239+
// Encode path part. If the original `req.path` already contained a
10240+
// query component, preserve its raw query string (including parameter
10241+
// order) instead of reparsing and reassembling it which may reorder
10242+
// parameters due to container ordering (e.g. `Params` uses
10243+
// `std::multimap`). When there is no query in `req.path`, fall back to
10244+
// building a query from `req.params` so existing callers that pass
10245+
// `Params` continue to work.
1020810246
auto path_with_query =
1020910247
path_encode_ ? detail::encode_path(path_part) : path_part;
1021010248

10211-
detail::parse_query_text(query_part, req.params);
10212-
if (!req.params.empty()) {
10213-
path_with_query = append_query_params(path_with_query, req.params);
10249+
if (!query_part.empty()) {
10250+
// Normalize the query string (decode then re-encode) while preserving
10251+
// the original parameter order.
10252+
auto normalized = detail::normalize_query_string(query_part);
10253+
if (!normalized.empty()) { path_with_query += '?' + normalized; }
10254+
10255+
// Still populate req.params for handlers/users who read them.
10256+
detail::parse_query_text(query_part, req.params);
10257+
} else {
10258+
// No query in path; parse any query_part (empty) and append params
10259+
// from `req.params` when present (preserves prior behavior for
10260+
// callers who provide Params separately).
10261+
detail::parse_query_text(query_part, req.params);
10262+
if (!req.params.empty()) {
10263+
path_with_query = append_query_params(path_with_query, req.params);
10264+
}
1021410265
}
1021510266

1021610267
// Write request line and headers

test/test.cc

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,38 @@ TEST(EncodeQueryParamTest, ParseReservedCharactersTest) {
399399
"%3B%2C%2F%3F%3A%40%26%3D%2B%24");
400400
}
401401

402+
TEST(ClientQueryOrder, PreserveOrder) {
403+
// This test reproduces Issue #2259: client may reorder query parameters
404+
// when sending a GET request. The expected behavior is that the client
405+
// preserves the original query string order when the caller supplied it
406+
// as part of the path.
407+
Server svr;
408+
svr.Get("/", [&](const Request &req, Response &res) {
409+
// Echo back the raw target so the test can assert ordering
410+
res.set_content(req.target, "text/plain");
411+
});
412+
413+
std::thread t{[&] { svr.listen(HOST, PORT); }};
414+
auto se = detail::scope_exit([&] {
415+
svr.stop();
416+
t.join();
417+
ASSERT_FALSE(svr.is_running());
418+
});
419+
420+
svr.wait_until_ready();
421+
422+
Client cli(HOST, PORT);
423+
ASSERT_TRUE(cli.is_valid());
424+
425+
const std::string original = "/?z=1&y=2&x=3&c=7&b=8&a=9";
426+
auto res = cli.Get(original);
427+
ASSERT_TRUE(res);
428+
429+
// Expect the echoed target to exactly match the original path (order
430+
// preserved)
431+
EXPECT_EQ(res->body, original);
432+
}
433+
402434
TEST(EncodeQueryParamTest, TestUTF8Characters) {
403435
string chineseCharacters = u8"中国語";
404436
string russianCharacters = u8"дом";
@@ -13221,4 +13253,4 @@ TEST(ETagTest, NegativeFileModificationTime) {
1322113253
svr.stop();
1322213254
t.join();
1322313255
std::remove(fname);
13224-
}
13256+
}

0 commit comments

Comments
 (0)