Commit Graph
100 Commits
Author SHA1 Message Date
yhirose 567e099361 Keep a verify callback alive while a handshake runs it
Replacing a verify callback, as set_server_certificate_verifier() does,
deleted the one that a handshake on another thread could be running, on
OpenSSL and wolfSSL. Handshakes now share the callback and take their
reference under a lock, and SSLClient replaces it under the lock that
guards creating a session from its context.
2026-10-16 23:52:15 -04:00
yhirose c52881cb1f Keep client certificates required after setting a verify callback
tls::set_verify_callback() set the verify mode anew, so on a server that
required client certificates it made them optional: OpenSSL and wolfSSL
dropped SSL_VERIFY_FAIL_IF_NO_PEER_CERT, and Mbed TLS went from REQUIRED
to OPTIONAL. A client without a certificate got through. Keep what the
server requires.
2026-10-16 23:52:15 -04:00
yhirose bb13a3bc76 Rotate ticket keys of the size BoringSSL and LibreSSL take
update_server_client_ca() rotated the session ticket keys with 80 bytes,
which only OpenSSL takes. BoringSSL and LibreSSL take 48, refused them,
and the result was not checked, so a client went on resuming with a
ticket from before the client CA changed. Use the size each library
takes, and fail the update when the keys cannot be set.
2026-10-16 23:52:15 -04:00
yhirose 9a08b01ed7 Turn off session resumption on wolfSSL servers verifying clients
wolfSSL shares its session cache between contexts, so a session resumed
on a server that checks client certificates could carry a certificate
checked against another server's CA, or against a client CA replaced
since. Such a server no longer resumes sessions.
2026-10-16 21:38:40 -04:00
yhirose a7fce92ed0 Let a client resume its session with an OpenSSL mTLS server
Without a session id context, OpenSSL answers a client resuming its
session with a fatal alert once client certificates are verified, which
curl and browsers run into on their second connection. Set one on every
server context. A changed client CA still stops resumption, as fcf7568
flushes the cache and rotates the ticket keys.
2026-10-16 21:38:40 -04:00
yhirose 489fe8e504 Give a redirect client the CAs its client trusts
With set_ca_cert_store() after load_ca_cert_store(), the redirect client
trusted the earlier PEM too, though the store had replaced it, and of
several load_ca_cert_store() calls it kept only the last. Take the CAs
from the context when a store was set, and from every PEM loaded
otherwise.
2026-10-16 21:38:40 -04:00
yhirose 9724ea6b2e Skip the CN next to a DNS SAN on Mbed TLS and wolfSSL
Hostname verification on these backends fell back to the subject CN
even when the certificate had a DNS SAN, unlike X509_check_host and
RFC 6125 6.4.4. It does so only without one now. Mbed TLS also reads
the CN by its OID instead of searching the formatted DN for "CN=", and
no longer stops at an empty SAN.
2026-10-16 21:38:40 -04:00
yhirose bf08ec763c Sign the request-target sent in a Digest uri
The Digest uri and A2 took the raw path, while the request line carried
it encoded, with the params and without the fragment, so a server
comparing the two rejected the credentials. Build the target in one
place for both.
2026-10-16 21:38:40 -04:00
yhirose f29721c7d8 Treat 0 as no limit for the remaining limits
set_keep_alive_max_count(0) tripped an assert, a negative WebSocket ping
interval sent pings in a tight loop, and the form length, multipart file
count and range count macros rejected every request at 0. Make 0 mean
no limit for those, and a ping interval of 0 or less turn pings off. The
URI, header and line bounds cannot be lifted, so a static_assert keeps
them positive.
2026-10-16 21:38:40 -04:00
yhirose ad6c851d19 Bound an SSE stream by event only
An event stream has no end, but the client's payload_max_length, 100MB
by default, cut it and made the client reconnect. SSEClient lifts that
limit for its stream; each event stays bounded by
CPPHTTPLIB_SSE_MAX_EVENT_LENGTH.
2026-10-16 21:38:40 -04:00
yhirose 35342285a0 Serve files without mapping them on POSIX
Files have been read with pread() since 44658e6, so the mapping went
unused. Keep only the descriptor, open it close-on-exec and without
waiting on a FIFO, and accept only a regular file, so that a device is
no longer served as an empty body.
2026-10-16 21:38:40 -04:00
yhirose a10338a6ee Unescape quoted Content-Type parameters and escape backslashes
A quoted boundary kept its quoted-pairs, as only the quotes were taken
off, and the multipart writer sent a backslash as it was, so a name
ending in one escaped the closing quote for this library's own parser.
Unescape \" and \\ in media type parameters as in Content-Disposition,
and write a backslash as \\.
2026-10-16 21:38:40 -04:00
yhirose aa0a588ed5 Answer WebSocket control frames from one place
The read loop and the loop over the fragments of a message each answered
Ping, Pong and Close the same way. Both go through one handler now.
2026-10-16 21:38:40 -04:00
yhirose fb439bda2e Split authentication challenges with split_unquoted
split_challenge_segments() did what split_unquoted() does, trimming and
dropping empty segments included, so parse_www_authenticate() uses the
latter.
2026-10-16 21:38:40 -04:00
yhirose 079f2176af Put Params before the fragment of a client path
append_query_params() added the parameters after a '#', where the
fragment, which is not sent, took them along, and write_request() read a
query that came after one. Insert them before the fragment, and leave
the fragment out of the query.
2026-10-16 21:38:40 -04:00
yhirose f18f0b7576 Close the client connection after faulty response framing
A response framed by Transfer-Encoding next to a Content-Length, or an
HTTP/1.0 response with Transfer-Encoding, left the connection open for
the next request, though RFC 9112 6.1 has such framing treated as faulty
and the connection closed after it. Close it, with the check the server
makes for requests now shared.
2026-10-16 21:38:40 -04:00
yhirose 9a3c1febee Stop copying CA certificates on Mbed TLS
get_ca_certs() handed out heap copies of the context's CA certificates,
but free_cert() frees nothing on Mbed TLS, so every copy leaked. Hand
out the context's own certificates instead, as get_peer_certs() does
the session's, and say on the declaration that they last only as long
as the context's CA certificates stay as they are.

This showed up on every redirect from a client given a store with
set_ca_cert_store(), which passes the store on through these
certificates.
2026-10-16 19:29:38 -04:00
yhirose 43c9c6ddf3 Frame response bodies in one place
A content provider that wrote past the length it was given put the
extra bytes after the framed body, where a keep-alive peer read them as
the next message. Write only what fits and fail the write, on both the
server and the client.

Content-Length and Transfer-Encoding a handler set were sent next to
the ones the library worked out, for ranges and compression, and a
post-routing handler could change the body after Content-Length was
set. Work both out in one place, from the body about to be written,
again after the post-routing handler; a HEAD response keeps the length
its handler gave it. A 1xx, 204 or 304 response sends no body and,
following RFC 9110 8.6, no Content-Length.

The client still sends a Content-Length or Transfer-Encoding its caller
passes, as curl and urllib3 do, and the README says these have to match
the body.
2026-10-16 19:13:12 -04:00
yhirose bc837b87fc Bound the request head by the read timeout as a whole
The read timeout applied to each read of the request line and headers,
so a client that sent them a byte now and then held a worker for as
long as it liked, and enough such clients left no worker for anyone
else. Bound the whole head by the read timeout, as the TLS handshake
already is, through a new Stream::set_max_timeout() that the socket
streams implement with their existing max timeout.

A request body is still bounded per read, and a WebSocket holds its
worker for its whole life. The README says so, and points to a
reverse proxy and the pre-request handler for untrusted clients.
2026-10-16 19:13:03 -04:00
yhirose ae169c6253 Address review of the audit fixes
A retry or redirect after an early answer to Expect: 100-continue now
opens a new connection, interim responses are also skipped while
waiting for 100 Continue, and a URL Client cannot use, such as one with
userinfo, gives an invalid client. Only a 407 is passed on from a
refused CONNECT, WebSocket::close() sends only a valid payload, and the
SSE client stops on an oversized event rather than reconnect to it.

Media type parameter names and user extension mappings are matched
without case, file content is read in larger chunks from a buffer of
its own, the streaming decompression limit reports
ExceedMaxPayloadSize, and the tests use the existing single-response
server helpers. Comments and the README are brought in line.
2026-10-16 16:39:08 -04:00
yhirose ceedf6ac22 Fuzz the buffered path of the line reader
The server fuzzer's stream exposed no buffered data, so the fast path of
stream_line_reader::getline was never fuzzed. Hand out what a read
pulled in beyond the bytes asked for, as SocketStream does.
2026-10-16 16:39:00 -04:00
yhirose 66266b80c6 Document regex route, file handler and WebSocket Origin caveats 2026-10-16 16:39:00 -04:00
yhirose 44658e660b Read served files with pread
Over TLS a file is copied out of its mapping in this process, so one
truncated while it was being served raised SIGBUS and took the whole
server down. Read it with pread, which comes up short instead.
2026-10-16 16:39:00 -04:00
yhirose 3782a9f1cd Document what follows a cross-host redirect
Credentials and the client certificate stay behind; other headers,
default ones included, go along as RFC 9110 15.4 has it, and so do the
limits and the TLS settings.
2026-10-16 16:39:00 -04:00
yhirose f361a08f67 Add certificate and session verifiers to WebSocketClient
A wss client could set its CAs but had no way to pin a certificate. Add
set_server_certificate_verifier() and set_session_verifier(), as
SSLClient has.
2026-10-16 16:39:00 -04:00
yhirose 7c44a7a5e1 Compare extensions, header names and media types without case
INDEX.HTML got the default type, a REMOTE_ADDR spelled in lower case got
past the guard on prohibited header names, and Multipart/Form-Data was
not taken for multipart, though RFC 9110 makes field names and media
types case-insensitive and web servers match extensions so.
2026-10-16 16:39:00 -04:00
yhirose fcf7568230 Complete server certificate updates on every backend
OpenSSL allowed client-initiated renegotiation, which runs a handshake
update_certs_pem() does not wait for, and kept sessions and ticket keys
across a change of client CAs, so a client verified under the old ones
could resume. wolfSSL added new client CAs to the old instead of
replacing them, and a bad certificate or key left Mbed TLS and wolfSSL
half updated.

Turn renegotiation off, forget the sessions and ticket keys on a client
CA change, replace wolfSSL's CAs, and check new material before swapping
it in. Note that the update must not be called from a TLS callback.
2026-10-16 16:39:00 -04:00
yhirose 314e5577b8 Match certificate names as X509_check_host does
The Mbed TLS and wolfSSL backends matched *.com, a bare *, vic*.test and
names with empty labels, which OpenSSL rejects. Require two labels after
a wildcard, none in an IDNA label and no empty label, and ignore a
trailing dot on the host, so every backend accepts the same names.
2026-10-16 16:39:00 -04:00
yhirose 07c3e8c9e0 Set the client verify mode under the context lock
On Mbed TLS and wolfSSL the verification mode is a setting of the
context, and it was changed outside the lock that guards creating a
session from that context.
2026-10-16 16:39:00 -04:00
yhirose 0fe883da97 Document when an SSL client loads its CA certificates
They are loaded on the first connection, so later changes to the CA
path or the system CA setting have no effect.
2026-10-16 16:39:00 -04:00
yhirose 7ce6c6438a Validate a WebSocket Close before echoing it
Any Close payload was echoed back, a 1-byte one, a code that may not be
sent and a reason that is not UTF-8 included. Echo a valid one and
answer the others with 1002, from one place for both read loops.
2026-10-16 16:39:00 -04:00
yhirose 162b87f325 Fail a refused CONNECT instead of returning the proxy's answer
A non-200 answer to CONNECT was returned as the response, so a proxy, or
anyone on the plain leg to it, could answer an HTTPS request as if the
origin had. Fail the request with Error::ProxyConnection for anything
but a 4xx or 5xx refusal.
2026-10-16 16:39:00 -04:00
yhirose ce7ecb633b Skip every interim response, with its header fields
Only 100 Continue was skipped, and only without header fields, so a 103
Early Hints was taken for the final response and a 100 with fields threw
the parsing off. Skip every 1xx but 101, along with its header fields.
2026-10-16 16:39:00 -04:00
yhirose 5d10305218 Close the connection after an early answer to Expect: 100-continue
When the final response came before 100 Continue, the body was never
sent but the connection was kept, so the server could read the next
request as that body. Close it after the response.
2026-10-16 16:39:00 -04:00
yhirose aaccde2122 Reject userinfo and backslashes in a URL authority
parse_url took http://user@evil.com/ for the host "user@evil.com", where
parsers that honour userinfo, or read a backslash as '/', see evil.com.
userinfo is not supported, so reject both.
2026-10-16 16:39:00 -04:00
yhirose eeaae4bc3c Serve no file whose path cannot be resolved
When the real path of a file under a mount point could not be resolved,
the check that it stays inside the mount point was skipped and the file
was served. Do not serve it from there, and refuse a mount point whose
directory cannot be resolved.
2026-10-16 16:39:00 -04:00
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
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
yhirose c419c904da Release 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
yhirose 00afaaed6a Release v0.60.1 2026-10-07 23:40:36 -04:00
yhirose 17eeb18feb Release v0.60.0 2026-10-06 22:57:42 -04:00
yhirose 6d1049462d Ignore a subprotocol the WebSocket client did not offer
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.
2026-10-04 18:34:21 -04:00
yhirose 09fa6820fe Fix 303 redirect request bodies and Location path decoding (Fix #2606, #2607)
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.
2026-10-03 19:06:45 -04:00
yhirose 438319cfcb Fix WebSocket pings being sent early on spurious wakeups
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.
2026-10-03 17:47:30 -04:00
yhirose eda9a10bfe Fix SSE client not clearing Last-Event-ID on an empty id field (Fix #2611)
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.
2026-10-03 17:29:33 -04:00
yhirose cf3693cb5c Release v0.59.0 2026-10-02 22:06:39 -04:00
yhirose 4fd9ae8f42 Serve pipelined requests without waiting for the keep-alive timeout
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
2026-10-02 21:47:18 -04:00
yhirose 3d40dfc727 Fix SSE parsing of CRLF field names and data-less events
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.
2026-10-02 21:38:39 -04:00
yhirose 8a3abfb597 Extract parse_int_in_range from parse_port
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.
2026-10-02 21:15:14 -04:00
yhirose 639391ad7f Reject an invalid Content-Length and honor Connection: close
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.
2026-10-02 00:22:24 -04:00
yhirose c1c2b1f4b4 Apply the request-target check to the client and encode control chars
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).
2026-09-28 03:37:11 -04:00
yhirose e11dbec7b3 Reject control characters in the request-target
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.
2026-09-27 23:35:09 -04:00
yhirose 174bce5ccf Escape request data in the docker server's access and error logs
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.
2026-09-27 22:50:26 -04:00
yhirose 4f3f9ef19b Release v0.58.0 2026-09-21 20:01:43 -04:00
yhirose 6d59d1e2df Build open_stream's request head in memory before sending
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.
2026-09-21 19:15:39 -04:00
yhirose 9386b25dd7 Don't wait for a response to a request rejected before sending
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.
2026-09-21 19:15:30 -04:00
yhirose 91c55a4385 Release v0.57.1 2026-09-21 12:47:22 -04:00
yhirose ad88645a83 Reject non-token methods in write_request_line
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
2026-09-21 12:25:12 -04:00
yhirose 82722fcb13 Release v0.57.0 2026-09-20 20:38:01 -04:00
yhirose 52f214bf2e Don't wait for the peer's close_notify on OpenSSL shutdown
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.
2026-09-19 17:28:09 -04:00