Commit Graph

767 Commits

Author SHA1 Message Date
yhirose
23f67f25c2 Preserve the order of multipart form parts (#2524)
FormFields and FormFiles were std::multimaps, which sort by field name.
RFC 7578 5.2 says a form processor "SHOULD send back results in order"
and that "Intermediaries MUST NOT reorder the results", so a handler
walking req.form.fields saw the parts alphabetised rather than as they
were sent, and a body received for forwarding could not be reproduced.

Point both at the insertion-ordered container #2523 generalized, with
std::equal_to since field names are case-sensitive. Entries sharing a
name already kept their relative order under std::multimap; what is
recovered here is the order across different names.

Server::read_content() keeps a FormFields::iterator alive across the
content callbacks that fill the part it points at, which is the one
thing this container could have broken: it is vector-backed, so a later
emplace can reallocate and leave an older iterator dangling. The code is
safe because the iterator is reassigned by the same emplace that could
reallocate, and is only read while the flag set alongside it says so.
ContentSurvivesContainerGrowth pins that down with 64 parts, enough to
grow the vector through seven reallocations; reverting the reassignment
makes it abort under ASan rather than fail quietly.

Growth also never copies a part's payload: the parser inserts the entry
with an empty content and appends the body bytes afterwards, and both
mapped types are nothrow-move-constructible, so a reallocation steals
the string buffers rather than deep-copying them.
2026-08-02 18:37:20 -04:00
yhirose
7963c382d6 Preserve the order of query parameters (#2523)
Params was a std::multimap, which sorts by parameter name. Parsing a
query string therefore threw away the order it arrived in, and building
one back out of Params handed the caller an alphabetised query rather
than the one they wrote. ClientImpl::send() takes that path whenever a
request carries Params without a query already in its path, so a caller
signing its query string could not reproduce the order it asked for.
normalize_query_string() exists in part to work around exactly this.

Generalize the container #2520 introduced for Headers into
detail::insertion_ordered_multimap<Mapped, KeyEqual> and alias both
types to it. Params passes std::equal_to, so parameter names stay
case-sensitive, while Headers keeps matching field names
case-insensitively. The name says insertion_ordered because in STL
vocabulary std::multimap is the ordered one, which is the reading this
change exists to correct.

This is a correctness change, not a performance one. Measured on the
real paths against the previous implementation, parse_query_text() over
eight parameters goes 931.5ns -> 935.1ns and params_to_query_str()
374.7ns -> 377.2ns; both are inside the run-to-run noise. The container
is a small share of that work, most of which is the string building in
decode_query_component().

Params picks up the same API changes Headers took in #2520: iterators
follow std::vector rules, value_type is std::pair<std::string,
std::string>, and insert(hint, value) is gone. Headers itself becomes an
alias rather than a class, so the names it appears in mangle differently
again; #2520 has not shipped in a release yet, so this costs nothing on
top of the break already there.
2026-08-02 12:40:00 -04:00
yhirose
486c81b275 Determine the final transfer coding across Transfer-Encoding lines (#2522)
RFC 9110 5.3 lets a coding list be split across several Transfer-Encoding
lines, which combine, in the order the lines were received, into one
comma-separated list. RFC 9112 6.1 then frames the message as chunked
only when chunked is the final coding of that combined list.

is_chunked_transfer_encoding() could not apply that rule while Headers
was an unordered_multimap, since the order of the lines was not
recoverable, so it fell back to reporting any message naming chunked on
any line as chunked. Headers now preserves the order the lines were
received in, so read the final coding directly: the last token of the
last line.

The fallback erred toward reporting chunked because mis-reading a
chunked message as unframed leaves its body in the socket, where a
keep-alive connection parses it as a smuggled request. That direction is
no longer needed. A request whose combined list ends in something other
than chunked is now reported as not chunked, and process_request()
answers 400 and closes rather than letting it reach the "no body" path,
which is what it already did for the single-line "chunked, gzip" form.
So `Transfer-Encoding: chunked` followed by `Transfer-Encoding: gzip` is
rejected instead of being read as chunked, and `gzip` followed by
`chunked` is still accepted.

A trailing line carrying no coding at all now leaves the combined list
ending in nothing rather than inheriting the coding from the line
before it.
2026-08-02 01:05:26 -04:00
yhirose
d860c842ea Preserve the order of header fields with the same name (#2520)
RFC 9110 5.3 makes the order of header fields sharing a field name
significant, but Headers was a std::unordered_multimap, which gives no
ordering guarantee for equivalent keys. libstdc++ hands duplicates back
in reverse insertion order while libc++ uses insertion order, so
get_header_value() returned a different field depending on the platform,
and code that picks a value out of an accidentally or maliciously
duplicated field name had no way to say which one it wanted.

Replace Headers with a small container that keeps the fields in the
order they were received or set. Storage is a flat vector and lookup is
a linear scan, which beats hashing for the at most
CPPHTTPLIB_HEADER_MAX_COUNT fields a message carries. begin()/end() walk
every field, while find() and equal_range() hand back the same iterator
type restricted to one field name; equality compares only the position,
so a restricted iterator still compares equal to end(). Erasing an
equal_range() therefore removes only the fields with that name and
leaves interleaved fields alone.

std::multimap was the smaller change but sorts by field name, which
would stop control data such as Host from leading the message. Instead
Host is now prepended via emplace_front() so it keeps its place at the
front of a request.

Two side effects worth noting: incrementing past the last field of a
name now saturates at end(), so an out-of-range id passed to
get_header_value() returns the default instead of running off the
container as it did before; and iterators follow std::vector rules, so
they are invalidated by insertion.

Fixes #2509
2026-08-02 00:22:41 -04:00
yhirose
15a23abd7e Clarify the MAP_FAILED guard comment and assert the cleared size
Explain why `addr_` is reset before `close()`: `munmap()` must not be
called with the sentinel. The test also checks `size()`, since the hazard
is a stale size paired with a sentinel `data()`.
2026-08-01 22:21:26 -04:00
yhirose
2e37c51921 Merge branch 'mmap-map-failed-guard' of github.com:metsw24-max/cpp-httplib into metsw24-max-mmap-map-failed-guard 2026-08-01 22:16:28 -04:00
yhirose
5539a66c63 Cover the non-206 Range paths and drop the duplicated test route
The added test only exercised a 403 with a single range, leaving three
paths that were equally broken untested: a suffix range, whose first_pos
is still -1 when the bounds asserts are compiled out under NDEBUG; two
ranges, where apply_ranges() makes no boundary for a non-206 response and
the multipart branch wrote an empty one; and a 2xx that is not a 206,
which was validated but still announced the full content length.

Fold the copied route back into /streamed-with-range with a status query
parameter, following the ?error idiom already used there.
2026-08-01 21:47:35 -04:00
yhirose
6b723fbf9d Merge branch 'range-206-only' of github.com:metsw24-max/cpp-httplib into metsw24-max-range-206-only 2026-08-01 21:38:55 -04:00
yhirose
1562f0ec4f Pass unrecognized Content-Encoding values through instead of failing (#2518)
Since 8bba34e, prepare_content_receiver() rejected every non-empty
Content-Encoding that create_decompressor() could not handle, conflating an
unrecognized content coding with a known one whose support was not compiled
in. A response labeled `Content-Encoding: UTF-8` -- a header some servers
misuse to advertise a character set -- therefore failed with 415, which the
client surfaced as the generic Error::Read. Before that commit the raw body
came back untouched.

Restore that behavior: reject only a coding cpp-httplib recognizes but was
not built with, and leave an unrecognized one (including "identity") alone.
Match codings case-insensitively while here, as RFC 9110 8.4.1 requires;
otherwise `Content-Encoding: GZIP` would look unrecognized and hand back a
still-compressed body.

open_stream() had the mirror-image problem. It silently passed the payload
through whenever create_decompressor() returned null, so a gzip response on
a build without zlib reached the caller still compressed, and it never
checked is_valid() -- gzip_decompressor::decompress() only asserts, so a
failed inflateInit2() was undefined behavior in release builds. It now
applies the same policy as the buffered path.

Add Error::UnsupportedContentEncoding so callers can tell this apart from a
read failure, and report an unusable decompressor as Error::Compression. The
status read_content() writes was previously discarded into an uninitialized
dummy_status.
2026-08-01 21:25:22 -04:00
yhirose
a691e531c3 Close the listening socket in stop() even when not serving (#2517)
* Close the listening socket in stop() even when not serving

Server::stop() released svr_sock_ only under `if (is_running_)`, and
is_running_ is set inside listen_internal(). A server that bound with
bind_to_port() / bind_to_any_port() and never reached listen_after_bind()
therefore kept its listening descriptor for the life of the process:
~Server() is defaulted and svr_sock_ is a bare atomic<socket_t>, so nothing
else closes it. The port stayed held too.

Close whenever the socket is open instead. The exchange already made this
safe against a concurrent accept loop, so dropping the is_running_ gate also
removes a TOCTOU between the check and the exchange.

* Fail listen_after_bind() when stop() already closed the socket

With stop() now releasing a bound-but-not-serving socket, a stop() that
lands between bind and listen used to slip through listen_internal():
the accept loop saw INVALID_SOCKET, never iterated, and returned success
without ever serving, pulsing is_running_ just long enough that a
wait_until_ready() caller could miss it and spin forever. Return false
instead and mark the server decommissioned the way any failed listen
does, so waiters wake up.

Also drop the assert() in stop(). It read is_running_ and svr_sock_
separately, which is exactly the race the exchange removes: a second
stop() while the accept loop is still unwinding sees is_running_ true
and svr_sock_ already INVALID_SOCKET, aborting debug builds.

The regression test checks the released port with a raw connect()
instead of a Client request: against the old code the connection is
accepted into the backlog and never answered, which would hang the test
rather than fail it.
2026-08-01 19:57:37 -04:00
yhirose
ae8356d86e Clean up addr_map hostname support
Follow-up to 49b921b.

Move the duplicated addr_map lookup into detail::apply_addr_map, shared by
ClientImpl::create_client_socket and WebSocketClient::connect.

Add a WebSocketClient test for a hostname mapped value, so that path has
the same coverage as the Client one. Guard its teardown with scope_exit:
a failing ASSERT_TRUE returns from the test body, and destroying a still
joinable std::thread calls std::terminate, taking the whole binary down.

Document set_hostname_addr_map in README. It had no entry at all.
2026-08-01 14:51:35 -04:00
Kim, Hyuk
49b921b52d Allow addr_map_ values to be hostnames, not just IP literals (#2515)
addr_map_ only accepted IP literals as mapped values; a non-IP value was
passed as the `ip` argument and rejected by getaddrinfo's AI_NUMERICHOST
path. The lookup in ClientImpl::create_client_socket and
WebSocketClient::connect now checks the mapped value with
detail::is_ip_address: IP literals keep the existing AI_NUMERICHOST path,
while hostnames are passed as the connect host so they get resolved.

host_ is never touched, so it keeps supplying the Host header and SNI in
both cases.

is_ip_address() moved into the non-SSL detail block so that both client
code paths can use it without CPPHTTPLIB_SSL_ENABLED.

This also fixes the documented Unix domain socket client example, where
the mapped value is a socket path: it previously took the AI_NUMERICHOST
path and never reached the AF_UNIX branch in create_socket.
2026-08-01 14:29:20 -04:00
yhirose
447b9c4a29 Buffer the WebSocket handshake before writing it to the socket
Follow-up to #2514. The rebuilt handshake wrote the request line straight
to the socket, so a header rejected by check_and_write_headers left a
truncated "GET /ws HTTP/1.1" sitting in the peer's buffer before the
connection was torn down, and every header cost its own small write.

Build the request into a BufferStream and flush it in one go, matching
ClientImpl::write_request. The new test drives a raw listener and asserts
the peer sees a clean EOF with zero bytes; without this change it observes
18.

Also fold WebSocketTest.HostHeaderInHandshake into
WebSocketTest.DefaultHeadersInHandshake, which covers the same Host
assertion through the capture helper #2514 introduced.
2026-08-01 14:09:53 -04:00
hyuk.kim
2c28d2fa3e websocket: rebuild handshake on Request/header pipeline
Replace the hand-built upgrade request string in
perform_websocket_handshake with the same Request,
write_request_line, and check_and_write_headers path used by
ClientImpl::open_stream. Add WebSocketClient::prepare_default_headers
to inject Host and User-Agent defaults; protocol-mandatory headers
(Upgrade, Connection, Sec-WebSocket-Key/Version) are always
overwritten.
2026-08-01 17:15:58 +09:00
Sayed Kaif
f4fce42e77 fail mmap::open when ::mmap returns MAP_FAILED 2026-07-30 00:46:08 +05:30
Sayed Kaif
23fef15e07 apply Range only to a 206 response in write_content_with_provider 2026-07-28 13:19:35 +05:30
yhirose
5b9d1495ff Apply path encoding to open_stream()
ClientImpl::open_stream() passed the caller-supplied path straight to the
request line, so it ignored path_encode_ entirely and always behaved as if
set_path_encode(false) had been called. The same path therefore produced
different bytes on the wire depending on which API was used:

  Get()          "/a b"  ->  GET /a%20b HTTP/1.1
  open_stream()  "/a b"  ->  GET /a b HTTP/1.1

A space is the request-target delimiter, so the streaming form is not merely
inconsistent: an RFC 9112 conformant server reads the target as "/a" and the
version as "b". Non-ASCII bytes and '+' diverged the same way, the latter
changing the value a server that decodes '+' as space sees.

Extract the path/query splitting and encoding out of ClientImpl::write_request
into detail::encode_request_target() and call it from both paths, so the
encoding rule lives in one place. open_stream() appends Params before
encoding, matching ClientImpl::Get(path, params), which builds its target the
same way.

Note a behavior change: with path encoding enabled, CR/LF in the target is now
percent-encoded and sent rather than rejected with Error::Write, matching
Get(). This is not a weakening of the CR/LF guard in write_request_line() --
that check is independent of path_encode_ and still backstops
set_path_encode(false), where encode_path() does nothing.
2026-07-25 13:05:48 -04:00
yhirose
2fa0417754 Fix mbedTLS is_peer_closed() destroying the first response byte
Mbed TLS has no SSL_peek() equivalent, so is_peer_closed() (called after
every SSL request write) probed liveness with a real 1-byte
mbedtls_ssl_read() and discarded whatever it read. If the response had
already arrived by the time the probe ran — plausible under CI load or
plain OS scheduling — the probe silently ate the first byte of the
status line, corrupting the response and surfacing as a fast
"Failed to read connection" failure.

This was the root cause of the long-standing MbedTLS-only CI flakiness
(ServerTest cases failing intermittently on Ubuntu and macOS), previously
worked around by reducing gtest shard parallelism. Fix: push the probed
byte back into MbedTlsSession and have tls::read()/pending() account for
it, so no data is lost.

Also fix a second, unrelated flake: ProxyTunnelTest.
OriginReturning407InsideTunnelDoesNotLeakProxyDigest used "localhost" for
its client while the test's proxy harness only listens on 127.0.0.1;
under dual-stack resolution this could race with another test's server
on ::1 using the same ephemeral port. Pin the test to 127.0.0.1.

With the root cause fixed, restore the mbedTLS CI jobs (ubuntu,
ubuntu-26.04, macOS) to the default shard count instead of the
previously reduced SHARDS=1/2 mitigation.
2026-07-23 22:41:43 -04:00
yhirose
f7c8455a62 Log res.error() on all ServerTest ASSERT_TRUE(res) assertions
Only 8 of 423 call sites logged the actual error on failure. The
MbedTLS-backend flaky CI failure (connection-level ASSERT_TRUE(res),
~20-30ms) keeps landing on assertions without this diagnostic, so the
real error code has never been captured. Broadens the existing
GetWithRange-only logging (a4d7066) to every plain ASSERT_TRUE(res);
site, no behavior change.
2026-07-23 18:55:41 -04:00
Sayed Kaif
695961f8ae skip invalid fields in write_headers to prevent response splitting 2026-07-23 21:42:37 +05:30
yhirose
c64bf21a5e Merge pull request #2504 from emreay-/skip-drain-on-closing-connection
Skip request body drain when connection will close
2026-07-22 12:01:20 -04:00
Emre Ay
8bbfc90380 Apply clang-format to test.cc and httplib.h
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 10:53:51 +03:00
yhirose
bf8baf0739 clang-format 2026-07-21 19:47:15 -04:00
yhirose
fd0c18b1b5 Validate connecting peer before honoring X-Forwarded-For
Server::process_request only checked that trusted_proxies_ was
non-empty before deriving req.remote_addr from the X-Forwarded-For
header. It never verified that the actual TCP peer (remote_addr) was
itself one of the trusted proxies, so any client connecting directly
to the server could spoof remote_addr simply by sending an arbitrary
X-Forwarded-For header.

Now X-Forwarded-For is only honored when the connecting peer address
matches an entry in trusted_proxies_.
2026-07-21 19:44:39 -04:00
yhirose
bbd56a7e2c Merge pull request #2497 from superm1/superm1/SWSPLAT-23636
Limit the number of header lines per multipart form-data part
2026-07-21 19:15:50 -04:00
yhirose
54b3c1d072 Add Mbed TLS 4.x support (PSA Crypto) (#2502)
* Add Mbed TLS 4.x support (PSA Crypto) for macOS

Auto-detect Mbed TLS 4.x via MBEDTLS_VERSION_MAJOR and adapt the backend:
- Include psa/crypto.h and drop the headers removed in 4.x (ctr_drbg,
  entropy, md5, sha*), gated behind the version macro.
- Compute MD5/SHA-256/SHA-512 via PSA (psa_hash_compute) and initialize
  PSA Crypto once with std::call_once.
- Drop the explicit entropy/CTR-DRBG RNG (PSA provides the TLS RNG) and
  skip the RNG-callback overloads of pk_parse_key/pk_check_pair on 4.x.
- Retry on a TLS 1.3 NewSessionTicket (the 4.x default) in connect, read,
  write and is_peer_closed via a single mbedtls_is_session_ticket() helper,
  so online HTTPS works, including large redirected downloads where the
  ticket arrives mid-write.

Note V4 implies V3, so 3.x-only paths now check V3 && !V4.

Build systems (macOS): the CMake config and pkg-config shipped by Homebrew
resolve 4.x transitively, so CMakeLists.txt and meson.build need no change
for linking; the Makefile links libtfpsacrypto when present, else
libmbedcrypto.

Tests: generate the encrypted client key as both PBES2-AES (3.6+/4.x,
OpenSSL, wolfSSL) and PBES1-3DES (Mbed TLS 2.28) and pick by version, since
4.x dropped DES and 2.28 lacks PBES2. Also generate the IP-host certs in
test/meson.build to match gen-certs.sh and CMakeLists.txt.

* CI: test Mbed TLS 4.x on macOS, 3.x on Ubuntu 26.04

Homebrew's default mbedtls is now 4.x, so switch the macOS build and CI job
to it (drop the mbedtls@3 pin). That leaves 3.x (Ubuntu 24.04 apt ships 2.28,
macOS now 4.x) uncovered, so add an ubuntu-26.04 job whose apt provides Mbed
TLS 3.6. Net coverage: 2.28 (ubuntu-latest), 3.6 (ubuntu-26.04), 4.2 (macOS).

ubuntu-26.04 is a public-preview runner image; fold it into the main ubuntu
matrix once ubuntu-latest moves to 26.04.

* Document Mbed TLS 4.x support and libtfpsacrypto rename

Update README.md and the tour's TLS setup pages (en/ja) to note that
Mbed TLS 4.x is now auto-detected and that it renames libmbedcrypto
to libtfpsacrypto.
2026-07-21 18:45:48 -04:00
metsw24-max
6c4cbd49a2 scan X-Forwarded-For right-to-left in get_client_ip (#2503) 2026-07-21 18:12:40 -04:00
Emre Ay
f3e9a4d887 Skip request body drain when connection will close
The post-response drain exists to keep unread framed body bytes from being parsed as a subsequent request on a persistent connection. Once response generation has committed the connection to close, there can be no subsequent request, so draining no longer provides that protection.

Continuing to drain is especially harmful when a ContentReader aborts an unterminated chunked upload: the server can wait indefinitely for the terminal chunk even after sending Connection: close. This delays the transport close that tells an in-flight uploader to stop and leaves a worker occupied consuming discarded data.

Use the finalized response Connection header as the single source of truth for whether to skip the drain. write_response_core already sets this header for keep-alive exhaustion, request-directed closure, handler-directed closure, and error responses. Marking connection_closed then terminates the keep-alive loop and closes the socket.

Add a raw-socket regression test whose ContentReader rejects the first chunk of an unterminated upload. The test verifies that the 409 response announces Connection: close and that the peer observes EOF rather than timing out while the server drains.
2026-07-20 20:36:06 +03:00
Mario Limonciello
877a52f6b3 Limit the number of header lines per multipart form-data part
The multipart/form-data parser bounded the length of each individual
header line via CPPHTTPLIB_HEADER_MAX_LENGTH but did not cap how many
header lines a single part could contain. Within the overall payload
limit, a client could pack a large number of small header lines into
one part, each of which is parsed twice, inflating CPU usage.

Add a per-part header-line counter that resets when a new entry begins
and abort parsing once it reaches CPPHTTPLIB_HEADER_MAX_COUNT, mirroring
the limit already enforced by read_headers() for request headers.
2026-07-18 19:20:51 -05:00
yhirose
3adc525cec Use detail::from_chars for out-of-range Content-Length check
Replace the strtoull + errno + cast-back dance in get_header_value_u64
with the existing hand-written detail::from_chars, which reports
result_out_of_range at size_t width. This detects the 32-bit truncation
case directly (instead of via a separate cast-back comparison), drops
the reliance on the global errno, and keeps the parsing locale-
independent and consistent with the rest of the codebase.
2026-07-18 18:29:17 -04:00
Sayed Kaif
a415362dff update OpenStreamMalformedContentLength.OutOfRange for stricter parsing 2026-07-18 18:29:16 -04:00
Sayed Kaif
982235c0a9 flag out-of-range Content-Length in get_header_value_u64 2026-07-18 18:29:16 -04:00
yhirose
255c075b82 Reject CR/LF in the request target and fail the request cleanly (#2501)
* reject crlf in request target in write_request_line

* declare write_request_line in test.cc for split builds

split.py strips `inline` and moves the definition into httplib.cc, so
detail::write_request_line is not visible from the split httplib.h and
test_split failed to compile. Re-declare it in test.cc, matching what the
base64_encode and getaddrinfo_with_timeout tests already do.

* Fail the request when write_request_line rejects the target

The CR/LF guard in write_request_line returns -1, but ClientImpl::write_request
ignored that return value. A rejected target therefore produced a request-line-
less request (headers only) that the client silently reported as a successful
send. This is the primary path reachable via a decoded redirect Location under
set_path_encode(false), and it also carries the CONNECT target.

Check the return value like ClientImpl::open_stream already does and fail with
Error::Write. Add an end-to-end test asserting the client refuses a CR/LF target
instead of putting it on the wire.

---------

Co-authored-by: Sayed Kaif <metsw24@gmail.com>
2026-07-18 18:07:44 -04:00
yhirose
75938f08c7 Match chunked as the final transfer coding, order-independently (#2500)
is_chunked_transfer_encoding compared the whole Transfer-Encoding field
value against "chunked", so a message whose final coding is chunked but
which names another coding first ("gzip, chunked", valid under RFC 9112
6.1) was read as unframed. On a keep-alive server the body was then left
in the socket and parsed as a smuggled request; open_stream repeated the
check case-sensitively with a raw ==, desyncing the client stream the
same way.

Rework the helper to match the last coding token case-insensitively and
route open_stream through it so both paths agree. The codings may also be
split across multiple Transfer-Encoding lines (RFC 9110 5.3); since
Headers is an unordered_multimap whose duplicate-key iteration order is
not portable, the final coding of a multi-line field cannot be
determined reliably, so treat any such message that names chunked as
chunked (fail safe: a mis-parse only closes the connection, whereas the
opposite error enables smuggling). A unit test covers the helper,
including order-independent multi-line cases.

Based on #2487 by @metsw24-max.
2026-07-18 18:07:29 -04:00
yhirose
0c1cc8c986 Strip Cookie headers on cross-origin redirect
Cookie and Cookie2 headers were forwarded to the new host when following
a cross-origin redirect, even though Host, Proxy-Authorization, and
Authorization were already stripped. Add them to the removal list so
session cookies are not leaked to a different origin.
2026-07-12 10:45:36 -04:00
yhirose
2f986fd5e5 Fix use-after-free of TLS session in WebSocketClient::shutdown_and_close()
shutdown_and_close() freed the TLS session before ws_->close() sent the
WebSocket close frame. The WebSocket's SSLSocketStream keeps a raw pointer
to that session, so sending the close frame then read/wrote a freed SSL
object. Reorder so ws_->close()/ws_.reset() run while the session is still
alive, then free the session (GHSA-w7p7-f35j-mw7q).
2026-07-11 21:20:36 -04:00
yhirose
06b8b91589 Fix Response::content_length_ not reflecting body size in Logger (Fix #2488)
Server::apply_ranges computed the correct Content-Length header for
body-based responses but never updated content_length_, so the
Logger callback always saw 0. Set content_length_ to the final body
size (post-range/post-compression) alongside the header.
2026-07-11 15:52:54 -04:00
yhirose
873d701972 Fix use-after-free in SSLClient destructor with mbedTLS (Fix #2492)
SSLClient::~SSLClient() freed the TLS context before shutting down
the SSL session. mbedTLS sessions hold a raw pointer into the
context's mbedtls_ssl_config, so a live keep-alive session's
close_notify would read freed memory. Shut down the session first,
then free the context.

Add a regression test that destructs an SSLClient while a keep-alive
mbedTLS session is still open.
2026-07-11 15:27:42 -04:00
yhirose
568d434e72 Fix CRLF injection in chunked response trailers
Trailer field names and values written by write_content_chunked()'s
done_with_trailer lambda were never validated, unlike every other
header output path (set_header, WebSocket handshake, client request
headers). An application reflecting untrusted input into a trailer
via DataSink::done_with_trailer() could inject CR/LF sequences and
achieve HTTP response splitting.

Skip trailer fields with invalid names or values, matching the
silent-skip behavior of set_header().
2026-07-11 13:54:02 -04:00
Copilot
32abac3de5 Fix ambiguous Get() examples in README after new Params overload (#2486)
* Initial plan

* Add Get(path, params) overload to ClientImpl and Client; fix ambiguous test calls

* Fix clang-format style errors in test.cc

* Fix remaining ambiguous Get() calls in test_proxy.cc and test.cc

* Update README Get() examples to disambiguate Headers overload

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-07-05 18:43:11 -04:00
yhirose
a4d7066c2c Log res.error() in GetWithRange test assertions to diagnose flaky CI failures 2026-07-01 23:46:05 -04:00
yhirose
bd455f8b91 Add locale-independent ASCII classification helpers (Fix #2482, Fix #2483)
std::isalnum and std::isdigit consult the global C locale, so a byte
like 0xC5 can classify as alphanumeric once an embedder calls
setlocale() (observed on macOS). HTTP grammars are defined over ASCII,
so raw bytes must be classified without regard to the locale.

Add detail::is_ascii_digit/is_ascii_alpha/is_ascii_alnum and use them
at every classification site: multipart boundary validation, token
checks, URI encoding, range header parsing, is_numeric, is_hex, and
IPv4 host detection. Also unify the hand-written digit range checks in
from_chars, URL parsing, and parse_ipv4 onto the same helpers.

With these in place nothing uses <cctype> anymore, so the include is
dropped.
2026-07-01 23:14:53 -04:00
yhirose
32ff75e355 Make ThreadPool idle timeout configurable at runtime (Fix #2481) 2026-07-01 22:08:01 -04:00
yhirose
45da614ddd Omit default port from WebSocket handshake Host header (Fix #2480)
The WebSocket upgrade request always appended ":port" to the Host
header, violating RFC 6455 Section 4.1 which says the port should be
included only when it is not the default (80 for ws, 443 for wss).
Some CDNs alter routing when the Host header carries an explicit
default port.

Build the Host header with detail::make_host_and_port_string, which
also brackets IPv6 literal hosts correctly.
2026-07-01 21:38:57 -04:00
yhirose
f5c8c982df Escape CR and LF in multipart part content types
Same header-injection vector as the name/filename fix: item.content_type
was concatenated into the part's Content-Type header unescaped, so
embedded CR/LF could inject arbitrary part headers.

Escape CR -> %0D and LF -> %0A via escape_multipart_field with a new
escape_quote = false mode. '"' is left intact since it is legal in
Content-Type values (e.g. quoted charset parameters) and appears outside
a quoted-string context here.
2026-07-01 21:38:57 -04:00
yhirose
9e8b960e44 Add public MultipartFormDataWriter for multipart body serialization
Building a multipart/form-data body outside Client::Post/Put/Patch
(e.g. to feed a custom content provider or compose with other body
sources) previously required calling several detail:: functions
directly, which is not a stable interface.

Introduce MultipartFormDataWriter, a small public class that owns the
boundary and delegates to the existing detail:: serializers: whole-body
serialization with known content length, and per-part framing
(item_begin/item_end/finish) for streaming. Also expose
is_valid_multipart_boundary() so callers using an explicit boundary can
validate it without exceptions.
2026-07-01 21:38:57 -04:00
yhirose
8149bb38fc Escape quote, CR and LF in multipart field names and filenames
detail::serialize_multipart_formdata_item_begin() concatenated item.name
and item.filename into the Content-Disposition quoted-string without any
escaping. A '"' in either value silently terminated the quoted-string
early, and embedded CR/LF allowed injecting arbitrary part headers or
forging part boundaries when a filename comes from external input.

Escape both values following the WHATWG HTML standard ('"' -> %22,
CR -> %0D, LF -> %0A), which matches what browsers send in
multipart/form-data bodies. Behavior only changes for inputs that
previously produced malformed HTTP.
2026-07-01 21:38:57 -04:00
Saber Haj Rabiee
3fe32b63b4 Send query string verbatim when path encoding is disabled (#2479)
set_path_encode(false) only suppressed encoding of the path part. The
query part was always run through normalize_query_string(), which
decodes then re-encodes each key/value pair regardless of the flag.

That round-trip is lossy for pre-encoded payloads: re-encoding emits
sub-delimiters literally (%2C->",", %24->"$", %3B->";", ...) and turns
%20 into "+", which a strict RFC 3986 server decodes back as "+"
(0x2B) rather than a space (0x20), corrupting binary query data.

Honor path_encode_ for the query as well: when disabled, append the
caller-supplied query verbatim. Add a regression test asserting on the
raw request target, since the server decodes "+" as space and would
otherwise mask the difference.
2026-07-01 21:18:05 -04:00
metsw24-max
a7b886b9cb Use an unsigned accumulator in base64_encode (#2477)
* use an unsigned accumulator in base64_encode

* Forward-declare detail::base64_encode for split builds
2026-07-01 20:12:00 -04:00
yhirose
9ac64e90db Generate IP-host test certificates in CMake build
The cert_ip_cn.pem and cert_ipv6.pem certificates added in ba390f2 were
only generated by gen-certs.sh, which the Makefile-based Linux/macOS CI
uses. The Windows CI builds with CMake, whose own certificate-generation
block was not updated, so cert_ipv6.pem was missing there and
SSLClientServerTest.TlsVerifyHostnameIpv6San failed on is_valid().

Mirror the two openssl commands into test/CMakeLists.txt to keep both
certificate-generation paths in sync.
2026-06-18 19:50:46 -04:00