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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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
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.
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.
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.
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.
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.
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.
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.
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.
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.
A SubProtocolSelector could return a value outside the client's
Sec-WebSocket-Protocol list, and the server sent it back as is. The
server now treats such a value as no selection.
A 303 response turns the follow-up request into a GET, but only the buffered body and headers were cleared. A content provider (sized or chunked) stayed on the request, so the original payload was sent again, and for a chunked provider the unframed chunks also broke the keep-alive connection.
The redirect also percent-decoded the Location path before sending it. That turned %23, %3F and %25 into a fragment, a query delimiter and a different octet, so the client requested a different resource than the one named, and made set_path_encode(false) fail on any Location containing %20. The path is now sent as given.
The heartbeat thread waited on ping_cv_ without a predicate, so a
spurious wakeup ended the wait early and sent a ping before
ping_interval_sec_ had elapsed. With max_missed_pongs enabled, the
early ping also counted toward the pong timeout.
Pass a predicate to wait_for so that the wait only ends when the
interval elapses or the connection is closed.
Reported in #2612.
An event with an empty id field must reset the last event ID, so that
no Last-Event-ID header is sent on reconnect. run_event_loop only
updated last_event_id_ when the id was non-empty, so it could not tell
an empty id field from an event with no id field, and kept sending the
stale ID.
Track whether an id field was seen with a has_id flag, as has_data
does for the data field, and add a regression test.
A client may pipeline its requests (RFC 9112 9.3.2). The server read the
following request(s) into a per-request SocketStream buffer, discarded them
with the stream after the first response, and then waited in keep_alive()
for socket data that never came, closing the connection after the
keep-alive timeout. Over TLS the bytes stayed decrypted in the TLS library,
where keep_alive() could not see them either.
Create one stream per connection and serve a request that is already
buffered (Stream::is_readable()) without waiting in keep_alive().
Keeping the buffer means an extra CRLF that some clients send after a
request body is now parsed as the next request line, which answered 400
and closed the connection. Ignore one empty line before the request-line,
as RFC 9112 2.2 recommends.
Fixes#2599
A field line without a colon kept the \r of a CRLF line ending in its
name, so "data\r\n" was not recognized. Strip the \r once per line
before parsing instead of from each value.
An event without a data field was neither dispatched nor cleared, so
its event type leaked into the next event and its id only reached
last_event_id after a later event was dispatched. Reset the message on
every blank line and record the id even when nothing is dispatched.
The parsing tests exercised a copy of parse_sse_line that had drifted
from the real one. Run them through SSEClient against a local server
instead, and add regression tests for the cases above.
parse_port and the NO_PROXY CIDR prefix parsing each repeated the same
from_chars call, full-consumption check and range check. Move that into
a parse_int_in_range helper and use it in both places.
A request whose Content-Length was present but not a valid decimal length
(e.g. "42, 42", "+42", "0x2e" or empty) was treated as having no body
unless a handler read it: no 400 was returned, the body was not drained,
and the bytes after the header block were parsed as the next request on
the keep-alive connection. Reject such a request with 400 and close the
connection before routing, as RFC 9112 Section 6.3 requires.
The server also kept reading after a response that announced
Connection: close. A rejected request line or header block left the rest
of the message to be parsed as a new request, and an error response to a
bodyless request did the same with whatever followed. Close the
connection whenever the final response carries Connection: close
(RFC 9112 Section 9.6), and mark the two request-head rejection paths
closed explicitly as the other rejection paths already do.
Share the server's request-target check as fields::is_request_target()
and use it in write_request_line too. The client previously used
is_field_value(), which let an embedded SP or HTAB through.
encode_path() only escaped CR/LF among the control characters, so with
path encoding enabled a path like "/a\tb" would now be rejected instead
of sent. Percent-encode every control character (0x00-0x1F, 0x7F).
RFC 9112 §3.2 does not allow control characters in the request-target,
and §2.2 requires a bare CR to be treated as invalid. parse_request_line
accepted them, so e.g. "GET /a\rb HTTP/1.1" was routed normally. Reject
any byte that is not VCHAR or obs-text with 400 Bad Request. obs-text is
still allowed since some clients send raw UTF-8 in the target.
req.path is percent-decoded, so a request like GET /%0D%0A... put a
literal CR/LF into the NGINX-style log lines and let a client forge
extra entries. Log the raw req.target (matching NGINX's $request) and
escape '"', '\', control and non-ASCII bytes as \xHH the way NGINX
does. Also note in the README logging section that req.path may
contain control characters and should be escaped before logging.
open_stream wrote the request line and each header straight to the
socket, so a header rejected by check_and_write_headers left the request
line and the headers before it on the wire. Build them in a BufferStream
first and flush once, as write_request and the WebSocket handshake do.
process_request reads the response even after write_request fails, so
that an early response (e.g. 413/414) sent while the body is still being
uploaded is not lost. A request line or header rejected while the
request is being built in memory never reaches the socket, though, so
no response will come and the client blocked until the read timeout (or
until the server closed the idle connection).
write_request now reports such a local rejection, and process_request
returns immediately in that case. Socket write failures still read the
response as before.
write_request_line checked the request target for CR/LF but concatenated
the method verbatim. A method carrying CR/LF could smuggle a whole
request ahead of the real one, and the client would take the smuggled
request's response as its own. A method with a space or an empty method
put a malformed request line on the wire.
Require the method to be a token (RFC 9110 Section 9.1) before anything
is written. All three callers (the buffered client path, open_stream and
the WebSocket handshake) go through this function and fail with
Error::Write, as they already do for a rejected target.
Claude-Session: https://claude.ai/code/session_01NTDesJQTQPEuu4o4XCu69g
tls::shutdown() on OpenSSL called SSL_shutdown() a second time to wait
for the peer's close_notify. An idle keep-alive client never sends one,
so closing its connection held the worker thread until the read timeout,
and Server::stop() waited for it. Send close_notify and return, as the
Mbed TLS and wolfSSL backends already do.
The server wrote 100 Continue as soon as it saw the expectation, before
pre_routing_handler, pre_request_handler, or routing ran. A request
those handlers rejected, or one that matched no route, still invited
the client to send a body the server would never read.
Defer the interim response until the body is about to be read. If the
request is answered without reading the body, 100 Continue is never
sent and the connection is closed, since whether and when the client
sends the body is unknown.
Also treat a 417 returned by expect_100_continue_handler as the final
response. It used to be written as a bare status line, after which the
request was processed and a second response was written.
The WebSocket upgrade path matched the route and switched protocols
without setting req.matched_route or calling pre_request_handler, so a
check placed there (e.g. authentication) never ran for WebSocket
routes. Set matched_route and run the handler before the upgrade; if it
handles the request, reply with a regular HTTP response instead of 101.
Also write rejected upgrade responses (from pre_routing_handler too)
with write_response_with_content, so they carry Content-Length. Without
it, a client reading the body waited until the keep-alive timeout.
`sed -i ''` is BSD-only: GNU sed takes the '' as the script and the expression as a file name, so `just release --run` failed on Linux before touching anything.
199d7ee made read() return the new ReadResult::Timeout for every read
timeout and leave the connection open. The compile-time server default
(CPPHTTPLIB_WEBSOCKET_SERVER_READ_TIMEOUT_SECOND, 300s) is always in
effect, so a handler written as `while (ws.read(msg))`, the form the
README's Quick Start uses, no longer ended when a peer went quiet:
Timeout is non-zero, so the loop ran its body again with the previous
message still in `msg`, and the worker the backstop is meant to reclaim
was never released. Nothing caught it because every test of the new
result set a timeout explicitly and checked the result by value, and the
heartbeat tests keep the connection alive with pings.
The two timeouts mean different things. One the caller sets through
set_read_timeout() is a request for control back, and is reported as
Timeout on a still-open connection. The compile-time default is a
backstop against a peer that has gone quiet, and elapsing it is now a
failure again: read() returns Fail and closes the connection, as it did
before 199d7ee. WebSocket tracks whether set_read_timeout() was called,
and WebSocketClient carries the same flag over to the WebSocket it
creates on connect().
Tests use the heartbeat binary, which compiles both defaults down to 3s:
a `while (ws.read(msg))` server handler runs its body once and exits
when the client falls silent, and a client that never set a timeout gets
Fail with the connection closed. The README and cookbook now say which
timeout produces Timeout.
Claude-Session: https://claude.ai/code/session_01EF5uZ1X2kaHhqJ8VgfjVaQ
ProxyTest, RedirectTest.HTTPBin*, KeepAliveTest and ProxyTest.SSLOpenStream
still sent their requests through the squid proxies to the external
httpbingo.org, so an upstream hiccup there failed CI with no code change
involved (KeepAliveTest.SSLWithDigest got a 502 on its first /get).
Switch them to the "httpbin" container (nginx + go-httpbin) that
BaseAuthTest/DigestAuthTest already use. go-httpbin serves /get,
/redirect/n and /digest-auth the same way, so the test logic is
unchanged; the SSL variants disable certificate verification for the
self-signed test cert, as BaseAuthTest.SSL does.
RedirectTest.YouTube* is left pointing at youtube.com since it exercises
a real cross-host, http -> https redirect chain.
Claude-Session: https://claude.ai/code/session_0148ZAzsuYRYXkwcA7UFeh95
The intermittent failures it was tracking stopped after the graceful
drain before close in 8e702d3: no windows-without-SSL test failure on
master since 2026-08-09. Drop the reporting step, the issues: write
permission it needed, and the run_tests step id that only it used.
Claude-Session: https://claude.ai/code/session_0148ZAzsuYRYXkwcA7UFeh95
These tests exercise the squid proxies by hitting /basic-auth and
/digest-auth on an external httpbin-style site. That site's identity has
already moved twice (httpbin.org -> httpcan.org, per #2300) chasing
uptime, and httpcan.org itself is now down (Cloudflare 502 from its
origin), failing CI with no code change involved.
Adds two containers to the existing squid docker-compose stack instead:
go-httpbin (mccutchen/go-httpbin) as the backend, and an nginx sidecar in
front of it under the single "httpbin" hostname so both the NoSSL tests
(port 80) and the SSL tests, which CONNECT-tunnel through the proxy to
port 443, resolve the same name -- go-httpbin only listens on one port at
a time, so it can't serve both protocols itself. nginx uses the repo's
existing self-signed test cert; the SSL client tests already disable
verification for it like other self-signed-cert tests in this suite.
go-httpbin was picked over the more feature-complete kennethreitz/httpbin
after finding the latter accepts a wrong digest-auth username as long as
the password matches -- confirmed with a direct curl against the
container, unrelated to anything in this repo. go-httpbin correctly
rejects both. The trade-off is losing SHA-512 digest-auth coverage here,
since go-httpbin only implements MD5 and SHA-256; nothing else in the
suite exercises SHA-512 digest auth against a live server. Response body
assertions are adjusted to go-httpbin's actual JSON shape (an added
"authorized" field, no "algorithm" field), and the domain changes from
httpcan.org to the self-hosted "httpbin".
This only affects 'make proxy'/'make proxy_mbedtls'/'make proxy_wolfssl'
and the Proxy Test CI workflow -- the default 'make' target is untouched.
The previous commit pinned CI and the pre-commit hook to a fixed
clang-format 23.1.0, but the maintainer develops on macOS against
whatever version `brew` currently installs, which changes over time
as Homebrew updates the formula.
style-check now runs on macos-latest and installs clang-format via
`brew install`, so it tracks the same moving target the maintainer's
Mac does. The pre-commit hook switches from pre-commit's own pinned
mirror to a local hook that shells out to the system clang-format,
so a local commit and CI both go through the same Homebrew-installed
binary rather than two independently versioned copies.
Trade-off: this reintroduces the non-determinism a fixed pin avoids
-- a commit's style-check result can now change over time as Homebrew
updates the formula -- but that mirrors how the maintainer already
develops, which is the point.
Also install coreutils in CI: the style_check Makefile target needs
grealpath's --relative-to, which the macOS-native realpath lacks.
CI relied on ubuntu-latest's default apt clang-format (18.1.3), while the
pre-commit hook was pinned to a different 18.x build. Neither tracked a
specific, deliberately-chosen version, and the two could drift from each
other and from whatever a contributor has installed locally.
Both now pin the same clang-format 23.1.0 (latest stable), installed via
pipx in CI. Also scope the pre-commit hook's file matcher to the same set
of files test/Makefile's style_check target checks, since the previous
\.(cpp|cc|h)$ pattern reached into vendored code (test/gtest,
benchmark/crow) that must stay untouched.
Reformat httplib.h's brace-init spacing to match 23.1.0's output.
Defined next to its non-template overload, it landed in the part of the header
that test_split compiles into httplib.cc, leaving a user TU's instantiation
with nothing to link against. The WebSocketClient overload of the same name has
always sat up with the class definitions; this one belongs there too.
Only test_split sees it -- a header-only build instantiates the template
wherever it is written, which is why the regular test target stayed green.
read() collapsed every failure into Fail and marked the connection closed with
it, so a read timeout could not be used to get control back and send on the
same connection -- it killed the connection instead. The information was
already there and thrown away: SocketStream::read records Error::Timeout, and
read_websocket_frame flattened it into a bool.
ReadResult gains Timeout, reported only when the timeout elapsed on a frame
boundary with nothing consumed, which is the only case where the stream can be
read again. Every multi-byte field now loops until it has its bytes, which also
fixes a frame header straddling the read buffer's boundary failing the frame:
Stream::read is allowed to return less than asked for, and only the payload
was reading in a loop.
ws::WebSocket::set_read_timeout() lets a server handler bound its own reads,
and WebSocketClient::set_read_timeout() now reaches an already-open connection
instead of only seeding the next connect().
The read timeout macro splits in two. A client waits forever by default -- a
read timeout is the caller's tool for taking back control, not a liveness
check, which is ping/pong's job -- while a server keeps the 300s that reclaims
a worker from a peer gone quiet. Defining the old name still sets both.
Also record a reason on the two WebSocketSSLStream::read failure paths that
returned -1 without one, so get_error() cannot report a previous call's
timeout, and make SocketStream's read timeout atomic now that it can be
changed while a read is in flight.
parse_www_authenticate() accepted any WWW-Authenticate: Digest
challenge that carried at least one auth-param, so a server sending
e.g. Digest qop="auth" with no realm/nonce would make it through.
make_digest_authentication_header() then dereferences auth.at("realm")
and auth.at("nonce") unconditionally, throwing std::out_of_range with
no try/catch on the retry path, which terminates the client process.
Now require both realm and nonce before treating a Digest challenge as
usable, same as if no Digest challenge were present at all.
The reporting step for issue #2533 was gated on failure(), which is true
when any step in the job failed. Two build failures on feature branches
were posted to the issue as flaky test recurrences, with the body falling
back to "Could not extract failed test name" because no test had run.
Gate it on the test step's own conclusion instead, and skip posting when
no [ FAILED ] line turns up in the shard logs. The explicit failure()
stays because an if expression with no status check function gets an
implicit success().
CPPHTTPLIB_MULTIPART_FORM_DATA_FILE_MAX_COUNT is enforced only in
Server::read_content(), where parts are accumulated into req.form. The
streaming ContentReader path keeps nothing and was never in scope, but
this was undocumented (GHSA-923p-8q8g-xcqj). Note the split in the README
and show how to bound the part count from inside a ContentReader handler.
write_content_with_progress() advances its offset only by what the provider
writes, so a provider that reported success without writing anything and
without calling done() was handed the same offset and length again on the next
pass. With the peer still connected it spun there, re-entering the provider as
fast as the loop could run.
make_file_body()'s provider was one way to reach this and was fixed in #2566,
but any user-supplied provider can do the same. Treat a pass that makes no
progress as a short body, which is how done() called early is already handled.
parse_accept_header() rejected any Accept value with a leading, trailing
or doubled comma, and Server::process_request() validates Accept before
routing, so "Accept: text/html," was answered 400 Bad Request on every
route.
RFC 9110 Section 5.6.1.2 requires a recipient to parse and ignore empty
list elements in a #rule list, so those values are legal. split() already
trims each element and skips the empty ones, which made the guard inside
the callback unreachable as well; drop both and let the empty elements
fall away. The header length limit bounds how many a sender can send, so
ignoring all of them cannot be used as a denial-of-service vector.
get_combined_header_value() keeps skipping empty field lines, but that
skip is no longer observable through a request now that a stray comma
parses cleanly, so it gets its own test.
expect_split_multipart_ok() carries a comment saying the request sends
"Connection: close" so the response drain ends as soon as the server has
answered, but the header itself never made it into the request, so both
callers kept idling until the read timeout instead.
Add the header. EpilogueSplitAcrossReadsIsIgnored and
InitialBoundarySplitAfterLongPreamble each drop from about 3.1s to about
0.11s.
Start-Process joins ArgumentList entries with spaces, so /DIR=C:\Program
Files\OpenSSL reached Inno Setup as /DIR=C:\Program and the install landed
there. Linking still succeeded, because the import libraries were present
under that path, and the failure surfaced only when gtest_discover_tests ran
the test binary: exit code 0xc0000135, DLL not found, since PATH pointed at
C:\Program Files\OpenSSL\bin.
Quote the value, and assert that the import libraries and runtime DLLs are
where we expect before exporting PATH, so a misplaced install fails loudly at
the install step instead of quietly at load time.
The Chocolatey openssl package hardcodes a versioned slproweb URL in its
install script, and slproweb keeps only the newest build of each OpenSSL
branch. Every OpenSSL release therefore deletes the file the current package
points at, and "windows with SSL" fails at the install step with a 404 until
someone respins the package. That is what broke the job today: the package is
still at 4.0.1 while slproweb has moved to 4.0.2.
slproweb publishes a JSON manifest of its current downloads, linked from the
download page and updated at the same time as the files themselves. Read that
and take the newest 64-bit 4.x installer from it, so the URL is always live.
The SHA512 in the manifest is verified before the installer runs.
The silent flags are the ones the Chocolatey package used. /DIR pins the
install location that the CMake step already finds, instead of relying on a
registry lookup. PATH and OPENSSL_CONF are exported the same way the package
set them.
Staying on 4.x is deliberate: it keeps this job on the OpenSSL 4.0 series
rather than dropping to the 3.6 that vcpkg would provide.
close() drained the peer's Close reply with its own frame read. If an
application reader thread was inside read() at that moment, two threads
parsed frames off one stream: read_websocket_frame()'s payload loop keeps
reading until it has the declared length, so bytes stolen by the drain
were silently replaced with bytes from further along the stream. The
in-flight message kept its correct length but got the wrong content.
Add a read_mutex_ that marks which thread owns the stream's read side.
read() holds it for the whole call. close() sends the Close frame, then
drains the peer's reply (RFC 6455 7.1.1) only if it can try_lock the
mutex; otherwise it returns immediately, leaving the stream entirely to
the thread already reading it. This also fixes close() blocking for the
full close timeout when a reader thread was parked waiting on a peer
that never replies.
Add WebSocketTest.CloseDoesNotStealBytesFromConcurrentRead, which drives
a raw TCP peer that stalls mid-payload to force the race; it fails
reliably against the old code and passes against the fix.
Update README-websocket.md: close() during a concurrent read() is now
supported.
The harness had a single endpoint returning a 12-byte set_content() body.
That is the one case where the response line, the headers and the body
already share a single write(), so any change to the write path measured
as noise. Comparing a gather-write branch against its merge base reported
0.993x at p = 1.000 while the same branch moved static-file throughput by
a quarter and TLS throughput by nearly half in both directions.
The server now also serves a large set_content() body and small and large
files from a mount point, over HTTPS when a certificate is given, with
--path, --large-mib and --tls selecting the combination.
ab.sh now compiles the harness from the invoking worktree instead of each
ref's own copy, so both refs run an identical workload and a ref that
predates a harness change stays measurable. Only httplib.h varies, through
-I. --timeout is exposed because bombardier's 2s default aborts large TLS
responses, which then fails the non-2xx check.
Drop the now-redundant has_header guard (get_header_value already
returns "" for a missing header, which the length check rejects),
name the "Bearer " prefix once, and cite RFC 9110 to match the
file's convention. Convert the regression test to the table-driven
form used elsewhere in test.cc and move it out of the middle of
GetHeaderValueTest so that suite stays contiguous.
detail::parse_www_authenticate() assumed a single challenge starting at
the first space in the field value and read only its first occurrence,
so a Basic challenge listed before Digest (or split across two field
lines, as some servers do) hid the Digest challenge entirely, and a
second Digest challenge with different parameters (RFC 7616 offering
both SHA-256 and MD5) could mix params from both. Combine repeated
field lines the same way the other list-valued headers do, then split
on commas that aren't inside a quoted-string so a quoted realm can
contain a comma, and track which challenge each auth-param belongs to
by the auth-scheme token that starts it. Also require at least one
auth-param before reporting a Digest challenge as found, since an
empty challenge can't produce a usable Authorization header.
RFC 9110 7.8 defines Upgrade as a comma-separated list of protocols and asks
recipients to match each protocol-name case-insensitively; RFC 6455 4.2.1 asks
for a header field containing the value "websocket". Both handshake checks
instead read occurrence zero and required the whole field value to be exactly
"websocket", so a client offering "websocket, HTTP/3.0" -- or naming websocket
on a second Upgrade field line -- was answered 404 rather than 101.
This is the defect ffe2a1c fixed for Connection two lines below, and
has_header_token() is already called in both of these functions.
The client-side check loosens what we accept back from a server, which is the
same reading: a server answering 101 may name websocket alongside another
protocol, and rejecting that handshake was ours to get wrong.
The four ExpectTokenTest cases landed inside the #ifndef _WIN32 that guards the
10 GiB content-provider test below them, so Windows never compiled them and the
green Windows jobs said nothing about the fix. Nothing in them is POSIX-only --
they use the same helpers as ConnectionTokenTest, which sits outside any guard
-- so move them above the guard.
probe_expect() re-implemented send_request(), down to the create_client_socket
argument list. Call send_request() instead, with Connection: close so its read
loop ends at the response rather than idling to the read timeout; the Connection
check runs before the Expect block, so it does not disturb what is under test.
Move the new Content-Encoding case below its siblings. Appending it to the tail
of the comment block left the "whole token" paragraph reading as documentation
for a test about repeated field lines. The paragraph above it had been detached
from KnownEncodingWithoutSupportIsReported the same way one commit earlier; put
that one back too.
RFC 9110 Section 10.1.1 defines Expect as a comma-separated list, states that
its value is case-insensitive, and requires a server that receives a
100-continue expectation in an HTTP/1.0 request to ignore it. Comparing the
whole field value against "100-continue" met none of those.
An HTTP/1.0 request asking for 100-continue was answered with a 100 (Continue)
interim response, which that section forbids. "100-Continue" and
"100-continue, foo" were both read as no expectation at all, so a client that
waits for the interim response before sending its content waited for a response
that was never coming.
Route the check through has_header_token(), which walks every field line and
compares complete tokens case-insensitively, and skip it for HTTP/1.0. An
expectation cpp-httplib does not recognize is still ignored rather than
refused; the 417 the section offers for one is a MAY, not a requirement.
RFC 9110 Section 5.3 makes a Content-Encoding spread over several field lines
the same message as the comma-joined one, so the two have to be read the same
way. Reading occurrence zero did not: a response carrying "gzip" on two field
lines was decoded as a single gzip coding, so a body the sender says was
encoded twice came back after one pass -- still compressed, but presented to
the caller as decoded. The same value written as "gzip, gzip" on one line took
the pass-through path instead.
Read the combined value at both sites. A value naming several codings matches
none of the ones cpp-httplib implements, so both representations now take the
pass-through path that prepare_content_receiver() already documents for an
unrecognized coding.
This does mean a sender that repeats "Content-Encoding: gzip" on two lines for
a body it gzipped once no longer has that body decoded. There is no way to tell
that sender apart from one that really did encode twice, and the conservative
reading is the one the field value states.
is_brotli_encoding() and is_zstd_encoding() searched the Content-Encoding
value for "br" and "zstd" as substrings, while is_zlib_encoding() beside them
compared the whole value. So "fibre" and "librarian" were read as Brotli and
"x-zstd-ish" as Zstandard, and "gzip, br" -- a value naming two codings, which
cpp-httplib does not support -- was labeled Brotli and run through a Brotli
decompressor over gzip data.
RFC 9110 8.4.1 defines a content coding as a token, so compare the whole value
case-insensitively as the zlib check already does. A value naming several
codings no longer matches any of them and takes the pass-through path
prepare_content_receiver() already documents for an unrecognized coding.
contains_case_ignore() has no callers left.
RFC 9110 Section 7.6.1 defines Connection as a comma-separated list of
case-insensitive connection options, and Section 5.3 lets that list be split
across several field lines. Comparing the whole field value against a single
option gets both wrong.
A client sending "Connection: keep-alive, close" was answered without a
Connection header and its socket was kept open, so the close it asked for was
never performed and never announced. An HTTP/1.0 client asking for keep-alive
only got it by spelling the option exactly "Keep-Alive"; the lowercase form
everyone actually sends closed the connection instead.
Route the five Connection checks through has_header_token(), which already
walks every field line and compares complete tokens. Expect is left alone:
matching "100-continue" as a token would make an unrecognized expectation
alongside it look acceptable, where Section 10.1.1 asks for 417.
It was defined as a static inline above the border line so that the split
build would not turn it into an exported symbol of the shared library, which
kept abidiff from reporting an added function. That put an internal helper's
location at the mercy of a CI check rather than of where it belongs: it reads
a header field the same way get_header_value() and get_combined_header_value()
do, and it is the closest sibling of the latter, both being about a list-valued
field spread over several field lines.
Define it as a plain inline beside them and forward-declare it with the
split() family it calls. Adding a symbol is a source and binary compatible
change, so let abidiff report it.
RFC 9110 Section 5.2 and 5.3 define the combined value of repeated field
lines as their values joined by commas in the order they were received.
Several call sites read only the first occurrence and then split that on
commas, so whatever the later field lines carried was silently dropped: an
acceptable media type or content coding, an ETag, a WebSocket subprotocol, a
declared trailer name, or an address a proxy appended as its own line rather
than by extending the one it received.
Add detail::get_combined_header_value() and use it for Accept,
Accept-Encoding, If-None-Match, Sec-WebSocket-Protocol, Trailer and
X-Forwarded-For. Empty field lines are skipped so the combined value never
starts with a bare comma, which parse_accept_header() rejects outright.
Also drop the now-dead manual trimming in parse_trailers() and replace the
istringstream-based subprotocol tokenizer with detail::split(); split()
already trims each token and skips empty ones.
RegexMatcher::match() called std::regex_match() directly on the
attacker-controlled request path. For quantified patterns such as "(.*)",
std::regex_match's recursive backtracking implementation (most acute on
libstdc++) recurses roughly once per matched character, so a long enough
path can exhaust the calling thread's stack and crash the process. Verified
against real GNU libstdc++: under the default thread stack size, a path of
a couple thousand characters against a simple quantified route pattern
reliably crashed the process, well within the existing 8192-byte request
URI limit.
Add CPPHTTPLIB_REGEX_ROUTE_PATH_MAX_LENGTH (default 256) and reject paths
longer than it before ever calling std::regex_match, treating them as a
non-match instead. Confirmed the fix eliminates the crash under the same
libstdc++ build and default stack size that reproduced it.
For a non-SSL request with neither Content-Length nor Transfer-Encoding,
Server::read_content_core() fell back to reading raw wire bytes with
detail::read_content_without_length() directly, bypassing the decompressor
wrapper that the length-framed and chunked paths already use. As a result,
payload_max_length only bounded the compressed bytes read off the socket,
not the decompressed size a handler could produce from them.
Route this fallback through detail::read_content(..., decompress=true)
instead, the same helper already used below for the length-framed and
chunked cases, so the decompressed-size guard applies uniformly.