Commit Graph
2197 Commits
Author SHA1 Message Date
yhirose 455166314b Keep a %u escape that is not a character
decode_path_component dropped a %u escape of a surrogate, joining what
was on either side of it, so /ad%uD800min became /admin after a filter
had seen something else. Keep such an escape as it is.
2026-10-16 16:39:00 -04:00
yhirose 3420ba8fca Read the request line strictly
Any run of spaces or tabs separated the parts of the request line, a
fragment in the target was stripped, and an HTTP/1.1 request without
Host was served, so a server or a cache in front that follows RFC 9112
could see another request. Require a single SP between the parts, and
answer a target with '#' and an HTTP/1.1 request without Host with 400.
The client no longer sends the fragment of a URL.
2026-10-16 16:39:00 -04:00
yhirose 58957ae4da Close the connection after an ambiguous Transfer-Encoding
RFC 9112 6.1 has a server close the connection after answering a
request with both Transfer-Encoding and Content-Length, or with
Transfer-Encoding in HTTP/1.0. Close it, and send Connection: close
whenever the connection closes after a response.
2026-10-16 16:39:00 -04:00
yhirose 64bebd3b2b Honor quoted-pairs in parameter values
split_unquoted ended a quoted-string at an escaped quote, so
name="a\"; filename=evil.php" came apart into a name and a filename that
a parser in front following RFC 9110 would not see. Skip quoted-pairs
when splitting, and unescape \" and \\ in Content-Disposition values,
leaving other backslashes, such as those of a Windows path, as they are.
2026-10-16 16:39:00 -04:00
yhirose d5851d7e89 Resume the multipart header search where it stopped
A part header arriving a few bytes at a time was searched for its CRLF
from the start on every read, which made parsing it quadratic. Remember
how far the search got.
2026-10-16 16:39:00 -04:00
yhirose cadcc425b8 Bound the size of an SSE event
The SSE client buffered a line, or the data of an event, with no limit
of its own, and with payload_max_length(0) with none at all. Add
CPPHTTPLIB_SSE_MAX_EVENT_LENGTH (16MB) and end the connection with
ExceedMaxPayloadSize past it.
2026-10-16 16:39:00 -04:00
yhirose f5e11024da Bound each WebSocket fragment by what is left of the limit
Every continuation frame could be as long as the whole message limit,
and the total was checked only after appending it, so a fragmented
message could take about twice the limit. Read each fragment against
what is left, with room for a control frame between them.
2026-10-16 16:39:00 -04:00
yhirose 672255619b Do not reserve a response body by its claimed length
The client reserved what Content-Length claimed, up to
payload_max_length, before any of the body arrived, and without a limit
nothing bounded it. Reserve at most 1 MiB up front and let a longer body
grow as it is read.
2026-10-16 16:39:00 -04:00
yhirose b270564c18 Keep a Client usable when its URL cannot be used
An unsupported scheme without exceptions, or a port out of range, left
Client with no implementation, so the first request after it went
through a null pointer. Put a client in its place that reports itself
invalid and fails every request with Error::Connection.
2026-10-16 16:39:00 -04:00
yhirose 0116be0a2b Treat a zero WebSocket payload limit as none
CPPHTTPLIB_WEBSOCKET_MAX_PAYLOAD_LENGTH = 0 rejected every frame with a
payload, while 0 means no limit for payload_max_length. Make it mean the
same here, and refuse a length that size_t cannot hold.
2026-10-16 16:39:00 -04:00
yhirose 194f392417 Treat a zero payload limit as none when decompressing a stream
set_payload_max_length(0) means no limit, but StreamHandle compared the
decompressed size against 0, so every compressed response read through
the streaming API failed.
2026-10-16 16:39:00 -04:00
yhirose d41d5586cf Remove the unused ClientImpl::copy_settings
Nothing calls it, and since it copies credentials too, it is not what a
redirect client should be set up with either.
2026-10-16 16:39:00 -04:00
yhirose 82e28aa4f3 Hash case-insensitive keys in a loop
case_ignore::hash recursed once per character of the key, and trailer
field names from the wire reach it, so a long one could overflow the
stack of a build that keeps the recursion (-O0). Hash in a loop instead,
which gives the same values.
2026-10-16 16:39:00 -04:00
yhirose 6acdc6255d Compare names as strings instead of by hash
find_content_type(), can_compress_content_type() and
is_prohibited_header_name() switched on a 26-bit hash of the name and
never compared the name itself, so any name that hashed alike was taken
for a known one. An uploaded file with an unknown extension could be
served as text/html, for one.

Compare the names themselves, and drop the hash and its literal
operator, which nothing else uses.
2026-10-16 14:57:01 -04:00
yhirose 9c51a0effa Wait for handshakes before replacing a server's certificates
update_certs_pem() replaced the certificate, key and client CAs inside
the TLS context while handshakes on other threads were still reading
them, and only the creation of a session was serialized with it. A
server rotating its certificates under load could crash.

Count the handshakes in progress, make an update wait until there are
none, and hold off new handshakes while an update is pending so that a
steady stream of them cannot keep it waiting. The wait is bounded by
the handshake timeout.
2026-10-16 12:49:22 -04:00
yhirose 41fada8c6e Bound a TLS handshake by its timeout as a whole
The handshake loops gave every wait for the socket the full timeout
again, so a peer that sent a byte now and then kept a handshake going,
and the worker serving it, for as long as it liked. Work out the
deadline once and wait only for what is left of it, on both sides and in
every backend.
2026-10-16 12:49:22 -04:00
yhirose 2b66aa65e1 Keep a client's limits and TLS checks across redirects
A cross-host redirect is followed by a new client, which did not get the
payload limit, the max timeout, the connection timeout's microseconds or
the hostname address map. Over HTTPS it also lost the session verifier,
the server certificate verifier and a store given to set_ca_cert_store(),
and fell back to the default CAs. Copy them over. The store belongs to
the original TLS context, so pass on the CA certificates it holds as PEM.

After the redirect, the original client also answered a 401 or 407 from
the new host with its own digest credentials and sent that to its own
host, with the new host's path. Return the redirected response as it is,
since the client that followed it has already answered what it could.
2026-10-16 12:02:26 -04:00
yhirose 344311c1c3 Grow a WebSocket payload only as it arrives
read_websocket_frame() resized the payload to the length a frame header
declared before reading any of it, so a 14-byte header made the peer
allocate and zero-fill up to CPPHTTPLIB_WEBSOCKET_MAX_PAYLOAD_LENGTH and
hold it until the read timed out. 70 such headers took a server to over
1 GB, and a client reading from a hostile server was exposed the same way.

Read the payload in CPPHTTPLIB_RECV_BUFSIZ chunks and grow it by each one,
so memory follows the bytes that actually came in.
2026-10-16 10:30:34 -04:00
yhirose 2e278fd6e9 Parse status lines without std::regex
std::regex_match recursed once per character of a status line's reason
phrase, so a server that sent one as long as a whole line overflowed the
client's stack. file_extension() and append_query_params() ran the same
kind of regex over a request path and a caller's path.

Parse all three by hand, with the same results, except that a path that
starts with '?' now gets its parameters joined with '&'. Serving a static
file no longer defers the content type lookup to keep a regex off the 304
path, so it looks the type up once.
2026-10-16 09:56:31 -04:00
yhirose b917676861 Declare set_file_content_provider for the split build
FileContentProviderStaysWithinFile, added in e374966, calls
detail::set_file_content_provider, which split.py moves into httplib.cc,
so test_split did not compile. Declare it in the test, as the other tests
that call into detail:: already do.
2026-10-16 09:56:19 -04:00
yhirose fc0e8230ba Close the connection after a partly read request body
When a content receiver returned false, or threw, partway through a body,
the drain after the response read a whole Content-Length again from where
it stopped. It swallowed the start of the next request, so the rest was
parsed as a request of its own once the handler answered below 400. A
handler that called the content reader a second time read into the next
request the same way.

Mark the body once reading starts, and if it does not finish, answer with
Connection: close and never drain it from the middle. Let each request's
body be read only once.
2026-10-16 05:32:29 -04:00
yhirose c11d95b26b Pass the session handle to wolfSSL verify callbacks
The wolfSSL verify callback set VerifyContext::session to the raw WOLFSSL
pointer, while every other tls:: function takes the WolfSSLSession that
wraps it, so passing ctx.session on from a verifier read the wrong type.
Point each WOLFSSL back to its WolfSSLSession and hand that out instead,
as the OpenSSL and Mbed TLS backends already do with their own handles.
2026-10-16 04:55:45 -04:00
yhirose 6fc677444f Keep TLS verify callbacks per context
tls::set_verify_callback(), which set_server_certificate_verifier() uses,
stored the callback in a single thread_local slot shared by every context.
A verifier was skipped when the handshake ran on another thread, ran for
other clients on the thread that set it, and could run after its client
was destroyed.

Store it with the context instead: in SSL_CTX ex_data for OpenSSL, and in
MbedTlsContext and WolfSSLContext for Mbed TLS and wolfSSL.
2026-10-16 04:55:45 -04:00
yhirose e3749669bc Keep a single content source in a Response
set_content() did not drop a content provider installed earlier, and
apply_ranges() then took the body's size as the Content-Length while the
provider was still the one written. The provider was asked for more bytes
than it had: a file under a mount point went out under the error status a
file request handler set to hide it, its mapping was read past the end,
and a user provider read past its own data.

Make every content setter clear the other sources so the last one wins,
let a body assigned directly after a provider or a file win as well, and
never read past the mapping in the file content provider.
2026-10-16 03:58:42 -04:00
Cosmin Polifronie 16f9d4a68d Avoid getaddrinfo_a under ThreadSanitizer (#2631)
glibc runs getaddrinfo_a() lookups in a thread it creates internally,
bypassing the pthread_create interceptor, so TSan never initializes that
thread and it crashes on its first allocation. Detect TSan (GCC's
__SANITIZE_THREAD__ / Clang's __has_feature(thread_sanitizer)) and use the
existing std::thread-based fallback instead, which keeps the lookup timeout
working. Non-TSan builds are unchanged.
2026-10-15 17:53:29 -04:00
yhirose 4a1700cb5e Use fill_random_bytes in random_string
Drop the thread_local std::mt19937 so multipart boundaries and the Digest
cnonce come from the same non-deterministic source as the WebSocket
masking key. This removes the last mt19937 from httplib.h.
2026-10-14 16:13:28 -04:00
metsw24-max c03af246fc Use a strong entropy source for the WebSocket masking key (#2629)
Draw the masking key and Sec-WebSocket-Key directly from std::random_device instead of a std::mt19937 seeded once with 32 bits, as RFC 6455 Section 5.3 requires an unpredictable masking key. Taking the bytes one at a time also fixes an all-zero key on 64-bit big-endian builds, where the 4-byte memcpy from the 64-bit result_type copied the zero high half.
2026-10-14 16:09:36 -04:00
yhirose 2c918f27ee Redirect a directory request with a relative Location (#2630)
* Redirect a directory request with a relative Location

The trailing-slash redirect used the request path as-is, so a path
starting with "//" produced a Location that clients read as a
network-path reference to another host. Name only the last segment,
percent-encoded and prefixed with "./", so the target always resolves
against the request URL, whatever the mount point or any prefix a
proxy put in front of it.

* Shorten the relative redirect comment
2026-10-14 16:00:03 -04:00
mervindejong 924b5cecf0 Fix redirect for mounted folders (#2628)
A directory request without a trailing slash was redirected to the path
relative to the mount point, so /mount/dir under a mount at /mount sent
Location: //dir/ instead of /mount/dir/. Redirect to the request path
with the slash appended.
2026-10-14 15:35:27 -04:00
yhirose c419c904da Release v0.61.0 v0.61.0 2026-10-13 22:56:02 -04:00
yhirose 878ba41099 Wait for the reconnect in the SSE retry test instead of sleeping
SSEIntegrationTest.DigitRetryFieldApplied slept for 500ms and then
expected a second connection. The first connection, the 100ms wait and
the reconnect do not always fit in that window: the macOS Mbed TLS job
saw only one connection.

Wait until the second connection arrives, and check that it came at
least 100ms after the first one rather than counting connections. The
test no longer depends on how fast the runner is, and takes about 150ms
instead of 500ms.
2026-10-13 09:07:56 -04:00
yhirose 941745c33d Send proxy Basic authentication when one credential is empty
Proxy-Authorization was added only when both the proxy username and the
proxy password were set, and a cross-host redirect carried the proxy
credentials over only when the username was set. Authorization has been
sent when either one is set since #958; do the same for the proxy.

A client that has both a proxy Basic credential with one empty field
and a proxy bearer token now sends Basic rather than Bearer, the order
Authorization already follows.
2026-10-12 22:59:55 -04:00
yhirose 11daa99c86 Stop at the last match when getting a value by index
get_header_value(), get_header_value_u64(), get_multimap_value() and
MultipartFormData::get_field() advanced an equal_range() iterator by
the caller's index after casting it to ssize_t. An index that is
negative as a signed value stepped back onto an entry of another key,
and a huge positive one kept looping after the last match.

Advance with bounded_next(), which stops at the end of the range.
2026-10-12 22:59:55 -04:00
yhirose cbaf751da1 Ignore quoted-strings when parsing an Accept header
parse_accept_header() split on every ',' and then on every ';', with no
idea that a parameter value can be a quoted-string (RFC 9110 §5.6.6). A
value such as text/html;profile="a,b" was cut in the middle, and the
request was answered with 400.

The parser never looks at a parameter value, so remove each
quoted-string (§5.6.4) before parsing rather than teach every split
about quotes. A header without a '"' takes the same path as before.

A quoted-string is removed wherever it appears, so a malformed value
can now be read without it instead of being rejected, and an
unterminated quote hides the rest of the value.
2026-10-12 22:59:47 -04:00
yhirose 33d84a12b6 Compare the Range unit case-insensitively
RFC 9110 §14.1 makes range unit names case-insensitive, but
parse_range_header() matched "bytes=" exactly, so a request with
Range: Bytes=1-3 was answered with 416.
2026-10-12 19:37:07 -04:00
yhirose 5107381963 Fix the RFC reference in the Host validation test
The rule that a client sends an empty Host value when the target URI
has no authority is in RFC 9112 §3.2, not RFC 9110 §7.2.
2026-10-11 23:44:07 -04:00
yhirose 0acbee16dc Reject an ambiguous or invalid Host in a request
A request with more than one Host field line, or with a Host value
holding a byte that cannot appear in uri-host [ ":" port ], is now
answered with 400 (RFC 9112 §3.2). The handler sees only the first
Host line, so such a request could reach it with an authority that
differs from the one an intermediary acted on.

Only the byte set is checked, not the full grammar. An empty value
stays valid, and a request without a Host line is still accepted.

Close #2623
2026-10-11 23:39:50 -04:00
yhirose 1f12bbaabb Treat a bare CR as an SSE line ending
The SSE spec ends a line with CRLF, LF or CR, but SSEClient split only
on LF, so a stream that uses CR alone never produced an event. The LF
of a CRLF is skipped even when it arrives in a later chunk than its CR.

Search for a line ending only in the newly received data. What is left
in the buffer has none, and scanning it again for every chunk made a
long line quadratic.
2026-10-11 23:05:46 -04:00
yhirose 5189edc52e Ignore an SSE id that cannot be sent as Last-Event-ID
SSEClient stored any id it received, but the client rejects a request
whose header value is invalid. An id with a NUL, a control character or
surrounding whitespace therefore made every reconnect fail with
Error::InvalidHeaders before reaching the server, and the id could
never be replaced.

Accept an id only if it is a valid header value, so that the previous
id stays in effect. The SSE spec asks for this for NUL only; the rest
follows from what this client can send.
2026-10-11 23:05:38 -04:00
yhirose fee592c2c2 Ignore a leading UTF-8 BOM in an SSE stream
The SSE spec strips one BOM at the start of the stream, but
SSEClient kept it as part of the first field name, so the
first event was silently dropped.
2026-10-08 21:07:40 -04:00
yhirose 885ed54b10 Unify spacing and wording in the Japanese docs pages
Drop the spaces between Japanese text and inline code or alphanumerics
in the tour and the few other pages that still had them, and reword
sentences that read awkwardly. The client compression page now lists
Zstd alongside zlib and Brotli.
2026-10-08 20:39:13 -04:00
yhirose c95d799180 Polish the wording of the updated docs pages
Reword the Japanese sentences so they read naturally, and correct the
size of the llama.cpp server sources in the code reading chapter.
2026-10-08 20:39:13 -04:00
yhirose bef278e0d2 Fix docs pages that no longer match the code
Samples that did not compile or run as shown:
- res.user_data.get<T>() inside a generic lambda needs the `template`
  keyword; use explicit parameter types (tour 09, cookbook s15).
- listen() on a Unix domain socket fails with port 0 (tour 09, s22).
- "*.dev.local" is not a NO_PROXY pattern (c16).
- ssl_backend_error() holds a verify result, not an ERR_get_error()
  value, after a verification failure; decode each with the matching
  OpenSSL function (c18).
- The content provider's `length` is everything that remains, so the
  sample read the whole file in one call (s05).

Statements corrected:
- Client keep-alive is off by default; c14 is rewritten around
  set_keep_alive(true).
- Mounted files are looked up before GET handlers (tour 04, s04).
- Params keep insertion order, and to_string(Error::Connection) reads
  "Could not establish connection" (tour 02).
- A chunked provider ends with sink.done(), and post_routing_handler
  runs before the response is sent (tour 09).
- Timeouts surface as Error::Read; Error::Timeout comes from the stream
  API (c17). The max timeout cuts off the wait for the response only
  (c13). The progress callback needs Content-Length (c11).
- Encoding selection follows q-values, then Brotli, gzip, Zstd (s08),
  and the client compresses with the first of those it was built with
  (c15).
- stop() cuts a provider-driven response short (s19); a rejected
  content_reader already gets 400 or 413 (s07); user_data values must be
  copyable (s12); Client accepts a client certificate too (t04);
  on_message() is the fallback for every unhandled event and 204/403/404
  end reconnection (e04); the pong timeout takes two to three intervals
  and ends a waiting read() (w02).

In the LLM app tutorial, an uncaught exception does not crash the
server, so say what it does instead. Drop the server and client timeout
settings whose stated purpose, covering inference and download time,
they do not serve: those timeouts bound a single socket wait. Update
the llama.cpp server layout in chapter 7.
2026-10-08 20:39:13 -04:00
yhirose 05f7478523 Fix README samples and statements that no longer match the code
Samples that did not compile or did not do what they showed:
- SSLServer has no default constructor, and SSLClient("host:port") does
  not parse the port.
- res.user_data.get<T>() inside a generic lambda needs the `template`
  keyword; use explicit parameter types instead.
- get_header_value() takes the default value before the index.
- Server has no socket(); show set_socket_opt inside set_socket_options.
- The content provider sample used a local without capturing it.
- "*.dev.local" is not a NO_PROXY pattern; ".dev.local" is.
- The reverse proxy example in README-stream.md called open_stream()
  without a method and moved a StreamHandle into a std::function. Keep
  the client and the handle in one shared object.
- open_stream() does not follow redirects, so drop set_follow_location()
  from the Stream API samples.

Statements corrected:
- ssl_error() returns a tls::ErrorCode, not SSL_ERROR_*.
- An exception escaping the exception handler no longer crashes the
  server; the connection is dropped and the error logger is told.
- post_routing_handler runs before every response, including those the
  earlier hooks short-circuit; file_request_handler runs for GET only.
- Only wolfSSL cannot enumerate CAs loaded from a path or the system.
- Digest authentication and the WebSocket TLS setters need any TLS
  backend, not OpenSSL specifically.
- A dynamic pool thread exits on the idle timeout only.
- The stream connection closes when the Result is destroyed.
- The pong timeout takes two to three ping intervals to fire.
- Regex routes skip paths over CPPHTTPLIB_REGEX_ROUTE_PATH_MAX_LENGTH.

Also list the Error values and compressible MIME types that were
missing, note that a content receiver lifts the default client payload
limit, and update the docker server's startup output.
2026-10-08 20:39:13 -04:00
yhirose f6aee694e2 Log the response length for static files in the docker server
The access log took $body_bytes_sent from res.body, which stays empty
for a static file because it is sent by a content provider. Every file
was logged as 0 bytes. Use the Content-Length of the response instead,
and 0 for HEAD.
2026-10-08 20:39:13 -04:00
yhirose 8aa547e4a1 Wake a blocked WebSocket read when the pong timeout fires
When max_missed_pongs was exceeded, the heartbeat thread called close(),
which returns without touching the socket while another thread is in
read(). That read() then stayed blocked until its read timeout: forever
by default on a client, 300 seconds on a server. A plain read loop never
noticed the unresponsive peer the heartbeat had just detected.

Shut down the read side of the socket after close() so the pending
read() returns Fail. Only the read side: a TLS backend answers the EOF
with an alert, and writing it to a socket closed for writing raises
SIGPIPE in a process that has not ignored it.

ClientDetectsNonResponsivePeer now waits in read() instead of polling
is_open(), which covers both.
2026-10-08 20:39:13 -04:00
yhirose 84ff13ec47 Treat a payload max length of 0 as unlimited on every read path
set_payload_max_length(0) is documented to disable the limit, but the
readers for a chunked body and for a body delimited by the connection
closing compared the size against the limit without checking for 0.
A server configured that way answered a chunked request with 413, and
a client failed to read a chunked or unframed response. A body with
Content-Length was already unlimited.

Give both readers the same `payload_max_length > 0` guard the other
payload checks use.
2026-10-08 20:39:13 -04:00
metsw24-maxandyhirose 8f094fe292 match a wildcard only in the leftmost label in match_hostname (#2619)
* match a wildcard only in the leftmost label in match_hostname

* Shorten the wildcard comment in match_hostname

---------

Co-authored-by: yhirose <yuji.hirose.bug@gmail.com>
2026-10-08 12:47:24 -04:00
yhirose 00afaaed6a Release v0.60.1 v0.60.1 2026-10-07 23:40:36 -04:00
yhirose e803f5e413 Let the SSL chain policy alone judge the Windows chain (#2618)
verify_cert_with_windows_schannel() rejected a chain whenever
TrustStatus.dwErrorStatus was non-zero, before
CertVerifyCertificateChainPolicy() ran. The
CERT_CHAIN_POLICY_IGNORE_ALL_REV_UNKNOWN_FLAGS flag passed to that
policy check was therefore dead code: a certificate without revocation
information, or one whose CRL could not be fetched, failed with
CERT_TRUST_REVOCATION_STATUS_UNKNOWN.

Drop the pre-check so the SSL chain policy is the only judge.
Revocation checking becomes best-effort: a revoked certificate and
every other chain error are still rejected, while an undetermined
revocation status is accepted.

On a rejected chain, ssl_backend_error() now holds the policy status,
such as CERT_E_UNTRUSTEDROOT, instead of the trust status bit mask.
2026-10-07 23:19:36 -04:00