Compare commits

...
110 Commits
Author SHA1 Message Date
yhirose 00afaaed6a Release v0.60.1 2026-10-07 23:40:36 -04:00
yhirose e803f5e413 Let the SSL chain policy alone judge the Windows chain (#2618)
verify_cert_with_windows_schannel() rejected a chain whenever
TrustStatus.dwErrorStatus was non-zero, before
CertVerifyCertificateChainPolicy() ran. The
CERT_CHAIN_POLICY_IGNORE_ALL_REV_UNKNOWN_FLAGS flag passed to that
policy check was therefore dead code: a certificate without revocation
information, or one whose CRL could not be fetched, failed with
CERT_TRUST_REVOCATION_STATUS_UNKNOWN.

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

On a rejected chain, ssl_backend_error() now holds the policy status,
such as CERT_E_UNTRUSTEDROOT, instead of the trust status bit mask.
2026-10-07 23:19:36 -04:00
Michael Weitzel 57b8aca6da Avoid name clash with class WebSocketClient by explicit qualification (#2616)
Adds an explicit namespace qualification and a forward declaration for
class WebSocketClient. This change avoids a name clash on code bases
containing unrelated WebSocketClient classes.
2026-10-07 22:38:50 -04:00
KBSandyoudie006 213029685a Serve the whole body for a suffix range longer than it (#2615)
RFC 9110 14.1.2: if the representation is shorter than the suffix-length,
the entire representation is used. range_error computed a negative first
byte position for bytes=-8 on a 7-byte body and answered 416. Clamp it
at 0, as #711 did before the range handling was reworked.

Co-authored-by: youdie006 <youdie006@users.noreply.github.com>
2026-10-07 22:20:53 -04:00
yhirose 17eeb18feb Release v0.60.0 2026-10-06 22:57:42 -04:00
metsw24-maxandyhirose edc9760005 use the SAN type tag in Mbed TLS verify_hostname and get_cert_sans (#2614)
* use the SAN type tag in Mbed TLS verify_hostname and get_cert_sans

Mbed TLS keeps a subjectAltName entry's GeneralName tag in buf.tag and the bare value in buf.p / buf.len. verify_hostname ignored the tag, so a dNSName whose bytes equal an address authenticated that IP host, and an iPAddress or rfc822Name was matched as a DNS pattern. get_cert_sans looked for the tag inside the value, so it reported no entries for an ordinary certificate, or part of a dNSName as an entry of its own.

* Shorten the SAN type comments

---------

Co-authored-by: yhirose <yuji.hirose.bug@gmail.com>
2026-10-05 21:13:50 -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
metsw24-maxandyhirose 4fcbc08f2d reject an unoffered subprotocol in read_websocket_upgrade_response (#2595)
* reject an unoffered subprotocol in the ws client handshake

* test the subprotocol check through WebSocketClient so the split build compiles

* Trim comments in the subprotocol check

---------

Co-authored-by: yhirose <yuji.hirose.bug@gmail.com>
2026-10-04 18:21:39 -04:00
Jordan Woyak d59f3e438c Remove semantically redundant closed_ checks in ping thread. (#2613) 2026-10-04 09:39:24 -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
metsw24-max dd71728110 escape quoted-string auth-params in make_digest_authentication_header (#2597) 2026-10-02 21:29:35 -04:00
DosX 7255a7e979 Preserve empty SSE data fields (#2594)
SSE events may contain an empty data field, and a data field without a colon also has an empty value. Track whether a data field was seen separately from the accumulated payload so empty events are dispatched and leading empty lines are preserved. Add an integration regression test for both forms.
2026-10-02 21:17:47 -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
KBS 10aadd57f7 Reject trailing characters in URL port numbers (#2593)
parse_port checked only the error code of from_chars, which stops at
the first non-digit, so http://host:80abc was accepted as port 80, and
a redirect Location with such a port was followed. RFC 3986 defines
port as *DIGIT. Require the whole string to be consumed, as #2590 does
for quality values.
2026-10-02 21:07:34 -04:00
yhirose 43863e1f67 Make CryptoAPI the only chain verifier when Windows verification is on (#2604)
Every backend loads the Windows ROOT and CA stores, but Windows adds a
root to them only when CryptoAPI needs it to build a chain. On a machine
that had not needed a root yet, the backend rejected the chain before
the CryptoAPI check ran, so Windows never fetched the root (for example
OpenSSL error 20 for accounts.spotify.com under Starfield Root G2).

With Windows verification enabled, the backend's chain verdict is no
longer used. Mbed TLS and wolfSSL skip chain verification during the
handshake, and the post-handshake verify result is ignored. The
CryptoAPI check becomes mandatory: a leaf that cannot be encoded now
fails the connection instead of skipping the check, and the chain must
allow server authentication, which the backends used to check.

A server certificate verifier works on the backend's chain verification,
so when one is set the backend still decides and CryptoAPI only adds its
own check, as before.

ClientCertMissing now disables hostname verification: cert.pem does not
match HOST, and on Windows the hostname check fails before the chain
check.

Refs #2596
2026-10-02 20:48:47 -04:00
yhirose 0db1df7cf2 Pass the server's intermediates to Windows certificate verification (#2602)
CryptoAPI got only the leaf, so it fetched an issuer from the leaf's AIA
URL instead of using the intermediates the server sent. For
accounts.spotify.com that issuer chains to Certainly Root R1, which
Windows does not trust, while the server's own chain ends at Starfield
Root G2.

Add tls::get_peer_certs(), which returns the certificates the peer sent
in the same way get_ca_certs() returns the CA certificates, for every
backend. The CryptoAPI check puts them into a memory store that it
passes to CertGetCertificateChain(), skipping any certificate that
cannot be added. wolfSSL keeps the received chain only when built with
SESSION_CERTS; without it, CryptoAPI still gets the leaf alone.

Refs #2596
2026-10-02 20:48:10 -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 0715c2739e Enforce a minimum SSE reconnect wait to avoid a busy loop (#2592)
SSEClient::wait_for_reconnect() sleeps in 100ms steps until the
reconnect interval has elapsed. With an interval of 0 (for example
"retry: 0" from the server, or set_reconnect_interval(0)) it never
slept at all, so a server that sends "retry: 0" and closes the stream
made the client reconnect in a tight loop. set_max_reconnect_attempts()
does not stop this either, because each successful connection resets
the attempt counter.

Always wait at least one step (100ms). Intervals of 1-99ms already
waited 100ms because of the step size, so only 0 and negative values
change behavior.
2026-09-28 17:25:37 -04:00
KBS 3330d0eb06 Ignore an SSE retry field that is not all digits (#2591)
parse_sse_line checked only the error code of from_chars, which accepts
a leading '-' and stops at the first non-digit, so retry: -1 made the
client reconnect without waiting and retry: 10s set 10 ms. The SSE spec
ignores a retry value that is not all ASCII digits.
2026-09-28 15:31:20 -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
DosX 57c4f7f385 Reject trailing characters in HTTP quality values (#2590)
parse_quality accepted values such as q=0.5junk because it checked only the conversion error and ignored the returned end pointer. Require the numeric parser to consume the complete q parameter so malformed Accept values are rejected and invalid Accept-Encoding weights are ignored. Add regression cases for both headers.
2026-09-27 19:20:14 -04:00
yhirose 2fb2dbbe1e Send small static files with the headers and large bodies without a copy (#2589)
A file served from a mount point or through set_file_content() left in two
writes, one for the status line and headers and one for the body, because the
body came from a content provider. A small file is now read into the header
buffer so the whole response leaves in a single write. Only file-backed
providers are coalesced this way: a user-supplied provider may produce its data
over time, and holding the headers back until it finishes would stall the
client.

A large set_content() body was copied into the header buffer before being
sent. A body of CPPHTTPLIB_SEND_BUFSIZ or more is now written directly after
the headers, which saves the copy at the cost of one extra write.
2026-09-26 22:19:15 -04:00
VecSzn 8b6ab24159 Resolve relative Location references on redirect (#2586) 2026-09-26 19:49:21 -04:00
Tobias WallnerandTobias Wallner 5a202d3d5f Added a feature test to auto enable/disable CPPHTTPLIB_USE_NON_BLOCKI… (#2578)
* Added a feature test to auto enable/disable CPPHTTPLIB_USE_NON_BLOCKING_GETADDRINFO

* turned status: WARNING 'GetAddrInfoExCancel is unavailable; disabling non-blocking getaddrinfo.' into a warning

* added ws2_32 for the GetAddrInfoExCancel. this catches previous false negatives

---------

Co-authored-by: Tobias Wallner <tobias.wallner@qtlabs.at>
2026-09-25 15:20:00 -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
metsw24-maxandyhirose 8b872605e0 reject control characters in chunk extensions in read_payload (#2585)
* reject control characters in chunk extensions in read_payload

* Bound every chunk-size line scan by the line terminator

read_payload() ended its scans of one line buffer two different ways: the
hex-size parse and the space skip that follows stopped on the NUL that
stream_line_reader::append() writes, while the new chunk-ext check walked
to an explicit end pointer. Compute that end pointer first and bound all
of them by it, so no scan depends on the buffer's NUL and the terminator
can never be read as line content.

The bare-LF branch is reachable only under
CPPHTTPLIB_ALLOW_LF_AS_LINE_TERMINATOR, where getline() ends the line on
an LF that is the terminator rather than extension text. Say so: the
comment below it explains why a bare LF inside the line is rejected, and
without that note the two read as contradictory. Its guard no longer
depends on the scan cursor either, since all it ever needed was a check
that there is a byte to look at.

* Reuse the chunked-body helper in the chunk-ext acceptance test

AcceptsChunkExtension repeated expect_chunked_body_rejected()'s body
verbatim apart from the expected status, so parameterise the helper on
the status and keep the rejection wrapper for the existing callers. The
decoded body is already checked by the /chunked handler, so asserting
the status is all the new test needs.

Also record why the control-character literal stays split: a hex escape
consumes every hex digit that follows it, so "\x01b" would be the single
byte \x1b rather than \x01 followed by 'b', and joining the halves would
quietly change what the test sends.

---------

Co-authored-by: yhirose <yuji.hirose.bug@gmail.com>
2026-09-20 20:22:52 -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
yhirose 09c02f1335 Send 100 Continue only when the request body is read
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.
2026-09-19 17:28:09 -04:00
yhirose 2e5480ad65 Document that pre_request_handler runs for WebSocket routes 2026-09-19 17:28:09 -04:00
yhirose 4cb363e3f2 Run pre_request_handler for WebSocket routes
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.
2026-09-19 17:28:09 -04:00
yhirose deb520e26b Update version files with sed -i.bak, which GNU sed accepts too
`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.
2026-09-19 13:56:31 -04:00
yhirose f37a5b1407 Fix #2583 2026-09-14 17:31:07 -04:00
yhirose f15992c7ed Update documentation 2026-09-14 12:27:52 -04:00
yhirose 278c2979e8 Release v0.56.0 2026-09-11 17:35:54 -04:00
yhirose 6b8c3f5387 Report only a caller-set WebSocket read timeout as Timeout
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
2026-09-11 17:29:20 -04:00
yhirose e640f8376b Release v0.55.0 2026-09-11 16:54:08 -04:00
yhirose 73a4092f8a Point the remaining httpbingo.org proxy tests at the self-hosted httpbin
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
2026-09-11 16:35:11 -04:00
yhirose 3517f92e2f ci: remove the temporary windows-without-SSL flaky failure reporter
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
2026-09-11 16:16:25 -04:00
metsw24-maxandyhirose 0480ff77b8 reject ambiguously framed responses in client read paths (#2581)
* reject ambiguously framed responses in client read paths

* Accept non-chunked Transfer-Encoding responses in the client framing guard

RFC 9112 §6.3 treats requests and responses differently when the final
transfer coding is not chunked: a request's body length cannot be
determined and the server must answer 400, but a response's body simply
runs until the server closes the connection. read_content() and the
open_stream() body reader already do that, so such a response is not
ambiguous and rejecting it broke valid responses such as
"Transfer-Encoding: gzip" followed by a close.

Keep rejecting a Transfer-Encoding paired with a non-zero Content-Length,
which is the actual ambiguity, and drop the non-chunked clause from both
client read paths.

Tests: check that rejection surfaces as Error::Read, that a non-chunked
Transfer-Encoding response is read until close on both paths, and that
HEAD, 204 and 304 responses with both framing headers are not rejected.

Claude-Session: https://claude.ai/code/session_01JYPWKpbp4a881EdpEf2xSi

* Share the framing check and reuse existing test helpers

Factor "Transfer-Encoding with a non-zero Content-Length" into
detail::has_conflicting_content_length() next to
is_chunked_transfer_encoding(), and call it from the server request
guard and both client read paths so the rule and its RFC 9112 §6.3
rationale live in one place.

In the tests, drop the POSIX-only raw socket helper in favour of the
existing serve_single_response() and read_all(), which also lets the
tests run on Windows. Fold the stream-only test into the buffered one so
each case checks both Get() and open_stream(), and cover the HEAD/204/304
exclusion on the open_stream() path too.

Claude-Session: https://claude.ai/code/session_01JYPWKpbp4a881EdpEf2xSi

---------

Co-authored-by: yhirose <yuji.hirose.bug@gmail.com>
2026-09-11 15:55:28 -04:00
KBSandyhirose 8d25b6a3ac Reject a Range first-byte-pos that overflows ssize_t (#2580)
* Reject a Range first-byte-pos that overflows ssize_t

parse_range_header initializes first to the -1 sentinel that means "no
first-byte-pos" and only overwrites it when detail::from_chars succeeds.
On std::errc::result_out_of_range the assignment is skipped and -1
survives, so "bytes=9223372036854775808-100" is parsed as the suffix
range "bytes=-100" and range_error serves the last 100 bytes instead of
returning 416.

Before the parser was rewritten onto detail::from_chars, std::stoll threw
std::out_of_range on the same input, the catch arm added in 8f8761e for
issue #705 returned false, and the request was answered with 416. The
catch arm is still there but from_chars reports through an error code, so
nothing reaches it any more.

get_header_value_u64 and parse_port already reject an out-of-range value
at their from_chars call sites; this was the remaining one that dropped
the error.

The last-byte-pos side is deliberately unchanged: -1 there is the
documented RFC 9110 14.1.2 "remainder of the representation" value, so an
oversized last-byte-pos stays accepted.

* Simplify the Range first-byte-pos overflow check

Parse the first-byte-pos straight into first, since a failed parse now
returns before first is read, and fold the overflow test into the
existing batch of rejected ranges. Also note on the last-byte-pos side
why an overflow there deliberately keeps -1.

Claude-Session: https://claude.ai/code/session_01JYPWKpbp4a881EdpEf2xSi

---------

Co-authored-by: yhirose <yuji.hirose.bug@gmail.com>
2026-09-11 13:48:04 -04:00
metsw24-maxandyhirose 515b8f84af send each credential only to its own hop in write_request (#2579)
* send each credential only to its own hop in write_request

An SSLClient behind a proxy sent Proxy-Authorization inside the TLS tunnel, where the origin reads it, and sent the origin's Authorization on the CONNECT request the proxy reads. Attach each only on the message its hop actually reads.

* Keep default headers off the CONNECT request

set_default_headers() is typically used for origin credentials such as
Authorization, Cookie or API keys, but they were also attached to the
CONNECT request an SSLClient sends to its proxy, in plaintext before the
TLS tunnel exists. Default headers now go only on requests the origin
reads, the same split the previous commit makes for set_basic_auth and
set_bearer_token_auth.

Claude-Session: https://claude.ai/code/session_01JYPWKpbp4a881EdpEf2xSi

* Simplify per-hop credential handling and its tests

Flatten the Authorization insertion in write_request into one guard with
an else-if (Basic already took precedence over Bearer), and shorten the
comments around it. Fold DefaultHeadersStayOffConnect into the
CredentialsStayWithTheirHop helper, which now takes the list of headers
that must reach only the origin.

Claude-Session: https://claude.ai/code/session_01JYPWKpbp4a881EdpEf2xSi

---------

Co-authored-by: yhirose <yuji.hirose.bug@gmail.com>
2026-09-11 13:25:48 -04:00
yhirose 88956ccad8 Self-host the httpbin auth-testing backend for BaseAuthTest/DigestAuthTest
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.
2026-09-07 21:49:00 -04:00
yhirose f83d06538b Track Homebrew's clang-format version instead of a fixed pin
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.
2026-09-07 20:15:31 -04:00
yhirose dc41dbd954 Pin style-check and pre-commit to clang-format 23.1.0
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.
2026-09-07 17:18:45 -04:00
yhirose 6303a99ce1 Run clang-format on httplib.h and test.cc
Fixes formatting introduced in 199d7ee and d5f8858 that clang-format
18.1.3 (the version used by CI) disagrees with.
2026-09-07 16:29:08 -04:00
yhirose d5f8858731 Move ws::WebSocket's chrono set_read_timeout into the header half
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.
2026-09-03 15:00:02 -04:00
yhirose 199d7ee248 Tell a WebSocket read timeout apart from a closed connection
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.
2026-09-03 14:32:13 -04:00
Avionic Harshit 9e2e33da56 let EXTRA_CXXFLAGS override -fsanitize=address (#2577) 2026-09-03 10:22:00 -04:00
Jhen-Jie Hongandyhirose 7d53a31d23 Don't compress a response whose handler already set Content-Encoding (#2575)
* Don't compress a response whose handler already set Content-Encoding

* Don't compress a pre-encoded response served from a file

The guard that stands down when a response already names a content coding
covered the responses that settle their coding in `apply_ranges()`, but a
file-backed one settles it in `static_file_encoding()`, which asked the
content-type overload and so never saw the field. With static file
compression enabled, a mount point naming the coding for a tree of
build-time compressed assets, and a handler setting the field on a
`set_file_content()` response, both had their stored bytes compressed a
second time and a second `Content-Encoding` field line appended.

A file-backed response has not been given a content type by the time its
coding is decided, which is the only reason it could not go through
`encoding_type()`. It takes the type as an argument now, so both paths share
the one guard instead of carrying a copy each.

`Response::content_encoding_` becomes `content_coding_`, after what it
holds. It names the coding chosen for the body, which is what its own
comment already called it, while the old name read as the value of the
`Content-Encoding` field whose presence is exactly what forces the coding to
`None`.

README gains the behaviour, including the part that stays with the handler:
`Vary` is added only to a coding the server chose, so a handler that picks a
representation from `Accept-Encoding` has to add the field itself.

---------

Co-authored-by: yhirose <yuji.hirose.bug@gmail.com>
2026-08-31 20:44:12 -04:00
yhirose 9d6a7ee2c1 Release v0.54.1 2026-08-29 21:20:55 -04:00
yhirose 9ce15a14e5 Reject Digest challenges missing realm or nonce (RFC 7616 §3.3)
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.
2026-08-28 23:36:40 -04:00
yhirose 9185fdb6ae Release v0.54.0 2026-08-28 19:16:24 -04:00
yhirose 644cda7032 Update .gitignore 2026-08-28 18:40:55 -04:00
yhirose 2adfc45838 Report a flaky failure only when the test step is what failed
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().
2026-08-28 18:39:53 -04:00
yhirose c7db3da982 Document that the multipart part-count cap only applies to the buffered form path
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.
2026-08-28 08:26:08 -04:00
yhirose 139f30e0f1 Compress static file responses behind an opt-in (Fix #2545) (#2572)
* Drop the claim that small bodies skip compression

There is no size threshold anywhere in the compression path.
encoding_type() gates on the content type and Accept-Encoding only, and
apply_ranges() compresses whatever body it is given, so a two-byte
text/plain response comes back gzipped at 22 bytes.

Say what actually happens and leave the decision to the handler.

* Compress static file responses behind an opt-in (Fix #2545)

apply_ranges() runs the compressor inside the branch it takes when
res.body is non-empty. A response served from a file leaves res.body
empty and sets content_length_, so it took the other branch, which
writes Content-Length and returns; encoding_type() was computed before
the split and never consulted on that side. The same bytes handed to
set_content() came back gzipped, which left set_mount_point() and
Response::set_file_content() as the one path that missed out.

Add Server::set_static_file_compression(), off by default so nothing
about an existing server changes. When it is on, the file-backed
provider is run through the compressor into res.body ahead of the rest
of apply_ranges(), so the response is framed the way set_content()
already frames one: it keeps its Content-Length, and HEAD still reports
the size a GET would return.

Ranges are answered from the identity representation, since RFC 9110
applies Range after content coding and slicing a compressed body would
mean compressing the whole file first. The ETag carries the coding it
belongs to, so a client that cached the compressed form revalidates
against its own validator rather than the identity one. Both the ETag
and the body take their coding from static_file_encoding(), so the two
cannot disagree.

Providers registered with set_content_provider() are left alone. zlib
buffers until its window fills, so running one through a compressor
would hold back writes that a caller expects to reach the peer as they
are produced.

The compressed bytes stay in memory until the response has been
written, so the peak cost scales with requests in flight.
set_static_file_compression_max_length() bounds it, defaulting to 4MB.

* Add a minimum size for static file compression

Compressing a file that already fits in a single 1500-byte MTU does not
get it to the client any sooner, and a file of a few bytes comes back
larger than it went in once gzip's header and trailer are added. Every
other server draws this line: nginx's gzip_min_length, Caddy's
minimum_length, IIS's minFileSizeForComp, CloudFront's 1000-byte floor.

The note this replaces told callers to decide in the handler. A response
served through set_mount_point() has no handler to decide in, so the
floor has to live in the server. It defaults to 1400 bytes, the size
that fits inside one MTU with room for headers.

set_static_file_compression_min_length() moves it, and
CPPHTTPLIB_STATIC_FILE_COMPRESSION_MIN_LENGTH sets the default at
compile time. The empty-file case keeps its own early-out so that a zero
floor still cannot turn an empty body into a 20-byte gzip stream.

The two bounds now read as a pair, so the documentation says what each
one is for: the lower bound is about what is worth compressing, the
upper bound about what one request is allowed to cost.

Every file under test/www except 1MB.txt is below the default floor, so
the tests that need a small file compressed lower it explicitly.
2026-08-27 17:19:43 -04:00
yhirose b4ec1bb1de Respect quoted-strings when splitting header parameters (Fix #2568) (#2573)
parse_disposition_params() and extract_media_type() both split on every
';' and then on every '=', with no idea that a parameter value can be a
quoted-string. RFC 9110 5.6.6 allows ';' and '=' inside one, so
filename="report=v2.pdf" came out as v2.pdf", and filename="a;b.txt" was
truncated at the semicolon and left a bogus parameter behind.

The same defect reached the boundary. RFC 2046 5.1.1 allows '=' in a
boundary, which forces a sender to quote it, so the common MIME form
boundary="----=_NextPart_000_0000_01D9" parsed as
_NextPart_000_0000_01D9".

Add split_unquoted(), which is split() with the one extra rule that a
delimiter inside a quoted-string is not a delimiter, and route both
parameter parsers through it. The key/value split, duplicated verbatim
in the two of them, moves into divide_param_pair(). That one divides at
the first '=' without tracking quotes: 5.6.6 makes the key a token, so
no quote can precede the separator, and reusing divide() keeps this off
the per-byte scan.

A backslash stays an ordinary character here. Both browsers and
httplib's own sender percent-encode '"' rather than escaping it, and
recognizing a quoted-pair without also unescaping it would just trade
one wrong value for another.
2026-08-27 17:18:29 -04:00
yhirose c58061ea81 Fail a content provider that makes no progress
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.
2026-08-26 23:05:19 -04:00
yhirose f2b338ae2e Drop the empty Accept entry from the example's invalid list
e96a52e made a leading comma legal, so the example printed
"Unexpectedly succeeded!" for ",application/json". Reported in #2570.
2026-08-26 22:54:09 -04:00
yhirose efe709c45f Format example/accept_header.cc
The file predates the clang-format hook, so any edit to it drags the
whole file into the diff. Reformat it on its own first.
2026-08-26 22:54:09 -04:00
Robert Miller 794a997d8c Fail make_file_body()'s provider when the file is short (#2566)
make_file_body() measures the file once and that length is already the
response's Content-Length. The provider re-opens the file by path on each
call, so if the file has been truncated since, the read comes up empty and
the provider returned true without writing. write_content_with_progress()
advances its offset only by what was written, so it called the provider
again, got nothing again, and kept spinning until the peer gave up.

Return false instead, as every other failure in this provider does.
2026-08-26 22:44:04 -04:00
yhirose e96a52e9dd Ignore empty list elements in the Accept header (Fix #2567)
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.
2026-08-26 22:37:22 -04:00
yhirose 2addb41089 Stop a throwing user callback from terminating the server (#2564)
Server::process_request() wraps only routing() in a try/catch.
Everything else the user supplies runs outside it:

- the content provider, from write_response_core()
- post_routing_handler_, error_handler_, logger_
- expect_100_continue_handler_
- a WebSocket handler, and pre_routing_handler_ on the upgrade path

An exception from any of those unwinds out of process_and_close_socket()
into the task queue, which calls the job without a catch, so it reaches
the top of a pool thread and terminates the process. One handler that
throws takes down every other connection the server is holding.

Add Server::serve_guarded() and run the serving loop through it in both
process_and_close_socket() overloads. The exception is not turned into a
500: by the time a content provider runs, the status line and headers
are already on the wire, so there is nothing left to replace. Report it
through the error logger as Error::UserCallbackException and drop the
connection, which is what the peer observes regardless. Requests on
other connections are unaffected, and the socket is still drained and
closed - which unwinding used to skip on the non-SSL path, since
drain_and_close_socket() sits after the call rather than in a scope
guard.

The error logger is a user callback too, so the report inside the guard
is itself wrapped: a throwing logger must not be able to open the guard
back up.

Adds ServerExceptionTest: a throwing content provider, post-routing
handler, WebSocket handler and error logger, plus the content provider
case against SSLServer, each checking that a later request on a new
connection still succeeds. Every test runs the server on a single worker
thread, so a guard that catches the exception but still loses the thread
shows up as the follow-up request never being served. Note that all of
them abort the test binary without this change - which is the bug, but
it means a regression here fails the run rather than one test.
2026-08-26 01:16:48 -04:00
yhirose ae417b405a Do not let a zero-length write end a chunked body (#2563)
write_content_chunked()'s sink treated "the provider wrote nothing" as
"the provider has finished":

    data_available = l > 0;

so sink.write(p, 0) ended the loop. Only done()/done_with_trailer()
emit the terminating zero-length chunk, so the body was left
unterminated - and the function still returned Success, because the
post-loop check only reports the is_shutting_down() case. The peer waits
for a last chunk that never arrives, and on a keep-alive connection
anything written next is parsed as a chunk-size line.

A provider reaching a pass with nothing to hand over is ordinary:
popping an empty buffer off a queue, or a compressor that has consumed
its input without producing output yet. It is not the end of the
message.

Ignore zero-length writes instead. A zero-length chunk is the terminator
in chunked coding, so it must never be emitted mid-body either way, and
data_available is now controlled only by done()/done_with_trailer().
This matches write_content_without_length(), where the sink's write
never ends the body.

The old behaviour cannot have been relied on: it produced an
unterminated response, so a provider using it never worked in the first
place.
2026-08-26 01:16:38 -04:00
yhirose bc7e51dbb9 Give DataSink's optional callbacks safe defaults (#2562)
DataSink has four callbacks, but only write is assigned by every writer
that hands a sink to a content provider:

  write_content_with_progress()    write, is_writable
  write_content_without_length()   write, is_writable, done
  write_content_chunked()          all four
  send_with_content_provider...()  write
  get_multipart_content_provider() write, done  (cur_sink)

A provider that calls one of the unassigned ones invokes an empty
std::function and throws std::bad_function_call. Nothing on that path
catches it, so it unwinds out of the thread running the provider and
terminates the process. The README's own idiom is enough to hit it:
sink.done() is documented for the without-length overload, but a
provider registered through set_content_provider() with a length gets a
sink where done is empty.

Default the three optional callbacks instead. A sink is writable unless
a writer says otherwise, and a sink that cannot carry trailers still has
to finish, so done_with_trailer() falls back to done(). Capturing this
for that is safe because DataSink is neither copyable nor movable.

A no-op done() alone would only trade the crash for a hang on the two
length-framed paths: both loop until offset reaches the promised length,
so a provider that reports itself done without writing would be called
again immediately, forever. Both now record that the provider finished
and stop, and the short body is reported as a write error. The client
path gains that check for the compressor-failure exit as well, which
used to send a truncated request body without reporting anything.

cur_sink in get_multipart_content_provider() now forwards is_writable
from the outer sink, so a provider item asking whether it may keep going
gets the stream's answer rather than the default.
2026-08-26 01:16:17 -04:00
yhirose f9c205632d Fix accept() error handling on Windows (#2561)
The accept loop in Server::listen_internal() classified accept() failures
by reading errno, but Winsock reports them through WSAGetLastError() and
never touches the CRT errno. Both retry branches were therefore dead code
on Windows, and every accept() failure fell through to the fatal path,
which closes the listening socket and ends listen().

That is reachable in normal operation: a peer resetting a pending
connection before it is accepted is enough, and descriptor or buffer
exhaustion shows up under load. One such event stopped the server from
accepting anything again.

Add is_accept_resource_error() and is_accept_transient_error() next to
is_connection_error(), which already abstracts the same errno vs
WSAGetLastError() difference, and use them in the accept loop.

The POSIX sets are widened to match the Windows ones rather than being
left as they were: ECONNABORTED is the POSIX spelling of the aborted
pending connection that motivates this, and ENFILE, ENOBUFS and ENOMEM
are resource exhaustion in the same sense as EMFILE.
2026-08-26 01:16:01 -04:00
yhirose 84f75185fe Clear svr_sock_ before closing it on the accept loop's fatal path (#2560)
When accept() failed for a reason the retry branches do not cover, the
loop closed svr_sock_ but left the descriptor in the atomic. Two things
go wrong from there:

- A later stop() reads the stale value and calls shutdown()/close() on
  it. By then the OS may have reused the descriptor for an unrelated
  socket (a worker's keep-alive connection, or one the application
  opened), and that connection is torn down instead.
- keep_alive() in the worker threads watches svr_sock_ to notice that
  the server is going away, so the workers keep waiting on a listening
  socket that no longer exists.

Take the descriptor with exchange(INVALID_SOCKET) before closing it,
which is what stop() already does. That also settles the race with a
concurrent stop(): whichever side takes the descriptor closes it exactly
once, and the other sees INVALID_SOCKET and does nothing.
2026-08-26 01:15:47 -04:00
yhirose 19352ae929 Cap the received multipart boundary at RFC 2046's 70 characters (#2565)
parse_multipart_boundary only rejected an empty boundary, so a request could
declare one as long as a header line is allowed to be. A stock server accepts
up to 8146 bytes there, which is what CPPHTTPLIB_HEADER_MAX_LENGTH leaves after
"Content-Type: multipart/form-data; boundary=".

FormDataParser searches the body for "--" + boundary + CRLF with a plain
substring scan. buf_find scans for that delimiter's first byte, always '-', and
at every position that matches calls start_with, which compares until the first
mismatch. A body of '-' makes every position a candidate, and a boundary of '-'
makes each candidate compare the whole delimiter before failing at the CRLF. The
worst case is the product of the body length and the boundary length, and only
the first factor was bounded.

Measured by driving the parser directly in 16 KB reads, Apple clang 17 at
-O2 -DNDEBUG, best of three runs on an otherwise idle machine. 100 MB of '-',
the default payload limit, costs 2.59 s of CPU with a 70 byte boundary and
281.83 s with an 8147 byte one, a factor of 109. The same shape shows at 8 MB:
0.211 s, 3.081 s, 11.359 s and 22.091 s for boundaries of 70, 1024, 4096 and
8147 bytes.

RFC 2046 5.1.1 caps a boundary at 70 characters, so honoring that limit bounds
the multiplier too. The limit applies to the value after unquoting, so a quoted
70 character boundary stays valid. Only the server receive path parses a
boundary out of a Content-Type, so what clients may send is unaffected, and the
boundaries the library generates itself are 45 characters.
2026-08-26 01:15:22 -04:00
yhirose 2afe933103 Send the Connection: close header the multipart test comment describes
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.
2026-08-26 01:06:21 -04:00
yhirose bc58e6e9ac Bound the multipart parser's buffer while it waits for a boundary (#2557)
* Bound the multipart parser's buffer while it waits for a boundary

FormDataParser accumulated the entire request body whenever the declared
boundary never appeared in it. State 0 returned without erasing anything, so
the buffer grew to the full payload (100 MB by default) and buf_find rescanned
all of it on every 16 KB read. The cost grew with the square of the body size:
50 MB of '-' took 198 s of CPU on one core, and the buffer pinned the body in
memory for the whole request. One unauthenticated request was enough, and the
parser runs for any multipart request even when the handler never looks at the
parsed result.

State 0 now keeps only the last dash_boundary_crlf_.size() - 1 bytes while it
waits, which bounds both the memory and the rescan without capping how long a
preamble may be. The same 50 MB body now takes 0.14 s and the buffer stays at
one read plus the boundary. A boundary split across reads still parses, which
is what de5a255 (#2159) gave up this erase for.

State 4 buffered without bound in the same way when a boundary was followed by
neither CRLF nor "--". No further data can make such a body valid, so it now
fails right away. That is only safe because the close-delimiter branch moves to
a new state 5 that discards the epilogue: it used to stay in state 4, so an
epilogue arriving in a later read fell into this same branch. An epilogue
beginning with CRLF was then parsed as a new part and the request was rejected
with 400, which state 5 fixes as well.

Affected since v0.23.0, where de5a255 replaced the erase that had kept the
buffer in check.

* Skip buffering the multipart epilogue

Once the close delimiter has been parsed the parser is in state 5 and discards
whatever follows, but it still copied each epilogue read into the buffer before
erasing it. Return before buffering so a large epilogue spread across several
reads is dropped without being copied in at all.

* Clean up the multipart parser tests and the state 4 branch

Review follow-ups on top of the previous two commits, no behavior change.

- Move the four new tests next to the rest of MultipartFormDataTest. They
  had landed in the middle of the RedirectTest block.
- Use bind_to_any_port instead of the fixed PORT, as AGENTS.md requires for
  newly added servers. NoInitialBoundaryParsingIsNotQuadratic holds its port
  for a couple of seconds, which matters when the suite is run sharded.
- Send "Connection: close" from expect_split_multipart_ok. The server kept
  the connection alive after answering, so the response drain idled until the
  client read timeout; both tests drop from about 3s to about 0.11s.
- Drop the dead `dash_.size() > buf_size()` guard in state 4 and flatten the
  nested else. The check above it already guarantees two buffered bytes, and
  both CRLF and "--" are two bytes, so it can never fire. Removing it is what
  makes the new comment's claim readable straight off the code.

* Rename the timing test's locals to avoid a Windows macro

MSVC's <rpcndr.h>, pulled in by <windows.h>, defines `small` as `char`, so
`auto small = ...` failed to compile on the Windows jobs. Same class of
problem as the std::min / std::max collision.
2026-08-25 22:55:52 -04:00
yhirose 254e576b50 Add Server::CustomRoute() for HTTP methods outside the built-in set (#2553)
* Add Server::CustomRoute() for HTTP methods outside the built-in set

parse_request_line validates the request method against a fixed whitelist and
rejects anything else with 400 before routing runs. That blocks WebDAV, where
PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK and UNLOCK are ordinary methods
defined by RFC 4918, and it blocks extension methods such as UPnP's SUBSCRIBE.
The need has been open since #847.

Registering a handler is now what makes the server accept a method:

    svr.CustomRoute("PROPFIND", "/dav/:id", handler);

Because custom methods go through the normal dispatch path, patterns work the
way they do for Get() and friends, and the request body is available in
req.body. Serving these methods through set_pre_routing_handler was never
enough: the body has not been read at that point, so PROPPATCH and LOCK, which
require one, could not be implemented at all.

A HandlerWithContentReader overload is available too. The content reader gate
in routing() also fires when a custom method carries no body, matching what
expect_content() does unconditionally for POST/PUT/PATCH/DELETE, so a body-less
PROPFIND (RFC 4918 treats one as allprop) reaches its handler instead of
falling through to 404.

Method names are validated as RFC 9110 tokens, and the ten built-in methods are
refused. Seven of them are dispatched by the if/else chain in routing() before
the custom tables are consulted, so a route registered for one could never
fire; CONNECT, TRACE and PRI carry protocol-level meaning this library does not
route. A refused registration makes is_valid() return false, so listen() fails
rather than starting a server holding a handler that would never run. This is
also why SSLServer::is_valid() now chains to Server::is_valid() instead of only
checking ctx_.

Servers that never call CustomRoute() keep the previous per-request cost: the
built-in method set is checked first and short-circuits, and the custom lookup
returns early on an empty map.

* Add cookbook recipe for custom HTTP methods

The CustomRoute() docs were a section inside S01, which pushed that page to 90
lines, the longest in the cookbook, and mixed a separate feature into a page
about registering GET/POST/PUT/DELETE handlers. Move the section into its own
recipe and give it room for the part that was missing: the OPTIONS handler
returning DAV: and Allow, which WebDAV clients probe for before anything else.
S01 goes back to 68 lines and keeps a pointer to the new page.

The recipe is titled after the API rather than after WebDAV, and says outright
that generating the 207 Multi-Status XML, interpreting Depth and managing locks
are the reader's job. Routing the method is all the library does.

S23 takes order 42, so the TLS, SSE and WebSocket recipes shift to 43-57. That
only moves the sort key. Filenames, the T01/E01/W01 labels, the published URLs
and every cross-reference are untouched.
2026-08-25 19:31:42 -04:00
yhirose af75a4160f CI: quote the OpenSSL installer's /DIR argument
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.
2026-08-25 19:09:56 -04:00
yhirose f3e5a93a5b CI: install Windows OpenSSL from slproweb's manifest instead of Chocolatey
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.
2026-08-25 19:04:26 -04:00
yhirose 00d1f54267 Fix WebSocket::close() racing a concurrent read() on the same stream
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.
2026-08-24 17:30:04 -04:00
yhirose f82d2d90b6 Update README-websocket.md 2026-08-24 07:09:13 -04:00
yhirose 228af9033b Fix TLS session data race on wss:// WebSocket connections (#2551)
A wss:// WebSocket enters a single TLS session from several threads: the
read path, the application's send()/close(), and the heartbeat ping thread.
The existing write_mutex_ only serializes writers, so a reader's SSL_read and
a writer's SSL_write (plus the SSL_peek in is_peer_closed() on the write path)
run concurrently on the same session. OpenSSL and the other backends forbid
concurrent access to one session, so this corrupts the record layer: messages
are silently dropped, and under ASan it shows up as a heap-buffer-overflow.
It affects wss:// only; plain ws:// is unaffected because the kernel allows
concurrent recv()/send() on a socket.

Route wss:// through a new WebSocketSSLStream that serializes every TLS call
with one per-stream mutex. The socket is kept non-blocking for the stream's
lifetime and each read()/write() performs a single non-blocking TLS call under
the lock, then waits for readiness with select() outside the lock. The lock is
therefore held only for CPU-bound work, so a reader blocked waiting for data
never stalls a concurrent sender.

Because the socket is non-blocking, a TLS call can stop needing either
direction, so read() also waits for writability on WantWrite and write() waits
for readability on WantRead. A read that shares its session with the send path
has to flush pending output before it can decrypt more input, and Mbed TLS
surfaces this on every mbedtls_ssl_read(). The read timeouts are atomic since
WebSocket::close() shortens them from the closing thread while the receive
thread is inside wait_readable().

SSLSocketStream is left untouched, so ordinary HTTP/HTTPS keeps its exact code
path and performance. The heartbeat ping thread also stays, so timer-driven
pings keep working as before.

Add test_websocket_thread_safety.cc, which drives send/close/heartbeat against
a concurrent reader over wss://. Built with ASan in CI, a regression surfaces
as a heap-buffer-overflow.
2026-08-24 07:04:43 -04:00
yhirose 6494edd8c0 Add static-file, large-body and TLS workloads to the A/B benchmark
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.
2026-08-19 21:13:30 -04:00
yhirose 70b49d50bd Simplify get_bearer_token_auth and table-drive its test
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.
2026-08-19 19:16:43 -04:00
metsw24-max 2a068def54 validate bearer scheme in get_bearer_token_auth (#2544) 2026-08-19 19:04:46 -04:00
yhirose abf525d78c Parse WWW-Authenticate/Proxy-Authenticate as an RFC 9110 challenge list
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.
2026-08-19 06:54:21 -04:00
yhirose 0151b3e23e Match the Upgrade websocket token rather than the whole field value
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.
2026-08-18 22:29:26 -04:00
yhirose 8b19c288e8 Tidy up the new list-field tests
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.
2026-08-18 22:21:51 -04:00
yhirose f442226581 Match the Expect 100-continue expectation as a token
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.
2026-08-18 22:01:40 -04:00
yhirose 3e3e4863b0 Read Content-Encoding as the combined field value
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.
2026-08-18 22:01:14 -04:00
yhirose e8887d98e9 Match Brotli and Zstandard content codings as whole tokens
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.
2026-08-18 21:05:22 -04:00
yhirose 881842cd72 Match Connection options as tokens rather than whole field values
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.
2026-08-18 20:58:34 -04:00
yhirose 2731b728f5 Move has_header_token() next to the other header field helpers
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.
2026-08-18 20:12:45 -04:00
yhirose 161f787fee Combine repeated field lines before parsing list-valued headers
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.
2026-08-18 19:46:31 -04:00
BioticR 7ae9ffad3e Update README.md (#2543)
AF_UNIX support on windows have already been added in Pr #2115 .
2026-08-18 06:49:10 -04:00
yhirose ffe2a1c1e9 Match the Connection "Upgrade" token exactly in WebSocket handshakes (#2542)
* Match the Connection "Upgrade" token exactly in WebSocket handshakes

The server and the client both looked for "upgrade" as a substring of the
Connection field value, so "notupgrade", "upgrade-not" and "xupgrade" all
passed as the standalone token the handshake requires. RFC 6455 4.2.1 asks
for an ASCII case-insensitive token match, and a value split across several
Connection lines was missed entirely because only the first line was read.

Parse the field as the comma-separated token list it is, across every line,
and reuse the same helper for the server request check and the client
response check.

Reported by gb1dev.

* Tidy up the Connection token helper

Move has_header_token() out of the WebSocket-only detail block and next to
the other header field helpers, forward-declaring it beside split(). Use the
existing split_find(), which drops the manual found flag and stops at the
first matching token.

Drive the client-side test from an ordinary Server route answering 101 with
a bad Connection value, rather than the hand-rolled listening socket copied
from the test above it.

* Keep the Connection token helper out of the split build's ABI

The split build strips inline from everything below the border line, so a
helper defined there becomes an exported symbol of the shared library and
abidiff reports it as an added function. The tests also could not see
is_websocket_upgrade() or websocket_accept_key(), since neither is declared
in the part of the header that survives the split.

Define has_header_token() as a static inline above the border, next to the
split() declarations its two call sites already sit below, and declare the
two WebSocket helpers the way ws::impl::read_websocket_frame() already is.
The shared library's exported symbols are now identical to master's.
2026-08-18 06:48:12 -04:00
Denis Gregor 2004668509 Make decode_uri the inverse of encode_uri (#2540)
decode_uri was a byte-for-byte copy of decode_uri_component: it decoded every
%XX, including escapes of the reserved characters that encode_uri leaves
literal. So decode_uri was not the inverse of encode_uri and promoted an
escaped delimiter into a real one -- decode_uri("http://h/a%2Fb") returned
"http://h/a/b". Keep escapes of the reserved set encode_uri preserves, matching
JS decodeURI; non-reserved escapes still decode.
2026-08-17 20:27:57 -04:00
Jean-Francois Simoneau d3ff68d28d Always use meson option non_blocking_getaddrinfo (#2537)
* Always use meson option non_blocking_getaddrinfo

* Replace not .disabled() with .allowed() for clarity
2026-08-15 08:48:39 -04:00
yhirose b8fe69e4e8 Release v0.53.1 2026-08-14 21:51:42 -04:00
yhirose acd0640870 Code improvement 2026-08-14 21:29:14 -04:00
metsw24-max 2d8e49dd9b reject trailing bytes after IPv6 host literal in parse_url (#2536) 2026-08-14 21:27:40 -04:00
yhirose 1e9d6f0b0b Match literal route patterns without building a std::regex (#2538)
Server::make_matcher() built a std::regex for every pattern that did not
contain "/:", even though most route patterns are plain literals with no
regular expression syntax in them. Matching those went through
std::regex_match on every request, for every registered route the
dispatcher scanned before reaching the one that matches.

PathParamsMatcher already performs an exact literal comparison when it
captures no parameter, so no new matcher class is needed: a pattern with
no regex metacharacter can simply use it. Add an early return for the
zero parameter case in PathParamsMatcher::match(), and select the matcher
by also looking for the 14 ECMAScript metacharacters instead of only for
"/:". Path params keep taking precedence, so a pattern that mixes both,
such as "/users/:id/(.*)", is unaffected.

Measured with clang -O2 on macOS, scanning routes that all miss until the
last one: at 100 routes a scan drops from 10.3us to 0.46us, and end to end
throughput rises by about 24%. At 1000 routes throughput is roughly 3
times higher. Registering 5000 routes drops from about 3.0ms to about
0.9ms, since no std::regex is built for literal patterns.

This also keeps CPPHTTPLIB_REGEX_ROUTE_PATH_MAX_LENGTH confined to the
routes it is meant for. That limit rejects overlong paths before calling
std::regex_match, but until now every literal route was a RegexMatcher
too, so a literal route longer than the limit stopped matching even though
no regular expression was involved. Literal routes no longer go through
RegexMatcher, so only real regex routes are capped.

Patterns containing a metacharacter keep their current behavior, so
"/index.html" still matches "/indexXhtml" the way it always has. One
visible change: a literal route no longer populates Request::matches,
which is now a default constructed std::smatch. Path parameter routes
have always behaved that way, and Request::matches only carries useful
information for regex routes.
2026-08-14 12:58:01 -04:00
yhirose 88240172ea Guard regex routes against stack overflow from long paths
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.
2026-08-13 23:57:28 -04:00
yhirose 89e0c5c238 Enforce payload_max_length on decompressed size for unframed requests
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.
2026-08-13 23:35:30 -04:00
71 changed files with 8459 additions and 1142 deletions
+60 -27
View File
@@ -31,12 +31,19 @@ env:
jobs:
style-check:
runs-on: ubuntu-latest
# Uses the macOS runner's pre-installed Homebrew so clang-format tracks
# whatever version `brew install clang-format` currently resolves to on
# the maintainer's own Mac, instead of a version pinned in this file.
runs-on: macos-latest
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name
continue-on-error: true
steps:
- name: checkout
uses: actions/checkout@v4
- name: install clang-format
run: |
brew update
brew install clang-format coreutils
- name: run style check
run: |
clang-format --version
@@ -114,6 +121,9 @@ jobs:
- name: build and run WebSocket heartbeat test
if: matrix.tls_backend == 'openssl'
run: cd test && make test_websocket_heartbeat && ./test_websocket_heartbeat
- name: build and run WebSocket TLS thread safety test
if: matrix.tls_backend == 'openssl'
run: cd test && make test_websocket_thread_safety && ./test_websocket_thread_safety
- name: build and run ThreadPool test
run: cd test && make test_thread_pool && ./test_thread_pool
@@ -414,6 +424,9 @@ jobs:
- name: build and run WebSocket heartbeat test
if: matrix.tls_backend == 'openssl'
run: cd test && make test_websocket_heartbeat && ./test_websocket_heartbeat
- name: build and run WebSocket TLS thread safety test
if: matrix.tls_backend == 'openssl'
run: cd test && make test_websocket_thread_safety && ./test_websocket_thread_safety
- name: build and run ThreadPool test
run: cd test && make test_thread_pool && ./test_thread_pool
@@ -469,7 +482,6 @@ jobs:
runs-on: windows-latest
permissions:
contents: read
issues: write
if: >
(github.event_name == 'push') ||
(github.event_name == 'pull_request' &&
@@ -518,7 +530,52 @@ jobs:
run: vcpkg install gtest curl zlib brotli zstd
- name: Install OpenSSL
if: ${{ matrix.config.with_ssl }}
run: choco install openssl
shell: pwsh
run: |
# Chocolatey's openssl package hardcodes a versioned slproweb URL, and
# slproweb keeps only the newest build of each branch. The package
# therefore 404s on every OpenSSL release until someone respins it.
# Read slproweb's own manifest instead: it is updated at the same time
# as the downloads it points at, so the URL is always live.
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue' # Invoke-WebRequest is slow with it
$manifest = 'https://raw.githubusercontent.com/slproweb/opensslhashes/master/win32_openssl_hashes.json'
$entry = (Invoke-RestMethod $manifest).files.PSObject.Properties.Value |
Where-Object {
$_.bits -eq 64 -and $_.arch -eq 'INTEL' -and
-not $_.light -and $_.installer -eq 'exe' -and $_.basever -like '4.*'
} |
Sort-Object { [version]$_.basever } | Select-Object -Last 1
if (-not $entry) { throw 'No 64-bit OpenSSL 4.x installer found in the manifest' }
Write-Host "Installing OpenSSL $($entry.basever) from $($entry.url)"
$installer = Join-Path $env:RUNNER_TEMP 'Win64OpenSSL.exe'
Invoke-WebRequest $entry.url -OutFile $installer
$actual = (Get-FileHash $installer -Algorithm SHA512).Hash.ToLower()
if ($actual -ne $entry.sha512.ToLower()) {
throw "SHA512 mismatch: expected $($entry.sha512), got $actual"
}
# Same silent flags the Chocolatey package used. The installer is Inno
# Setup, so /DIR pins the location CMake already looks in. The inner
# quotes matter: ArgumentList joins on spaces, so an unquoted /DIR
# would install to C:\Program and only fail later, at load time.
$dir = 'C:\Program Files\OpenSSL'
$proc = Start-Process $installer -Wait -PassThru -ArgumentList `
'/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART', '/SP-', "/DIR=`"$dir`""
if ($proc.ExitCode -ne 0) { throw "Installer exited with $($proc.ExitCode)" }
# Catch a misplaced install here rather than at link or load time.
if (-not (Test-Path "$dir\lib\VC\x64\MD\libcrypto.lib")) {
throw "OpenSSL import libraries missing under $dir"
}
if (-not (Get-ChildItem "$dir\bin\libcrypto-*.dll" -ErrorAction SilentlyContinue)) {
throw "OpenSSL runtime DLLs missing under $dir\bin"
}
"$dir\bin" | Out-File $env:GITHUB_PATH -Append -Encoding utf8
"OPENSSL_CONF=$dir\bin\openssl.cfg" | Out-File $env:GITHUB_ENV -Append -Encoding utf8
- name: Configure CMake ${{ matrix.config.name }}
run: >
cmake -B build -S .
@@ -566,30 +623,6 @@ jobs:
}
if ($failed) { exit 1 }
Write-Host "All shards passed."
- name: Report flaky failure on issue #2533
if: failure() && matrix.config.name == 'without SSL' && github.event_name == 'push'
continue-on-error: true
shell: pwsh
working-directory: build/test
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
$summary = ""
for ($i = 0; $i -lt 4; $i++) {
$log = "shard_${i}.log"
if (Test-Path $log) {
$failedLines = Select-String -Path $log -Pattern "\[ FAILED \]"
if ($failedLines) {
$summary += "**Shard ${i}:**`n" + (($failedLines | ForEach-Object { $_.Line }) -join "`n") + "`n`n"
}
}
}
if (-not $summary) {
$summary = "_Could not extract failed test name from shard logs; see the run for details._`n`n"
}
$runUrl = "$($env:GITHUB_SERVER_URL)/$($env:GITHUB_REPOSITORY)/actions/runs/$($env:GITHUB_RUN_ID)"
$body = "Reoccurred on push: $runUrl`n`nCommit: $($env:GITHUB_SHA)`n`n$summary"
gh issue comment 2533 --repo $env:GITHUB_REPOSITORY --body $body
env:
VCPKG_ROOT: "C:/vcpkg"
+2
View File
@@ -1,5 +1,6 @@
tags
AGENTS.md
CLAUDE.md
docs-src/pages/AGENTS.md
plans/
work/
@@ -54,6 +55,7 @@ test/test_split_mbedtls
test/test_split_wolfssl
test/test_split_no_tls
test/test_websocket_heartbeat
test/test_websocket_thread_safety
test/test_thread_pool
test/test_benchmark
test/test.xcodeproj/xcuser*
+9 -4
View File
@@ -1,7 +1,12 @@
repos:
- repo: https://github.com/pre-commit/mirrors-clang-format
rev: v18.1.8 # 最新バージョンを使用
- repo: local
hooks:
- id: clang-format
files: \.(cpp|cc|h)$
args: [-i] # インプレースで修正
name: clang-format
# Uses whatever `clang-format` resolves to on PATH (the Homebrew
# install on macOS) instead of a version pinned here, so it tracks
# the same version CI installs via `brew install clang-format`.
entry: clang-format
language: system
files: ^(httplib\.h|(example|fuzzing|test)/[^/]+\.(cpp|cc|h))$
args: [-i]
+21 -3
View File
@@ -15,7 +15,7 @@
* HTTPLIB_REQUIRE_BROTLI (default off)
* HTTPLIB_REQUIRE_ZSTD (default off)
* HTTPLIB_DISABLE_MACOSX_AUTOMATIC_ROOT_CERTIFICATES (default off)
* HTTPLIB_USE_NON_BLOCKING_GETADDRINFO (default on)
* HTTPLIB_USE_NON_BLOCKING_GETADDRINFO (default on when supported)
* HTTPLIB_COMPILE (default off)
* HTTPLIB_INSTALL (default on)
* HTTPLIB_SHARED (default off) builds as a shared library (if HTTPLIB_COMPILE is ON)
@@ -181,6 +181,24 @@ if(HTTPLIB_DISABLE_MACOSX_AUTOMATIC_ROOT_CERTIFICATES)
set(HTTPLIB_IS_USING_MACOSX_AUTOMATIC_ROOT_CERTIFICATES FALSE)
endif()
set(HTTPLIB_IS_USING_NON_BLOCKING_GETADDRINFO ${HTTPLIB_USE_NON_BLOCKING_GETADDRINFO})
if(HTTPLIB_IS_USING_NON_BLOCKING_GETADDRINFO AND WIN32)
include(CheckCXXSymbolExists)
set(_httplib_cmake_required_definitions ${CMAKE_REQUIRED_DEFINITIONS})
set(_httplib_cmake_required_libraries ${CMAKE_REQUIRED_LIBRARIES})
list(APPEND CMAKE_REQUIRED_DEFINITIONS -D_WIN32_WINNT=0x0A00)
list(APPEND CMAKE_REQUIRED_LIBRARIES ws2_32)
check_cxx_symbol_exists(GetAddrInfoExCancel "winsock2.h;ws2tcpip.h" HTTPLIB_HAVE_GETADDRINFOEXCANCEL)
set(CMAKE_REQUIRED_DEFINITIONS ${_httplib_cmake_required_definitions})
set(CMAKE_REQUIRED_LIBRARIES ${_httplib_cmake_required_libraries})
unset(_httplib_cmake_required_definitions)
unset(_httplib_cmake_required_libraries)
if(NOT HTTPLIB_HAVE_GETADDRINFOEXCANCEL)
set(HTTPLIB_IS_USING_NON_BLOCKING_GETADDRINFO FALSE)
message(WARNING "GetAddrInfoExCancel is unavailable; disabling non-blocking getaddrinfo.")
endif()
endif()
# Threads needed for <thread> on some systems, and for <pthread.h> on Linux
set(THREADS_PREFER_PTHREAD_FLAG TRUE)
@@ -367,7 +385,7 @@ target_link_libraries(${PROJECT_NAME} ${_INTERFACE_OR_PUBLIC}
# Needed for API from MacOS Security framework
"$<$<AND:$<PLATFORM_ID:Darwin>,$<BOOL:${HTTPLIB_IS_USING_OPENSSL}>,$<BOOL:${HTTPLIB_IS_USING_MACOSX_AUTOMATIC_ROOT_CERTIFICATES}>>:-framework CFNetwork -framework CoreFoundation -framework Security>"
# Needed for non-blocking getaddrinfo on MacOS
"$<$<AND:$<PLATFORM_ID:Darwin>,$<BOOL:${HTTPLIB_USE_NON_BLOCKING_GETADDRINFO}>>:-framework CFNetwork -framework CoreFoundation>"
"$<$<AND:$<PLATFORM_ID:Darwin>,$<BOOL:${HTTPLIB_IS_USING_NON_BLOCKING_GETADDRINFO}>>:-framework CFNetwork -framework CoreFoundation>"
# Can't put multiple targets in a single generator expression or it bugs out.
$<$<BOOL:${HTTPLIB_IS_USING_BROTLI}>:Brotli::common>
$<$<BOOL:${HTTPLIB_IS_USING_BROTLI}>:Brotli::encoder>
@@ -390,7 +408,7 @@ target_compile_definitions(${PROJECT_NAME} ${_INTERFACE_OR_PUBLIC}
$<$<BOOL:${HTTPLIB_IS_USING_WOLFSSL}>:CPPHTTPLIB_WOLFSSL_SUPPORT>
$<$<BOOL:${HTTPLIB_IS_USING_MBEDTLS}>:CPPHTTPLIB_MBEDTLS_SUPPORT>
$<$<AND:$<PLATFORM_ID:Darwin>,$<BOOL:${HTTPLIB_DISABLE_MACOSX_AUTOMATIC_ROOT_CERTIFICATES}>>:CPPHTTPLIB_DISABLE_MACOSX_AUTOMATIC_ROOT_CERTIFICATES>
$<$<BOOL:${HTTPLIB_USE_NON_BLOCKING_GETADDRINFO}>:CPPHTTPLIB_USE_NON_BLOCKING_GETADDRINFO>
$<$<BOOL:${HTTPLIB_IS_USING_NON_BLOCKING_GETADDRINFO}>:CPPHTTPLIB_USE_NON_BLOCKING_GETADDRINFO>
)
# CMake configuration files installation directory
+1 -1
View File
@@ -69,7 +69,7 @@ sse.on_error([](httplib::Error err) { });
#### Configuration
```cpp
// Set reconnect interval (default: 3000ms)
// Set reconnect interval (default: 3000ms, minimum: 100ms)
sse.set_reconnect_interval(5000);
// Set max reconnect attempts (default: 0 = unlimited)
+43 -6
View File
@@ -57,14 +57,30 @@ if (ws.connect()) {
```cpp
enum ReadResult : int {
Fail = 0, // Connection closed or error
Text = 1, // UTF-8 text message
Binary = 2, // Binary message
Fail = 0, // Connection closed or error
Text = 1, // UTF-8 text message
Binary = 2, // Binary message
Timeout = 3, // Read timeout elapsed; connection still open
};
```
Returned by `read()`. Since `Fail` is `0`, the result works naturally in boolean contexts — `while (ws.read(msg))` continues until the connection closes. When you need to distinguish text from binary, check the return value directly.
`Timeout` is only returned for a read timeout you set yourself with `set_read_timeout()`. It means the timeout elapsed on a message boundary: nothing was consumed and the connection is still open, so you can send on it and read again. The compile-time defaults (`CPPHTTPLIB_WEBSOCKET_SERVER_READ_TIMEOUT_SECOND`, 300 seconds on the server; a client waits forever) are a backstop against a peer that has gone quiet, not a request for control: when one of them elapses, `read()` returns `Fail` and closes the connection, so code that never calls `set_read_timeout()` can keep using `while (ws.read(msg))`.
**`msg` is left untouched on `Timeout`.** Because `Timeout` is non-zero, `while (ws.read(msg))` keeps looping — with the *previous* message still in `msg`. Once you set a read timeout, test the result instead:
```cpp
ws.set_read_timeout(std::chrono::milliseconds(100));
std::string msg;
while (ws.is_open()) {
auto r = ws.read(msg);
if (r == httplib::ws::Timeout) { continue; } // nothing yet; send if you like
if (r == httplib::ws::Fail) { break; }
handle(msg);
}
```
### CloseStatus
```cpp
@@ -103,7 +119,7 @@ using SubProtocolSelector =
std::function<std::string(const std::vector<std::string> &protocols)>;
```
The `SubProtocolSelector` receives the list of subprotocols proposed by the client (from the `Sec-WebSocket-Protocol` header) and returns the selected one. Return an empty string to decline all proposed subprotocols.
The `SubProtocolSelector` receives the list of subprotocols proposed by the client (from the `Sec-WebSocket-Protocol` header) and returns the selected one. Return an empty string to decline all proposed subprotocols. A returned value that the client did not propose is ignored.
### WebSocket (Server-side)
@@ -327,6 +343,18 @@ svr.WebSocket("/ws", [](const httplib::Request &req, httplib::ws::WebSocket &ws)
});
```
The check above runs after the handshake, so the client sees a successful upgrade followed by a close frame. To refuse the upgrade itself with an HTTP status, use a pre-routing or pre-request handler. Both run before the `101 Switching Protocols` response, and `req.matched_route` is available in the pre-request handler:
```cpp
svr.set_pre_request_handler([](const httplib::Request &req, httplib::Response &res) {
if (req.matched_route == "/ws" && req.get_header_value("Authorization").empty()) {
res.status = httplib::StatusCode::Unauthorized_401;
return httplib::Server::HandlerResponse::Handled; // not upgraded
}
return httplib::Server::HandlerResponse::Unhandled;
});
```
### Custom Headers and Timeouts
```cpp
@@ -409,7 +437,8 @@ if (ws.connect()) {
| Macro | Default | Description |
|---------------------------------------------|-------------------|----------------------------------------------------------|
| `CPPHTTPLIB_WEBSOCKET_MAX_PAYLOAD_LENGTH` | `16777216` (16MB) | Maximum payload size per message |
| `CPPHTTPLIB_WEBSOCKET_READ_TIMEOUT_SECOND` | `300` | Read timeout for WebSocket connections (seconds) |
| `CPPHTTPLIB_WEBSOCKET_CLIENT_READ_TIMEOUT_SECOND` | `0` | Client read timeout (seconds); `0` waits forever |
| `CPPHTTPLIB_WEBSOCKET_SERVER_READ_TIMEOUT_SECOND` | `300` | Server read timeout (seconds) |
| `CPPHTTPLIB_WEBSOCKET_CLOSE_TIMEOUT_SECOND` | `5` | Timeout for waiting peer's Close response (seconds) |
| `CPPHTTPLIB_WEBSOCKET_PING_INTERVAL_SECOND` | `30` | Automatic Ping interval for heartbeat (seconds) |
| `CPPHTTPLIB_WEBSOCKET_MAX_MISSED_PONGS` | `0` (disabled) | Close the connection after N consecutive unacked pings |
@@ -446,7 +475,7 @@ The server side has the same `set_websocket_max_missed_pongs()`.
With the default ping interval of 30 seconds, `max_missed_pongs = 2` detects a dead peer within ~60 seconds. The counter is reset every time a Pong frame is received, so the mechanism only works when your code is actively calling `read()` — exactly the pattern a normal WebSocket client already uses.
**The default is `0`**, which means "never close the connection because of missing pongs." Pings are still sent on the heartbeat interval, but their responses are not checked. Even so, a dead connection does not linger forever: while your code is inside `read()`, `CPPHTTPLIB_WEBSOCKET_READ_TIMEOUT_SECOND` (default **300 seconds = 5 minutes**) acts as a backstop and `read()` fails if no frame arrives in time. `max_missed_pongs` is the knob for detecting an unresponsive peer faster than that 5-minute fallback.
**The default is `0`**, which means "never close the connection because of missing pongs." Pings are still sent on the heartbeat interval, but their responses are not checked. On the server side a dead connection still does not linger: while a handler is inside `read()`, `CPPHTTPLIB_WEBSOCKET_SERVER_READ_TIMEOUT_SECOND` (default **300 seconds = 5 minutes**) acts as a backstop. A client has no such backstop — it waits forever unless you set a read timeout — so there `max_missed_pongs` is what notices an unresponsive peer at all. On either side it is also the knob for noticing one *faster* than the 5-minute fallback.
## Threading Model
@@ -466,6 +495,14 @@ svr.new_task_queue = [] {
Choose sizes that account for both your expected HTTP load and the maximum number of simultaneous WebSocket connections.
### Calling from Multiple Threads
A single `WebSocket` (server-side) or `WebSocketClient` handle is shared by three potential callers: the thread running your handler (or holding the client), the heartbeat thread, and, if your code does its own thing, a separate thread calling `send()`/`close()` while another thread is blocked in `read()`.
**Supported**: calling `read()` from one thread while calling `send()`/`close()` from another. This is the common pattern for a client that reads incoming messages in a loop on one thread and sends from elsewhere (e.g. a UI thread). A message that is in flight when `close()` is called still arrives intact; `close()` sends the Close frame and returns, leaving the connection's read side to the thread that owns it, so it does not block waiting for the peer's Close reply in that case. The heartbeat thread's automatic pings use the same `send()` path internally, so they are safe to run concurrently with your `read()` loop too — for `wss://` this requires every TLS call on a connection to be serialized internally, which cpp-httplib does for you.
**Not supported**: calling `read()` from two threads at the same time on the same handle. The calls are serialized rather than left to corrupt each other, but which thread receives which message is unspecified, so there is nothing useful to build on it.
## Protocol
The implementation follows [RFC 6455](https://tools.ietf.org/html/rfc6455):
+113 -5
View File
@@ -230,7 +230,7 @@ cpp-httplib automatically integrates with the OS certificate store on macOS and
| Platform | Behavior | Disable (compile time) |
| :------- | :------- | :--------------------- |
| macOS | Loads system certs from Keychain (link `CoreFoundation` and `Security` with `-framework`). Requires Apple Clang; GCC is not supported for this feature. | `CPPHTTPLIB_DISABLE_MACOSX_AUTOMATIC_ROOT_CERTIFICATES` |
| Windows | Verifies certs via CryptoAPI (`CertGetCertificateChain` / `CertVerifyCertificateChainPolicy`) with revocation checking | `CPPHTTPLIB_DISABLE_WINDOWS_AUTOMATIC_ROOT_CERTIFICATES_UPDATE` |
| Windows | Verifies the certificate chain with CryptoAPI (`CertGetCertificateChain` / `CertVerifyCertificateChainPolicy`) instead of the TLS backend. Revocation checking is best-effort: a revoked server certificate is rejected, while one whose revocation status cannot be determined is accepted. Windows fetches missing roots and intermediates on demand. With a custom CA, the TLS backend verifies the chain instead; with `set_server_certificate_verifier()`, both do. | `CPPHTTPLIB_DISABLE_WINDOWS_AUTOMATIC_ROOT_CERTIFICATES_UPDATE` |
On Windows, verification can also be disabled at runtime:
@@ -307,6 +307,39 @@ int main(void)
`Post`, `Put`, `Patch`, `Delete` and `Options` methods are also supported.
### Custom HTTP methods
Methods outside the built-in set are rejected with `400 Bad Request` unless a handler is registered for them with `CustomRoute`. This covers the WebDAV methods of RFC 4918, `SUBSCRIBE` and friends from UPnP, and any other extension method.
```c++
svr.CustomRoute("PROPFIND", "/dav/:id", [](const Request& req, Response& res) {
// The request body is available as usual
auto id = req.path_params.at("id");
res.status = StatusCode::MultiStatus_207;
res.set_content(build_multistatus(req.body), "application/xml");
});
// A content reader overload is available too
svr.CustomRoute("REPORT", "/dav/.*",
[](const Request& req, Response& res,
const ContentReader& content_reader) {
content_reader([&](const char* data, size_t data_length) {
// ...
return true;
});
});
```
Patterns work exactly as they do for `Get` and the other methods, so regular expressions and path parameters are both available.
Note the following:
* The method name must be a valid HTTP method token (RFC 9110) and must be registered before `listen()` is called.
* `GET`, `HEAD`, `POST`, `PUT`, `DELETE`, `CONNECT`, `OPTIONS`, `TRACE`, `PATCH` and `PRI` cannot be registered this way. Use the dedicated methods above instead.
* A rejected registration makes `is_valid()` return `false`, and `listen()` then fails rather than starting a server with a route that would never fire.
* Static file serving and WebSocket upgrades remain `GET`/`HEAD` only.
* `Allow` and the WebDAV `DAV:` header are not generated automatically. Register an `Options` handler if clients need them.
### Bind a socket to multiple interfaces and any available port
```cpp
@@ -314,6 +347,25 @@ int port = svr.bind_to_any_port("0.0.0.0");
svr.listen_after_bind();
```
### Port sharing and exclusive binding
By default, the server socket enables address/port reuse: `SO_REUSEPORT` where it is available (Linux, macOS), and `SO_REUSEADDR` otherwise (Windows). A restarted server can bind again immediately, but binding to a port that another server is already listening on also succeeds, and connections are distributed between them.
If you want `listen()` to fail when the port is already in use, replace the default socket options with `set_socket_options`:
```cpp
svr.set_socket_options([](socket_t sock) {
#ifdef _WIN32
httplib::set_socket_opt(sock, SOL_SOCKET, SO_EXCLUSIVEADDRUSE, 1);
#else
httplib::set_socket_opt(sock, SOL_SOCKET, SO_REUSEADDR, 1);
#endif
});
```
> [!NOTE]
> Setting only `SO_REUSEADDR` is not enough on Windows. There, `SO_REUSEADDR` allows two sockets that both set it to bind to the same port, so use `SO_EXCLUSIVEADDRUSE` instead.
### Static File Server
```cpp
@@ -400,6 +452,9 @@ svr.set_logger([](const httplib::Request& req, const httplib::Response& res) {
});
```
> [!NOTE]
> `req.path` is percent-decoded and may contain control characters such as CR/LF. Escape request data before writing it to a log file (see [docker/main.cc](docker/main.cc) for an example).
#### Pre-compression Logging
You can also set a pre-compression logger to capture request/response data before compression is applied:
@@ -414,6 +469,8 @@ svr.set_pre_compression_logger([](const httplib::Request& req, const httplib::Re
The pre-compression logger is only called when compression would be applied. For responses without compression, only the access logger is called.
For a static file response (see [Static file compression](#static-file-compression)), `res.body` is empty when the logger runs. The bytes are still on disk at that point, not in memory.
#### Error Logging
Error loggers capture failed requests and connection issues. Unlike access loggers, error loggers only receive the Error and Request information, as errors typically occur before a meaningful Response can be generated.
@@ -510,14 +567,15 @@ svr.set_pre_request_handler([](const auto& req, auto& res) {
```
Request received
│
├─ expect_100_continue_handler (when the request has "Expect: 100-continue")
│ └─ returns a status other than 100 → stop here
│
├─ pre_routing_handler route not matched yet, body not read
│ └─ returns Handled → stop here
│
├─ file_request_handler (GET/HEAD, static file serving)
│
├─ expect_100_continue_handler (when the request has "Expect: 100-continue")
│
├─ route matching → req.matched_route is set
│
├─ pre_request_handler route matched, body NOT read yet
@@ -533,6 +591,10 @@ Request received
Use `pre_routing_handler` to reject a request as early as possible, before the route is known. Use `pre_request_handler` for route-specific checks, since `req.matched_route` is available and the body has not been read yet.
For a request with `Expect: 100-continue`, the `100 Continue` response is not sent until the body is about to be read. A request rejected before that point (by `pre_routing_handler`, `pre_request_handler`, or because no route matched) gets its final response without `100 Continue`, so the client never sends the body.
A WebSocket upgrade request that matches a route registered with `svr.WebSocket()` takes a shorter path: `pre_routing_handler`, then route matching (`req.matched_route` is set), then `pre_request_handler`, then the WebSocket handler. If either hook returns `Handled`, its response is sent as a regular HTTP response and the connection is not upgraded. Once the connection is upgraded, `post_routing_handler` does not run.
### Response user data
`res.user_data` is a type-safe key-value store that lets pre-routing or pre-request handlers pass arbitrary data to route handlers.
@@ -695,6 +757,12 @@ svr.Post("/content_receiver",
});
```
`CPPHTTPLIB_MULTIPART_FORM_DATA_FILE_MAX_COUNT` (default 1024) caps the number of
form-data parts only on the buffered path, where every part is accumulated into
`req.form`. The content receiver keeps nothing, so the cap does not apply here.
If your handler needs an upper bound on the number of parts, count them yourself
and return `false` from the callback to stop the parser.
### Send content with the content provider
```cpp
@@ -786,7 +854,9 @@ svr.Get("/content", [&](const Request &req, Response &res) {
### 'Expect: 100-continue' handler
By default, the server sends a `100 Continue` response for an `Expect: 100-continue` header.
By default, the server accepts an `Expect: 100-continue` header and sends a `100 Continue` response when it starts reading the request body. If the request is answered without reading the body, `100 Continue` is not sent and the connection is closed after the response.
The handler runs before `pre_routing_handler`. Returning `100` lets the request proceed; returning any other status sends that status as the final response and closes the connection.
```cpp
// Send a '417 Expectation Failed' response.
@@ -1433,6 +1503,45 @@ The server can apply compression to the following MIME type contents:
- application/protobuf
- application/xhtml+xml
A response that already carries `Content-Encoding` is sent as it is. A handler serving content it encoded itself, an asset compressed at build time for instance, keeps its own coding and its own bytes:
```c++
svr.Get("/app.js", [](const Request & /*req*/, Response &res) {
res.set_header("Content-Encoding", "gzip");
res.set_content(gzipped_asset, "application/javascript");
});
```
This holds for every kind of response, including the file-backed ones below.
`Vary: Accept-Encoding` is added only to responses the server encoded itself. A handler that chooses between an encoded and an identity representation by reading `Accept-Encoding` should set the field itself, so that shared caches keep the two apart.
### Static file compression
Responses served from a file, whether through `set_mount_point()` or `Response::set_file_content()`, are sent as is by default. Turn compression on for them with:
```c++
svr.set_static_file_compression(true);
```
Only files within a size range are compressed, and both ends of it can be moved:
```c++
svr.set_static_file_compression_min_length(512);
svr.set_static_file_compression_max_length(1024 * 1024);
```
The lower bound defaults to 1400 bytes. A response that already fits in a single 1500-byte MTU is not delivered any faster for being smaller, and a file of a few bytes comes back larger than it went in, since gzip's header and trailer outweigh what deflate saves. `0` compresses everything down to a single byte, and `CPPHTTPLIB_STATIC_FILE_COMPRESSION_MIN_LENGTH` sets the default at compile time. An empty file is never compressed regardless.
The upper bound defaults to 4MB, and exists for a different reason: the file is compressed per request, and the compressed bytes are held in memory until the response has been written, so the peak cost scales with the number of requests in flight. It is a bound on what one request can cost, not a statement about how well large files compress, which is why raising it is reasonable when the files are known and the traffic is not. `0` removes the limit, and `CPPHTTPLIB_STATIC_FILE_COMPRESSION_MAX_LENGTH` sets the default at compile time.
A compressed response keeps its `Content-Length`, so `HEAD` still reports the size a `GET` would return. Two details are worth knowing:
- Range requests are answered from the uncompressed representation, so `Content-Range` keeps naming the file's own bytes.
- The `ETag` carries the coding it belongs to (`W/"...-gzip"`), so a client that cached the compressed form revalidates against the right validator.
Content providers registered with `set_content_provider()` are not covered. Feeding one through a compressor would hold each write back until the compressor's window filled, which breaks providers that produce their body incrementally. Use `set_chunked_content_provider()` to compress a generated body.
### Zlib Support
'gzip' compression is available with `CPPHTTPLIB_ZLIB_SUPPORT`. `libz` should be linked.
@@ -1481,7 +1590,6 @@ res->body; // Compressed data
Unix Domain Socket Support
--------------------------
Unix Domain Socket support is available on Linux and macOS.
```c++
// Server
+68 -9
View File
@@ -3,7 +3,25 @@
# A/B throughput comparison between two git refs.
#
# Usage: ./ab.sh [--base REF] [--head REF] [--rounds N] [--duration S]
# [--connections N] [--threads N]
# [--connections N] [--threads N] [--path PATH] [--tls]
# [--large-mib N] [--timeout S]
#
# --path selects the workload. The harness serves:
# / small body via set_content(); the response line, the
# headers and the body already share a single write(), so
# this is the least sensitive case
# /large large body via set_content()
# /static/small.js 1 KiB file from a mount point, where the headers and the
# body are two separate writes
# /static/large.bin same, with the body large enough to dominate
#
# --large-mib sizes the two large workloads (default 1).
#
# --tls runs the same workload over HTTPS, which writes through
# SSLSocketStream instead of SocketStream.
#
# --timeout is bombardier's per-request timeout. Its 2s default aborts large
# TLS responses, and the run then fails on the non-2xx check.
#
# Absolute numbers from a single run are meaningless: on a quiet 8-core laptop
# the same binary varies by +/-20% run to run, and shared CI runners are worse.
@@ -21,6 +39,10 @@ DURATION="5s"
CONNECTIONS=10
THREADS=""
PORT=8080
REQ_PATH="/"
TLS=0
LARGE_MIB=1
TIMEOUT="30s"
while [ $# -gt 0 ]; do
case "$1" in
@@ -30,6 +52,10 @@ while [ $# -gt 0 ]; do
--duration) DURATION="$2"; shift 2 ;;
--connections) CONNECTIONS="$2"; shift 2 ;;
--threads) THREADS="$2"; shift 2 ;;
--path) REQ_PATH="$2"; shift 2 ;;
--large-mib) LARGE_MIB="$2"; shift 2 ;;
--timeout) TIMEOUT="$2"; shift 2 ;;
--tls) TLS=1; shift ;;
*) echo "Unknown option: $1" >&2; exit 1 ;;
esac
done
@@ -62,6 +88,7 @@ HEAD_SHA=$(git -C "$REPO_ROOT" rev-parse --short "$HEAD_REF")
echo "==> base: $BASE_REF ($BASE_SHA)"
echo "==> head: $HEAD_REF ($HEAD_SHA)"
echo "==> rounds=$ROUNDS duration=$DURATION connections=$CONNECTIONS threads=$THREADS"
echo "==> path=$REQ_PATH tls=$TLS large=${LARGE_MIB}MiB"
echo ""
if [ "$BASE_SHA" = "$HEAD_SHA" ]; then
@@ -69,18 +96,49 @@ if [ "$BASE_SHA" = "$HEAD_SHA" ]; then
echo ""
fi
# --- Toolchain bits that depend on --tls ---
SCHEME="http"
INSECURE=""
TLS_CXXFLAGS=""
TLS_LDFLAGS=""
TLS_ARGS=""
if [ "$TLS" = "1" ]; then
SCHEME="https"
INSECURE="-k"
TLS_CXXFLAGS="-DCPPHTTPLIB_OPENSSL_SUPPORT"
TLS_LDFLAGS="-lssl -lcrypto"
if command -v pkg-config >/dev/null 2>&1 && pkg-config --exists openssl; then
TLS_CXXFLAGS="$TLS_CXXFLAGS $(pkg-config --cflags openssl)"
TLS_LDFLAGS="$(pkg-config --libs openssl)"
elif command -v brew >/dev/null 2>&1 && brew --prefix openssl >/dev/null 2>&1; then
OPENSSL_PREFIX=$(brew --prefix openssl)
TLS_CXXFLAGS="$TLS_CXXFLAGS -I$OPENSSL_PREFIX/include"
TLS_LDFLAGS="-L$OPENSSL_PREFIX/lib -lssl -lcrypto"
fi
if [ "$(uname -s)" = "Darwin" ]; then
TLS_LDFLAGS="$TLS_LDFLAGS -framework CoreFoundation -framework Security"
fi
TLS_ARGS="--cert $REPO_ROOT/test/cert.pem --key $REPO_ROOT/test/key.pem"
for f in "$REPO_ROOT/test/cert.pem" "$REPO_ROOT/test/key.pem"; do
[ -f "$f" ] || { echo "Error: $f not found" >&2; exit 1; }
done
fi
# --- Build both refs ---
# The harness source always comes from the invoking worktree, so both refs run
# an identical workload and a ref that predates a harness change stays
# measurable. Only httplib.h varies, through -I.
HARNESS="$REPO_ROOT/benchmark/cpp-httplib/main.cpp"
[ -f "$HARNESS" ] || { echo "Error: $HARNESS not found" >&2; exit 1; }
build() {
local name=$1 ref=$2
git -C "$REPO_ROOT" worktree add --detach --quiet "$WORKDIR/$name" "$ref"
if [ ! -f "$WORKDIR/$name/benchmark/cpp-httplib/main.cpp" ]; then
echo "Error: benchmark/cpp-httplib/main.cpp missing in $ref" >&2
exit 1
fi
"$CXX" -o "$WORKDIR/$name/server-ab" -O2 -std=c++11 \
-I"$WORKDIR/$name" \
-DCPPHTTPLIB_THREAD_POOL_COUNT="$THREADS" \
"$WORKDIR/$name/benchmark/cpp-httplib/main.cpp" -lpthread
$TLS_CXXFLAGS \
"$HARNESS" -lpthread $TLS_LDFLAGS
}
echo "==> Building..."
@@ -92,7 +150,8 @@ measure() {
local name=$1
local json rc
"$WORKDIR/$name/server-ab" >/dev/null 2>&1 &
"$WORKDIR/$name/server-ab" --port "$PORT" --dir "$WORKDIR/$name-www" \
--large-mib "$LARGE_MIB" $TLS_ARGS >/dev/null 2>&1 &
local pid=$!
# Wait for the listener (no dependency on nc)
@@ -103,8 +162,8 @@ measure() {
done
set +e
json=$(bombardier -c "$CONNECTIONS" -d "$DURATION" -o json -p r \
"http://127.0.0.1:$PORT/" 2>/dev/null)
json=$(bombardier -c "$CONNECTIONS" -d "$DURATION" -t "$TIMEOUT" -o json -p r $INSECURE \
"$SCHEME://127.0.0.1:$PORT$REQ_PATH" 2>/dev/null)
rc=$?
set -e
+130 -3
View File
@@ -1,12 +1,139 @@
#include "httplib.h"
#include <cstdlib>
#include <cstring>
#include <fstream>
#include <string>
#ifndef _WIN32
#include <sys/stat.h>
#include <sys/types.h>
#endif
using namespace httplib;
int main() {
Server svr;
namespace {
// The workloads differ in which part of the write path they exercise:
//
// / small body set through set_content(); the response line,
// the headers and the body already share a single write()
// /large large body set through set_content(); the body is copied
// into the header buffer before that single write().
// --large-mib sets its size (and large.bin's).
// /static/small.js small file served from a mount point, where the headers
// and the body are two separate writes
// /static/large.bin same, with the body large enough to dominate
//
// Bodies are generated at startup so the repository carries no fixtures.
const size_t SMALL_SIZE = 1024;
const size_t LARGE_SIZE_DEFAULT_MIB = 1;
std::string filler(size_t n) {
std::string s;
s.reserve(n);
while (s.size() < n) {
s += "0123456789abcdef";
}
s.resize(n);
return s;
}
bool write_file(const std::string &path, const std::string &content) {
std::ofstream f(path.c_str(), std::ios::binary);
f.write(content.data(), static_cast<std::streamsize>(content.size()));
return f.good();
}
std::string default_dir() {
const char *tmp = std::getenv("TMPDIR");
std::string base = tmp && *tmp ? tmp : "/tmp";
if (!base.empty() && base[base.size() - 1] == '/') {
base.erase(base.size() - 1);
}
return base + "/cpp-httplib-bench";
}
bool make_dir(const std::string &path) {
#ifdef _WIN32
return _mkdir(path.c_str()) == 0 || errno == EEXIST;
#else
return ::mkdir(path.c_str(), 0755) == 0 || errno == EEXIST;
#endif
}
void setup(Server &svr, const std::string &large, const std::string &dir) {
svr.Get("/", [](const Request &, Response &res) {
res.set_content("Hello World!", "text/plain");
});
svr.listen("0.0.0.0", 8080);
svr.Get("/large", [&large](const Request &, Response &res) {
res.set_content(large, "application/octet-stream");
});
svr.set_mount_point("/static", dir);
}
} // namespace
int main(int argc, char *argv[]) {
int port = 8080;
std::string dir = default_dir();
std::string cert;
std::string key;
size_t large_mib = LARGE_SIZE_DEFAULT_MIB;
for (int i = 1; i < argc; i++) {
auto last = i + 1 < argc;
if (!std::strcmp(argv[i], "--port") && last) {
port = std::atoi(argv[++i]);
} else if (!std::strcmp(argv[i], "--large-mib") && last) {
large_mib = static_cast<size_t>(std::atoi(argv[++i]));
} else if (!std::strcmp(argv[i], "--dir") && last) {
dir = argv[++i];
} else if (!std::strcmp(argv[i], "--cert") && last) {
cert = argv[++i];
} else if (!std::strcmp(argv[i], "--key") && last) {
key = argv[++i];
} else {
std::fprintf(stderr,
"usage: %s [--port N] [--dir PATH] [--large-mib N]"
" [--cert PATH --key PATH]\n",
argv[0]);
return 2;
}
}
if (!make_dir(dir)) {
std::fprintf(stderr, "cannot create %s\n", dir.c_str());
return 1;
}
auto large = filler(large_mib * 1024 * 1024);
if (!write_file(dir + "/small.js", filler(SMALL_SIZE)) ||
!write_file(dir + "/large.bin", large)) {
std::fprintf(stderr, "cannot write fixtures under %s\n", dir.c_str());
return 1;
}
if (!cert.empty()) {
#ifdef CPPHTTPLIB_OPENSSL_SUPPORT
SSLServer svr(cert.c_str(), key.c_str());
if (!svr.is_valid()) {
std::fprintf(stderr, "cannot load %s / %s\n", cert.c_str(), key.c_str());
return 1;
}
setup(svr, large, dir);
svr.listen("0.0.0.0", port);
return 0;
#else
std::fprintf(stderr, "built without CPPHTTPLIB_OPENSSL_SUPPORT\n");
return 1;
#endif
}
Server svr;
setup(svr, large, dir);
svr.listen("0.0.0.0", port);
}
+26 -6
View File
@@ -48,6 +48,23 @@ std::string get_error_time_format() {
return ss.str();
}
// Escape a value for a log line the way NGINX does: '"', '\\', control
// bytes and non-ASCII bytes become \xHH. Request fields are attacker-controlled
// (e.g. a raw CR in the request target or a decoded %0D%0A in req.path), so
// writing them verbatim would let a client forge extra log lines.
std::string escape_log(const std::string &s) {
std::string out;
out.reserve(s.size());
for (unsigned char c : s) {
if (c == '"' || c == '\\' || c < 0x20 || c >= 0x7f) {
out += std::format("\\x{:02X}", c);
} else {
out += static_cast<char>(c);
}
}
return out;
}
// NGINX Combined log format:
// $remote_addr - $remote_user [$time_local] "$request" $status $body_bytes_sent
// "$http_referer" "$http_user_agent"
@@ -55,7 +72,9 @@ void nginx_access_logger(const Request &req, const Response &res) {
std::string remote_user =
"-"; // cpp-httplib doesn't have built-in auth user tracking
auto time_local = get_time_format();
auto request = std::format("{} {} {}", req.method, req.path, req.version);
// $request is the original request line, so log the raw target rather than
// the percent-decoded req.path.
auto request = std::format("{} {} {}", req.method, req.target, req.version);
auto status = res.status;
auto body_bytes_sent = res.body.size();
auto http_referer = req.get_header_value("Referer");
@@ -64,9 +83,9 @@ void nginx_access_logger(const Request &req, const Response &res) {
if (http_user_agent.empty()) http_user_agent = "-";
std::cout << std::format("{} - {} [{}] \"{}\" {} {} \"{}\" \"{}\"",
req.remote_addr, remote_user, time_local, request,
status, body_bytes_sent, http_referer,
http_user_agent)
req.remote_addr, remote_user, time_local,
escape_log(request), status, body_bytes_sent,
escape_log(http_referer), escape_log(http_user_agent))
<< std::endl;
}
@@ -79,14 +98,15 @@ void nginx_error_logger(const Error &err, const Request *req) {
if (req) {
auto request =
std::format("{} {} {}", req->method, req->path, req->version);
std::format("{} {} {}", req->method, req->target, req->version);
auto host = req->get_header_value("Host");
if (host.empty()) host = "-";
std::cerr << std::format("{} [{}] {}, client: {}, request: "
"\"{}\", host: \"{}\"",
time_local, level, to_string(err),
req->remote_addr, request, host)
req->remote_addr, escape_log(request),
escape_log(host))
<< std::endl;
} else {
// If no request context, just log the error
+1 -1
View File
@@ -4,7 +4,7 @@ langs = ["en", "ja"]
[site]
title = "cpp-httplib"
version = "0.53.0"
version = "0.60.1"
hostname = "https://yhirose.github.io"
base_path = "/cpp-httplib"
footer_message = "© 2026 Yuji Hirose. All rights reserved."
+1 -1
View File
@@ -1,6 +1,6 @@
---
title: "E01. Implement an SSE Server"
order: 47
order: 48
status: "draft"
---
@@ -1,6 +1,6 @@
---
title: "E02. Use Named Events in SSE"
order: 48
order: 49
status: "draft"
---
@@ -1,6 +1,6 @@
---
title: "E03. Handle SSE Reconnection"
order: 49
order: 50
status: "draft"
---
+1 -1
View File
@@ -1,6 +1,6 @@
---
title: "E04. Receive SSE on the Client"
order: 50
order: 51
status: "draft"
---
+3
View File
@@ -73,6 +73,9 @@ A collection of recipes that answer "How do I...?" questions. Each recipe is sel
- [S21. Configure the thread pool](s21-thread-pool)
- [S22. Talk over a Unix domain socket](s22-unix-socket)
### Protocol Extensions
- [S23. Handle custom HTTP methods](s23-custom-methods)
## TLS / Security
- [T01. Choosing between OpenSSL, mbedTLS, and wolfSSL](t01-tls-backends)
+3 -1
View File
@@ -4,7 +4,7 @@ order: 20
status: "draft"
---
With `httplib::Server`, you register a handler per HTTP method. Just pass a pattern and a lambda to `Get()`, `Post()`, `Put()`, or `Delete()`.
With `httplib::Server`, you register a handler per HTTP method. Just pass a pattern and a lambda to `Get()`, `Post()`, `Put()`, or `Delete()`. For methods outside the built-in set, such as WebDAV's `PROPFIND`, use `CustomRoute()`.
## Basic usage
@@ -64,3 +64,5 @@ To add a response header, use `res.set_header("Name", "Value")`.
> **Note:** `listen()` is a blocking call. To run it on a different thread, wrap it in `std::thread`. If you need non-blocking startup, see [S18. Control startup order with `listen_after_bind`](../s18-listen-after-bind).
> To use path parameters like `/users/:id`, see [S03. Use path parameters](../s03-path-params).
> For methods outside the built-in set, such as WebDAV's `PROPFIND`, see [S23. Handle custom HTTP methods](../s23-custom-methods).
@@ -66,6 +66,38 @@ svr.Post("/upload",
Only a small chunk sits in memory at any moment, so gigabyte-scale files are no problem.
## Count the parts yourself
There is a cap on the number of parts, `CPPHTTPLIB_MULTIPART_FORM_DATA_FILE_MAX_COUNT` (1024 by default), but it only applies to the buffered path, where every part is accumulated into `req.form`. The `ContentReader` keeps nothing on the library side, so the cap does not apply here.
If you want an upper bound, count the parts yourself and return `false` from the header callback. The parser stops right there.
```cpp
svr.Post("/upload",
[](const httplib::Request &req, httplib::Response &res,
const httplib::ContentReader &content_reader) {
size_t count = 0;
auto ok = content_reader(
[&](const httplib::FormData &file) {
if (++count > 100) { return false; } // stop here
return true;
},
[&](const char *data, size_t len) {
return true;
});
if (!ok) {
res.status = httplib::StatusCode::BadRequest_400;
return;
}
res.set_content("ok", "text/plain");
});
```
When `content_reader` returns `false`, set the response status yourself. The rest of the body is left unread and the connection is closed, so a client that is still sending sees the connection drop.
> **Warning:** When you use `HandlerWithContentReader`, `req.body` stays **empty**. Handle the body yourself inside the callbacks.
> For the client side of multipart uploads, see [C07. Upload a file as multipart form data](../c07-multipart-upload).
@@ -48,6 +48,31 @@ svr.Get("/events", [](const httplib::Request &req, httplib::Response &res) {
});
```
> **Note:** Tiny responses barely benefit from compression and just waste CPU time. cpp-httplib skips compression for bodies that are too small to bother with.
## Static files need to be opted in
Files served as they are, through `set_mount_point()` or `Response::set_file_content()`, are not compressed by default. Turn it on with:
```cpp
svr.set_static_file_compression(true);
```
Only files within a size range are compressed, and both ends of it can be moved:
```cpp
svr.set_static_file_compression_min_length(512);
svr.set_static_file_compression_max_length(1024 * 1024);
```
The lower bound defaults to 1400 bytes. A response that already fits in a single 1500-byte MTU is not delivered any faster for being smaller, and a file of a few bytes comes back larger than it went in, because gzip's header and trailer outweigh what deflate saves.
The upper bound defaults to 4MB and exists for a different reason: the file is compressed on every request, and the compressed bytes stay in memory until the response has been written, so the peak cost scales with the number of requests in flight. It bounds what a single request can cost, and says nothing about how well large files compress, so raising it is reasonable when the files are known and the traffic is not.
Either bound takes `0` to turn it off, and each has a compile-time default (`CPPHTTPLIB_STATIC_FILE_COMPRESSION_MIN_LENGTH`, `CPPHTTPLIB_STATIC_FILE_COMPRESSION_MAX_LENGTH`).
A compressed response keeps its `Content-Length`, so `HEAD` reports the same size a `GET` would. Two details to know: Range requests are answered from the uncompressed representation, and the `ETag` carries the coding it belongs to, as in `W/"...-gzip"`.
Content providers registered with `set_content_provider()` are not covered. Running one through a compressor holds each write back until the internal buffer fills, which stalls providers that build their body a piece at a time. To compress a generated body, use `set_chunked_content_provider()`.
> **Note:** The size range covers static files only. A body passed to `set_content()` is compressed whenever the client accepts it and the MIME type is compressible, however small it is, so a response of a few bytes ends up larger than it started. Decide in the handler if you want to avoid that.
> For the client-side counterpart, see [C15. Enable compression](../c15-compression).
@@ -37,6 +37,8 @@ svr.set_pre_request_handler(
`matched_route` is the pattern **before** path parameters are expanded (e.g. `/admin/users/:id`). You compare against the route definition, not the actual request path, so IDs or names don't throw you off.
The pre-request handler also runs for routes registered with `svr.WebSocket()`. It is called before the `101 Switching Protocols` response, so returning `Handled` sends your HTTP response (such as a 403) and the connection is never upgraded.
## Return values
Same as pre-routing — return `HandlerResponse`.
@@ -43,15 +43,40 @@ svr.listen_after_bind();
## Check the return values
`bind_to_port()` returns `false` on failure — typically when the port is already taken. Always check it.
`bind_to_port()` returns `false` on failure, for example when you don't have permission to bind to the port. Always check it.
```cpp
if (!svr.bind_to_port("0.0.0.0", 8080)) {
std::cerr << "port already in use" << std::endl;
std::cerr << "bind failed" << std::endl;
return 1;
}
```
`listen_after_bind()` blocks until the server stops and returns `true` on a clean shutdown.
## Detect a port that's already in use
With the default settings, you can actually bind to a port another server is already using. That's because cpp-httplib sets `SO_REUSEPORT` (Linux, macOS) or `SO_REUSEADDR` (Windows) on the server socket. A restarted server can bind again right away. The flip side is that a second server on the same port starts without an error, and connections get split between the two.
To make `bind_to_port()` fail on a port in use, replace the socket options with `set_socket_options()`.
```cpp
svr.set_socket_options([](socket_t sock) {
#ifdef _WIN32
httplib::set_socket_opt(sock, SOL_SOCKET, SO_EXCLUSIVEADDRUSE, 1);
#else
httplib::set_socket_opt(sock, SOL_SOCKET, SO_REUSEADDR, 1);
#endif
});
if (!svr.bind_to_port("0.0.0.0", 8080)) {
std::cerr << "port already in use" << std::endl;
return 1;
}
```
`set_socket_options()` replaces the defaults entirely. Setting `SO_REUSEADDR` on Linux and macOS keeps the "restarted server can bind again right away" behavior.
> **Note:** `SO_REUSEADDR` alone isn't enough on Windows. Two sockets that both set it can bind to the same port, so use `SO_EXCLUSIVEADDRUSE` instead.
> **Note:** To auto-pick a free port, see [S17. Bind to any available port](../s17-bind-any-port). Under the hood, that's just `bind_to_any_port()` + `listen_after_bind()`.
@@ -0,0 +1,59 @@
---
title: "S23. Handle custom HTTP methods"
order: 42
status: "draft"
---
The server rejects HTTP methods it does not know with `400 Bad Request`. To accept an extension method, such as the WebDAV methods of RFC 4918 (`PROPFIND`, `PROPPATCH`, `MKCOL` and friends) or UPnP's `SUBSCRIBE`, register a handler with `CustomRoute()`. Registering the handler is what makes the server accept the method.
## Basic usage
```cpp
svr.CustomRoute("PROPFIND", "/dav/:id",
[](const httplib::Request &req, httplib::Response &res) {
// The request body is available as usual
auto id = req.path_params.at("id");
res.status = httplib::StatusCode::MultiStatus_207;
res.set_content(build_multistatus(req.body), "application/xml");
});
```
Patterns work the same way as they do for `Get()`. Regular expressions and path parameters are both available.
## Advertise your methods with OPTIONS
A WebDAV client asks the server about its capabilities with `OPTIONS` before doing anything else. cpp-httplib generates neither the `DAV:` header nor `Allow`, so return them yourself. Forget this and clients will turn you away even though your `PROPFIND` works.
```cpp
svr.Options("/dav/.*", [](const httplib::Request &req, httplib::Response &res) {
res.set_header("DAV", "1");
res.set_header("Allow", "OPTIONS, GET, HEAD, PROPFIND, PROPPATCH, MKCOL");
});
```
## Read the body as a stream
There is a content reader overload, just like the one on `Post()`. Use it when you would rather not hold a large XML document in memory all at once.
```cpp
svr.CustomRoute("REPORT", "/dav/.*",
[](const httplib::Request &req, httplib::Response &res,
const httplib::ContentReader &content_reader) {
content_reader([&](const char *data, size_t data_length) {
// Process it a chunk at a time
return true;
});
res.status = httplib::StatusCode::MultiStatus_207;
});
```
## Things to keep in mind
- The method name has to be a valid HTTP method token (RFC 9110), and it must be registered before you call `listen()`
- `GET`, `HEAD`, `POST`, `PUT`, `DELETE`, `CONNECT`, `OPTIONS`, `TRACE`, `PATCH` and `PRI` cannot be registered here. Use the dedicated methods for those
- A rejected registration makes `is_valid()` return `false` and `listen()` fail, so the server never starts holding a handler that would never run
- Static file serving and WebSocket upgrades stay `GET`/`HEAD` only
> **Note:** cpp-httplib takes you as far as routing the method. If you want to call it WebDAV, generating the `207 Multi-Status` XML, interpreting the `Depth` header and managing locks are all yours to implement. The protocol itself lives outside the library.
> For the basics of registering handlers, see [S01. Register GET / POST / PUT / DELETE handlers](../s01-handlers).
@@ -1,6 +1,6 @@
---
title: "T01. Choosing Between OpenSSL, mbedTLS, and wolfSSL"
order: 42
order: 43
status: "draft"
---
@@ -1,6 +1,6 @@
---
title: "T02. Control SSL Certificate Verification"
order: 43
order: 44
status: "draft"
---
+1 -1
View File
@@ -1,6 +1,6 @@
---
title: "T03. Start an SSL/TLS Server"
order: 44
order: 45
status: "draft"
---
+1 -1
View File
@@ -1,6 +1,6 @@
---
title: "T04. Configure mTLS"
order: 45
order: 46
status: "draft"
---
+1 -1
View File
@@ -1,6 +1,6 @@
---
title: "T05. Access the Peer Certificate on the Server Side"
order: 46
order: 47
status: "draft"
---
@@ -1,6 +1,6 @@
---
title: "W01. Implement a WebSocket Echo Server and Client"
order: 51
order: 52
status: "draft"
---
@@ -36,6 +36,7 @@ The `read()` return value is a `ReadResult` enum:
- `ReadResult::Text`: received a text message
- `ReadResult::Binary`: received a binary message
- `ReadResult::Fail`: error, or connection closed
- `ReadResult::Timeout`: a read timeout you set with `set_read_timeout()` elapsed with nothing received; the connection is still open. The compile-time default timeout closes the connection and is reported as `Fail` instead — see [W06. Set Timeouts](../w06-websocket-timeouts)
## Client: talk to the echo server
@@ -1,6 +1,6 @@
---
title: "W02. Set a WebSocket Heartbeat"
order: 52
order: 53
status: "draft"
---
@@ -75,6 +75,6 @@ The counter is reset whenever `read()` consumes an incoming Pong frame, so this
`max_missed_pongs` defaults to `0`, which means "never close the connection because of missing pongs." Pings are still sent on the heartbeat interval, but their responses aren't checked. If you want unresponsive-peer detection, set it explicitly to `1` or higher.
Even with `0`, a dead connection won't linger forever: while your code is inside `read()`, `CPPHTTPLIB_WEBSOCKET_READ_TIMEOUT_SECOND` (default **300 seconds = 5 minutes**) acts as a backstop and `read()` fails if no frame arrives in time. Think of `max_missed_pongs` as the knob for detecting an unresponsive peer **faster** than that.
On the server side, even with `0`, a dead connection won't linger forever: while a handler is inside `read()`, `CPPHTTPLIB_WEBSOCKET_SERVER_READ_TIMEOUT_SECOND` (default **300 seconds = 5 minutes**) acts as a backstop. A client has no backstop of its own — it waits forever unless you set a read timeout — so there `max_missed_pongs` is what notices an unresponsive peer at all. On either side, it is also how you notice one **faster** than that 5-minute fallback.
> For handling a closed connection, see [W03. Handle connection close](../w03-websocket-close).
@@ -1,6 +1,6 @@
---
title: "W03. Handle Connection Close"
order: 53
order: 54
status: "draft"
---
@@ -1,6 +1,6 @@
---
title: "W04. Send and Receive Binary Frames"
order: 54
order: 55
status: "draft"
---
@@ -42,6 +42,9 @@ switch (result) {
case httplib::ws::ReadResult::Fail:
// error or closed
break;
case httplib::ws::ReadResult::Timeout:
// read timeout elapsed; the connection is still open
break;
}
```
@@ -1,6 +1,6 @@
---
title: "W05. Configure TLS for wss:// Connections"
order: 55
order: 56
status: "draft"
---
@@ -1,6 +1,6 @@
---
title: "W06. Set Timeouts"
order: 56
order: 57
status: "draft"
---
@@ -9,7 +9,7 @@ status: "draft"
| Kind | API | Default |
| --- | --- | --- |
| Connection | `set_connection_timeout` | 300s |
| Read | `set_read_timeout` | 300s (`CPPHTTPLIB_WEBSOCKET_READ_TIMEOUT_SECOND`) |
| Read | `set_read_timeout` | none — waits forever (`CPPHTTPLIB_WEBSOCKET_CLIENT_READ_TIMEOUT_SECOND`) |
| Write | `set_write_timeout` | 5s |
## Basic usage
@@ -26,7 +26,7 @@ if (ws.connect()) {
}
```
Set these before calling `connect()`.
Set the connection and write timeouts before calling `connect()`. The read timeout can be changed at any time — setting it on an open connection takes effect on the next `read()`.
## Use `std::chrono`
@@ -40,9 +40,44 @@ ws.set_read_timeout(30s);
ws.set_write_timeout(10s);
```
## Watch out for what the read timeout means
## What the read timeout means
`set_read_timeout()` applies to a single `read()` call. If no message arrives within that time, `read()` returns `ReadResult::Fail`. For connections where long idle periods are normal — waiting on notifications, for example — set a longer timeout, or reconnect from your application code when the read fails.
`set_read_timeout()` applies to a single `read()` call. If no message arrives within that time, `read()` returns `ReadResult::Timeout`: **the connection is still open** and nothing was consumed, so you can send on it and read again. That is what separates it from `ReadResult::Fail`, which means the connection is gone.
This is what lets one thread own a connection in both directions:
```cpp
using namespace std::chrono_literals;
ws.set_read_timeout(100ms);
std::string msg;
while (ws.is_open()) {
auto r = ws.read(msg);
if (r == httplib::ws::Timeout) {
flush_outgoing(ws); // nothing arrived — send whatever is queued
continue;
}
if (r == httplib::ws::Fail) { break; }
handle(msg);
}
```
Without a read timeout, `read()` blocks until a message arrives, so the thread holding the connection never gets to its writes.
Two things to know about `Timeout`:
- It leaves `msg` untouched, and it is non-zero. So `while (ws.read(msg))` is not usable once a read timeout is set — the loop would keep running with the *previous* message still in `msg`.
- It is only reported on a message boundary. If the timeout elapses partway through a fragmented message, that message cannot be resumed and `read()` returns `Fail`.
For connections where long idle periods are normal — waiting on notifications, for example — either leave the read timeout unset, or treat `Timeout` as the no-op it is and keep looping.
## On the server side
A handler's `ws::WebSocket` has `set_read_timeout()` too, and the pattern above is how a handler relays between connections instead of parking in `read()`.
The server default is 300s (`CPPHTTPLIB_WEBSOCKET_SERVER_READ_TIMEOUT_SECOND`) rather than "forever": it is a backstop that reclaims a worker from a peer that has gone silent, since a WebSocket handler holds its worker for the life of the connection.
Because it is a backstop rather than something the handler asked for, it does not surface as `Timeout`. When it elapses, `read()` returns `Fail` and closes the connection, so a handler written as `while (ws.read(msg))` ends the way it always has. Only a timeout the handler set itself with `set_read_timeout()` comes back as `Timeout`.
> Unresponsive-peer detection via Ping/Pong is a separate mechanism. See [W02. Set a WebSocket Heartbeat](../w02-websocket-ping) for details.
+2
View File
@@ -107,6 +107,8 @@ svr.WebSocket("/ws", [](const httplib::Request &req, httplib::ws::WebSocket &ws)
});
```
A check inside the handler runs after the handshake has completed. To refuse the connection with an HTTP status such as 401 before it is upgraded, use `set_pre_request_handler()` instead. It also runs for WebSocket routes. See [S11. Authenticate per route with a pre-request handler](../../cookbook/s11-pre-request).
## Using WSS
WebSocket over HTTPS (WSS) is also supported. On the server side, just register a WebSocket handler on `httplib::SSLServer`.
+1 -1
View File
@@ -1,6 +1,6 @@
---
title: "E01. SSEサーバーを実装する"
order: 47
order: 48
status: "draft"
---
@@ -1,6 +1,6 @@
---
title: "E02. SSEでイベント名を使い分ける"
order: 48
order: 49
status: "draft"
---
@@ -1,6 +1,6 @@
---
title: "E03. SSEの再接続を処理する"
order: 49
order: 50
status: "draft"
---
+1 -1
View File
@@ -1,6 +1,6 @@
---
title: "E04. SSEをクライアントで受信する"
order: 50
order: 51
status: "draft"
---
+3
View File
@@ -73,6 +73,9 @@ status: "draft"
- [S21. マルチスレッド数を設定する](s21-thread-pool)
- [S22. Unix domain socketで通信する](s22-unix-socket)
### プロトコル拡張
- [S23. カスタムHTTPメソッドを扱う](s23-custom-methods)
## TLS / セキュリティ
- [T01. OpenSSL・mbedTLS・wolfSSLの選択指針](t01-tls-backends)
+3 -1
View File
@@ -4,7 +4,7 @@ order: 20
status: "draft"
---
`httplib::Server`では、HTTPメソッドごとにハンドラを登録します。`Get()`、`Post()`、`Put()`、`Delete()`の各メソッドにパターンとラムダを渡すだけです。
`httplib::Server`では、HTTPメソッドごとにハンドラを登録します。`Get()`、`Post()`、`Put()`、`Delete()`の各メソッドにパターンとラムダを渡すだけです。WebDAVの`PROPFIND`のような組み込み以外のメソッドを扱いたいときは、`CustomRoute()`を使います。
## 基本の使い方
@@ -64,3 +64,5 @@ svr.Get("/me", [](const httplib::Request &req, httplib::Response &res) {
> **Note:** `listen()`はブロックする関数です。別スレッドで動かしたいときは`std::thread`で包むか、ノンブロッキング起動が必要なら[S18. `listen_after_bind`で起動順序を制御する](../s18-listen-after-bind)を参照してください。
> パスパラメーター(`/users/:id`)を使いたい場合は[S03. パスパラメーターを使う](../s03-path-params)を参照してください。
> WebDAVの`PROPFIND`のような組み込み以外のメソッドは[S23. カスタムHTTPメソッドを扱う](../s23-custom-methods)を参照してください。
@@ -66,6 +66,38 @@ svr.Post("/upload",
メモリには常に小さなチャンクしか載らないので、ギガバイト級のファイルでも扱えます。
## パート数は自分で数える
`CPPHTTPLIB_MULTIPART_FORM_DATA_FILE_MAX_COUNT`(デフォルト1024)というパート数の上限がありますが、これが効くのは`req.form`にすべてのパートを溜め込むバッファリング側だけです。`ContentReader`はライブラリ側で何も溜め込まないので、この上限は適用されません。
パート数に上限をつけたいときは、自分で数えてヘッダーのコールバックから`false`を返してください。パースはその場で止まります。
```cpp
svr.Post("/upload",
[](const httplib::Request &req, httplib::Response &res,
const httplib::ContentReader &content_reader) {
size_t count = 0;
auto ok = content_reader(
[&](const httplib::FormData &file) {
if (++count > 100) { return false; } // ここで打ち切る
return true;
},
[&](const char *data, size_t len) {
return true;
});
if (!ok) {
res.status = httplib::StatusCode::BadRequest_400;
return;
}
res.set_content("ok", "text/plain");
});
```
`content_reader`が`false`を返したら、レスポンスのステータスは自分でセットしてください。ボディの残りは読まずに接続を閉じるので、送信中のクライアントには接続が切れたように見えます。
> **Warning:** `HandlerWithContentReader`を使うと、`req.body`は**空のまま**です。ボディはコールバック内で自分で処理してください。
> クライアント側でマルチパートを送る方法は[C07. ファイルをマルチパートフォームとしてアップロードする](../c07-multipart-upload)を参照してください。
@@ -48,6 +48,31 @@ svr.Get("/events", [](const httplib::Request &req, httplib::Response &res) {
});
```
> **Note:** 小さなレスポンスは圧縮しても効果が薄く、むしろCPU時間を無駄にすることがあります。cpp-httplibは小さすぎるボディは圧縮をスキップします。
## 静的ファイルは明示的に有効にする
`set_mount_point()`や`Response::set_file_content()`でファイルをそのまま返す場合、デフォルトでは圧縮されません。有効にするには次を呼びます。
```cpp
svr.set_static_file_compression(true);
```
圧縮の対象になるのは一定のサイズ範囲に収まるファイルだけで、上下どちらの境界も変更できます。
```cpp
svr.set_static_file_compression_min_length(512);
svr.set_static_file_compression_max_length(1024 * 1024);
```
下限のデフォルトは1400バイトです。1500バイトのMTUに収まるレスポンスは、小さくしたところで到達が速くなるわけではありません。さらに数バイトのファイルは、gzipのヘッダとトレーラがdeflateの削減分を上回るため、かえって大きくなって返ります。
上限のデフォルトは4MBで、こちらは理由が違います。リクエストのたびに圧縮が走り、圧縮後のバイト列はレスポンスを書き終えるまでメモリに載るため、ピーク時のコストが同時処理中のリクエスト数に比例するからです。つまり1リクエストあたりのコストを抑えるための値であって、大きいファイルは圧縮しても無駄だという意味ではありません。配信するファイルが分かっていてトラフィックがそれほど多くないなら、引き上げて構いません。
どちらの境界も`0`で無効にできます。コンパイル時のデフォルトは`CPPHTTPLIB_STATIC_FILE_COMPRESSION_MIN_LENGTH`と`CPPHTTPLIB_STATIC_FILE_COMPRESSION_MAX_LENGTH`で決まります。
圧縮しても`Content-Length`は付いたままなので、`HEAD`は`GET`と同じサイズを返します。細かい挙動として、Rangeリクエストは非圧縮の表現から切り出して返し、`ETag`には`W/"...-gzip"`のように使われた圧縮方式が入ります。
なお`set_content_provider()`で登録したコンテンツプロバイダは対象外です。圧縮器を通すと、内部バッファが埋まるまで書き込みが送出されず、ボディを少しずつ生成するプロバイダが止まってしまうためです。生成したボディを圧縮したい場合は`set_chunked_content_provider()`を使ってください。
> **Note:** サイズ範囲が効くのは静的ファイルだけです。`set_content()`に渡したボディは、圧縮対象のMIMEタイプでクライアントが受け入れていれば、大きさによらず圧縮されます。数バイトのレスポンスはgzipのヘッダ分だけかえって大きくなるので、避けたい場合はハンドラ側で判断してください。
> クライアント側の挙動は[C15. 圧縮を有効にする](../c15-compression)を参照してください。
@@ -37,6 +37,8 @@ svr.set_pre_request_handler(
`matched_route`はパスパラメーターを展開する**前**のパターン文字列(例: `/admin/users/:id`)です。特定の値ではなく、ルート定義のパターンで判定できるので、IDや名前に左右されません。
`svr.WebSocket()`で登録したルートでも、Pre-requestハンドラは呼ばれます。呼ばれるのは`101 Switching Protocols`を返す前なので、`Handled`を返すとそのHTTPレスポンス(403など)がそのまま返り、WebSocketへのアップグレードは行われません。
## 戻り値の意味
Pre-routingハンドラと同じく、`HandlerResponse`を返します。
@@ -43,15 +43,40 @@ svr.listen_after_bind();
## 戻り値のチェック
`bind_to_port()`は失敗すると`false`を返します。ポートが既に使われている場合などです。必ずチェックしてください。
`bind_to_port()`は失敗すると`false`を返します。ポートにbindする権限が無い場合などです。必ずチェックしてください。
```cpp
if (!svr.bind_to_port("0.0.0.0", 8080)) {
std::cerr << "port already in use" << std::endl;
std::cerr << "bind failed" << std::endl;
return 1;
}
```
`listen_after_bind()`はサーバーが停止するまでブロックし、正常終了なら`true`を返します。
## 使用中のポートを検出する
実は、デフォルトの設定では、ほかのサーバーが使っているポートにもbindできてしまいます。cpp-httplibがサーバーソケットに`SO_REUSEPORT`(Linux、macOS)か`SO_REUSEADDR`(Windows)を設定しているからです。再起動したサーバーはすぐにbindし直せます。その代わり、同じポートで2つ目のサーバーを起動してもエラーにならず、接続が両方に振り分けられます。
使用中のポートで`bind_to_port()`を失敗させたいときは、`set_socket_options()`でソケットオプションを差し替えてください。
```cpp
svr.set_socket_options([](socket_t sock) {
#ifdef _WIN32
httplib::set_socket_opt(sock, SOL_SOCKET, SO_EXCLUSIVEADDRUSE, 1);
#else
httplib::set_socket_opt(sock, SOL_SOCKET, SO_REUSEADDR, 1);
#endif
});
if (!svr.bind_to_port("0.0.0.0", 8080)) {
std::cerr << "port already in use" << std::endl;
return 1;
}
```
`set_socket_options()`はデフォルトの設定を丸ごと置き換えます。Linux、macOSで`SO_REUSEADDR`を設定しているのは、再起動したサーバーがすぐにbindし直せるようにするためです。
> **Note:** Windowsでは`SO_REUSEADDR`だけでは足りません。お互いに`SO_REUSEADDR`を設定したソケット同士は、同じポートにbindできてしまいます。`SO_EXCLUSIVEADDRUSE`を使ってください。
> **Note:** 空いているポートを自動で選びたいときは[S17. ポートを動的に割り当てる](../s17-bind-any-port)を参照してください。こちらも内部では`bind_to_any_port()` + `listen_after_bind()`の組み合わせです。
@@ -0,0 +1,59 @@
---
title: "S23. カスタムHTTPメソッドを扱う"
order: 42
status: "draft"
---
サーバーは知らないHTTPメソッドを`400 Bad Request`で弾きます。RFC 4918のWebDAVメソッド(`PROPFIND`、`PROPPATCH`、`MKCOL`など)やUPnPの`SUBSCRIBE`のような拡張メソッドを受け付けたいときは、`CustomRoute()`でハンドラを登録してください。登録したことがそのまま「このメソッドを受け付ける」という意味になります。
## 基本の使い方
```cpp
svr.CustomRoute("PROPFIND", "/dav/:id",
[](const httplib::Request &req, httplib::Response &res) {
// リクエストボディも通常どおり読める
auto id = req.path_params.at("id");
res.status = httplib::StatusCode::MultiStatus_207;
res.set_content(build_multistatus(req.body), "application/xml");
});
```
パターンの書き方は`Get()`などと同じです。正規表現もパスパラメーターもそのまま使えます。
## OPTIONSで対応メソッドを知らせる
WebDAVクライアントは接続すると、まず`OPTIONS`でサーバーの能力を問い合わせます。cpp-httplibは`DAV:`ヘッダーも`Allow`ヘッダーも自動生成しないので、自分で返してください。ここを忘れると、`PROPFIND`が正しく動いてもクライアントに拒否されます。
```cpp
svr.Options("/dav/.*", [](const httplib::Request &req, httplib::Response &res) {
res.set_header("DAV", "1");
res.set_header("Allow", "OPTIONS, GET, HEAD, PROPFIND, PROPPATCH, MKCOL");
});
```
## ボディをストリーミングで受け取る
`Post()`などと同じく、Content Reader版のオーバーロードがあります。大きなXMLを一度にメモリへ載せたくないときに使ってください。
```cpp
svr.CustomRoute("REPORT", "/dav/.*",
[](const httplib::Request &req, httplib::Response &res,
const httplib::ContentReader &content_reader) {
content_reader([&](const char *data, size_t data_length) {
// 少しずつ処理する
return true;
});
res.status = httplib::StatusCode::MultiStatus_207;
});
```
## 覚えておくこと
- メソッド名はHTTPのトークン(RFC 9110)である必要があります。`listen()`より前に登録してください
- `GET`、`HEAD`、`POST`、`PUT`、`DELETE`、`CONNECT`、`OPTIONS`、`TRACE`、`PATCH`、`PRI`は登録できません。これらには専用のメソッドを使ってください
- 登録が拒否されると`is_valid()`が`false`になり、`listen()`が失敗します。呼ばれないハンドラを抱えたままサーバーが起動することはありません
- 静的ファイルの配信とWebSocketのアップグレードは`GET`/`HEAD`のままです
> **Note:** cpp-httplibが用意するのはメソッドのルーティングまでです。WebDAVを名乗るなら、`207 Multi-Status`のXML生成、`Depth`ヘッダーの解釈、ロックの管理は自分で実装することになります。プロトコルの本体はライブラリの外側です。
> ハンドラ登録の基本は[S01. GET / POST / PUT / DELETEハンドラを登録する](../s01-handlers)を参照してください。
@@ -1,6 +1,6 @@
---
title: "T01. OpenSSL・mbedTLS・wolfSSLの選択指針"
order: 42
order: 43
status: "draft"
---
@@ -1,6 +1,6 @@
---
title: "T02. SSL証明書の検証を制御する"
order: 43
order: 44
status: "draft"
---
+1 -1
View File
@@ -1,6 +1,6 @@
---
title: "T03. SSL/TLSサーバーを立ち上げる"
order: 44
order: 45
status: "draft"
---
+1 -1
View File
@@ -1,6 +1,6 @@
---
title: "T04. mTLSを設定する"
order: 45
order: 46
status: "draft"
---
+1 -1
View File
@@ -1,6 +1,6 @@
---
title: "T05. サーバー側でピア証明書を参照する"
order: 46
order: 47
status: "draft"
---
@@ -1,6 +1,6 @@
---
title: "W01. WebSocketエコーサーバー/クライアントを実装する"
order: 51
order: 52
status: "draft"
---
@@ -31,11 +31,12 @@ int main() {
`svr.WebSocket()`でWebSocket用のハンドラを登録します。ハンドラが呼ばれた時点で、すでにWebSocketのハンドシェイクは完了しています。ループの中で`ws.read()`して`ws.send()`するだけで、エコー動作が完成します。
`read()`の返り値は`ReadResult`列挙値で、次の3種類です。
`read()`の返り値は`ReadResult`列挙値で、次の4種類です。
- `ReadResult::Text`: テキストメッセージを受信
- `ReadResult::Binary`: バイナリメッセージを受信
- `ReadResult::Fail`: エラー、または接続が閉じた
- `ReadResult::Timeout`: `set_read_timeout()`で自分が設定した読み取りタイムアウトが、何も受信しないまま経過した。接続は開いたまま。コンパイル時のデフォルトのタイムアウトは接続を閉じ、`Fail`として返る([W06. タイムアウトを設定する](../w06-websocket-timeouts)を参照)
## クライアント: エコーを叩く
@@ -1,6 +1,6 @@
---
title: "W02. ハートビートを設定する"
order: 52
order: 53
status: "draft"
---
@@ -75,6 +75,6 @@ cli.set_websocket_max_missed_pongs(2); // 2回連続でPongが返ってこなけ
`max_missed_pongs`のデフォルトは`0`で、これは「Pongが何回返ってこなくてもこの仕組みでは切断しない」という意味です。Ping自体は送られ続けますが、応答の有無はチェックされません。無応答ピアを検出したい場合は明示的に`1`以上を設定してください。
ただし`0`のままでも最終的に接続が残り続けることはありません。`read()`を呼んでいる間は`CPPHTTPLIB_WEBSOCKET_READ_TIMEOUT_SECOND`(デフォルト**300秒 = 5分**)が保険として働き、フレームが一定時間来なければ`read()`が失敗します。つまり`max_missed_pongs`は「**もっと速く**無応答を検出したい」ときに使うオプションだと考えてください。
サーバ側は`0`のままでも接続が残り続けることはありません。ハンドラが`read()`を呼んでいる間は`CPPHTTPLIB_WEBSOCKET_SERVER_READ_TIMEOUT_SECOND`(デフォルト**300秒 = 5分**)が保険として働きます。一方クライアント側にはこの保険がなく、読み取りタイムアウトを設定しない限り無期限に待つので、無応答ピアを検出する手段は`max_missed_pongs`だけです。どちらの側でも「**もっと速く**検出したい」ときに使うオプションでもあります。
> 接続が閉じたときの処理は[W03. 接続クローズをハンドリングする](../w03-websocket-close)を参照してください。
@@ -1,6 +1,6 @@
---
title: "W03. 接続クローズをハンドリングする"
order: 53
order: 54
status: "draft"
---
@@ -1,6 +1,6 @@
---
title: "W04. バイナリフレームを送受信する"
order: 54
order: 55
status: "draft"
---
@@ -42,6 +42,9 @@ switch (result) {
case httplib::ws::ReadResult::Fail:
// エラーまたは切断
break;
case httplib::ws::ReadResult::Timeout:
// 読み取りタイムアウト。接続は開いたまま
break;
}
```
@@ -1,6 +1,6 @@
---
title: "W05. wss接続でTLSを設定する"
order: 55
order: 56
status: "draft"
---
@@ -1,6 +1,6 @@
---
title: "W06. タイムアウトを設定する"
order: 56
order: 57
status: "draft"
---
@@ -9,7 +9,7 @@ status: "draft"
| 種類 | API | デフォルト |
| --- | --- | --- |
| 接続タイムアウト | `set_connection_timeout` | 300秒 |
| 読み取りタイムアウト | `set_read_timeout` | 300秒(`CPPHTTPLIB_WEBSOCKET_READ_TIMEOUT_SECOND`) |
| 読み取りタイムアウト | `set_read_timeout` | なし。無期限に待つ(`CPPHTTPLIB_WEBSOCKET_CLIENT_READ_TIMEOUT_SECOND`) |
| 書き込みタイムアウト | `set_write_timeout` | 5秒 |
## 基本の使い方
@@ -26,7 +26,7 @@ if (ws.connect()) {
}
```
`connect()`を呼ぶ前に設定してください。
接続タイムアウトと書き込みタイムアウトは`connect()`を呼ぶ前に設定してください。読み取りタイムアウトはいつでも変更でき、接続済みの状態で設定した場合は次の`read()`から効きます。
## `std::chrono`で指定する
@@ -40,9 +40,44 @@ ws.set_read_timeout(30s);
ws.set_write_timeout(10s);
```
## 読み取りタイムアウトの意味に注意
## 読み取りタイムアウトの意味
`set_read_timeout()`は「1回の`read()`呼び出し」に対するタイムアウトです。メッセージが届かないまま指定時間が経過すると`read()`が`ReadResult::Fail`を返します。通知の待受のように長時間メッセージが来ないことが正常な接続では、意図せず切断されないよう長めに設定するか、切断されたらアプリケーション側で再接続してください。
`set_read_timeout()`は「1回の`read()`呼び出し」に対するタイムアウトです。メッセージが届かないまま指定時間が経過すると`read()`は`ReadResult::Timeout`を返します。このとき**接続は開いたまま**で、1バイトも読み進めていないので、そのまま送信して読み直せます。接続が失われたことを意味する`ReadResult::Fail`とはここが違います。
1本の接続を1つのスレッドで双方向に扱えるのはこのためです。
```cpp
using namespace std::chrono_literals;
ws.set_read_timeout(100ms);
std::string msg;
while (ws.is_open()) {
auto r = ws.read(msg);
if (r == httplib::ws::Timeout) {
flush_outgoing(ws); // 何も届いていない。溜まっている分を送る
continue;
}
if (r == httplib::ws::Fail) { break; }
handle(msg);
}
```
読み取りタイムアウトを設定しないと`read()`はメッセージが届くまで戻らないので、接続を持っているスレッドは送信に手が回りません。
`Timeout`について2点あります。
- `msg`は書き換えられません。値も0以外なので、読み取りタイムアウトを設定した状態で`while (ws.read(msg))`と書くと、**前回のメッセージ**が`msg`に残ったままループが回り続けます。
- 報告されるのはメッセージの境界だけです。分割されたメッセージの途中でタイムアウトした場合、そのメッセージは再開できないので`read()`は`Fail`を返します。
通知の待受のように長時間メッセージが来ないことが正常な接続では、読み取りタイムアウトを設定しないままにするか、`Timeout`を「まだ何も来ていない」印として扱ってループを続けてください。
## サーバ側
ハンドラが受け取る`ws::WebSocket`にも`set_read_timeout()`があります。ハンドラが`read()`で止まったままにならないので、上と同じ書き方で複数の接続の間をメッセージが中継できます。
サーバ側のデフォルトは「無期限」ではなく300秒(`CPPHTTPLIB_WEBSOCKET_SERVER_READ_TIMEOUT_SECOND`)です。WebSocketのハンドラは接続が続く限りワーカーを1つ占有するので、無言になったピアからワーカーを回収する保険として働きます。
この保険はハンドラが求めたタイムアウトではないので、`Timeout`としては返りません。経過すると`read()`は`Fail`を返して接続を閉じるため、`while (ws.read(msg))`と書いたハンドラは従来どおりそこで終わります。`Timeout`が返るのは、ハンドラ自身が`set_read_timeout()`で設定したタイムアウトだけです。
> Ping/Pongによる無応答ピア検出は別の仕組みです。詳しくは[W02. ハートビートを設定する](../w02-websocket-ping)を参照してください。
+2
View File
@@ -107,6 +107,8 @@ svr.WebSocket("/ws", [](const httplib::Request &req, httplib::ws::WebSocket &ws)
});
```
ハンドラー内のチェックは、ハンドシェイクが完了した後に行われます。アップグレードする前に401などのHTTPステータスで接続を拒否したい場合は、`set_pre_request_handler()`を使ってください。WebSocketのルートでも呼ばれます。詳しくは[S11. Pre-request handlerでルート単位の認証を行う](../../cookbook/s11-pre-request)を参照してください。
## WSSで使う
HTTPS上のWebSocket(WSS)にも対応しています。サーバー側は `httplib::SSLServer` にWebSocketハンドラーを登録するだけです。
+134 -124
View File
@@ -2,133 +2,143 @@
#include <iostream>
int main() {
using namespace httplib;
// Example usage of parse_accept_header function
std::cout << "=== Accept Header Parser Example ===" << std::endl;
// Example 1: Simple Accept header
std::string accept1 = "text/html,application/json,text/plain";
std::vector<std::string> result1;
if (detail::parse_accept_header(accept1, result1)) {
std::cout << "\nExample 1: " << accept1 << std::endl;
std::cout << "Parsed order:" << std::endl;
for (size_t i = 0; i < result1.size(); ++i) {
std::cout << " " << (i + 1) << ". " << result1[i] << std::endl;
}
using namespace httplib;
// Example usage of parse_accept_header function
std::cout << "=== Accept Header Parser Example ===" << std::endl;
// Example 1: Simple Accept header
std::string accept1 = "text/html,application/json,text/plain";
std::vector<std::string> result1;
if (detail::parse_accept_header(accept1, result1)) {
std::cout << "\nExample 1: " << accept1 << std::endl;
std::cout << "Parsed order:" << std::endl;
for (size_t i = 0; i < result1.size(); ++i) {
std::cout << " " << (i + 1) << ". " << result1[i] << std::endl;
}
} else {
std::cout << "\nExample 1: Failed to parse Accept header" << std::endl;
}
// Example 2: Accept header with quality values
std::string accept2 =
"text/html;q=0.9,application/json;q=1.0,text/plain;q=0.8";
std::vector<std::string> result2;
if (detail::parse_accept_header(accept2, result2)) {
std::cout << "\nExample 2: " << accept2 << std::endl;
std::cout << "Parsed order (sorted by priority):" << std::endl;
for (size_t i = 0; i < result2.size(); ++i) {
std::cout << " " << (i + 1) << ". " << result2[i] << std::endl;
}
} else {
std::cout << "\nExample 2: Failed to parse Accept header" << std::endl;
}
// Example 3: Browser-like Accept header
std::string accept3 = "text/html,application/xhtml+xml,application/"
"xml;q=0.9,image/webp,*/*;q=0.8";
std::vector<std::string> result3;
if (detail::parse_accept_header(accept3, result3)) {
std::cout << "\nExample 3: " << accept3 << std::endl;
std::cout << "Parsed order:" << std::endl;
for (size_t i = 0; i < result3.size(); ++i) {
std::cout << " " << (i + 1) << ". " << result3[i] << std::endl;
}
} else {
std::cout << "\nExample 3: Failed to parse Accept header" << std::endl;
}
// Example 4: Invalid Accept header examples
std::cout << "\n=== Invalid Accept Header Examples ===" << std::endl;
std::vector<std::string> invalid_examples = {
"text/html;q=1.5,application/json", // q > 1.0
"text/html;q=-0.1,application/json", // q < 0.0
"text/html;q=invalid,application/json", // invalid q value
"invalidtype,application/json" // invalid media type
};
for (const auto &invalid_accept : invalid_examples) {
std::vector<std::string> temp_result;
std::cout << "\nTesting invalid: " << invalid_accept << std::endl;
if (detail::parse_accept_header(invalid_accept, temp_result)) {
std::cout << " Unexpectedly succeeded!" << std::endl;
} else {
std::cout << "\nExample 1: Failed to parse Accept header" << std::endl;
std::cout << " Correctly rejected as invalid" << std::endl;
}
// Example 2: Accept header with quality values
std::string accept2 = "text/html;q=0.9,application/json;q=1.0,text/plain;q=0.8";
std::vector<std::string> result2;
if (detail::parse_accept_header(accept2, result2)) {
std::cout << "\nExample 2: " << accept2 << std::endl;
std::cout << "Parsed order (sorted by priority):" << std::endl;
for (size_t i = 0; i < result2.size(); ++i) {
std::cout << " " << (i + 1) << ". " << result2[i] << std::endl;
}
} else {
std::cout << "\nExample 2: Failed to parse Accept header" << std::endl;
}
// Example 4: Server usage example
std::cout << "\n=== Server Usage Example ===" << std::endl;
Server svr;
svr.Get("/api/data", [](const Request &req, Response &res) {
// Get Accept header
auto accept_header = req.get_header_value("Accept");
if (accept_header.empty()) {
accept_header = "*/*"; // Default if no Accept header
}
// Example 3: Browser-like Accept header
std::string accept3 = "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8";
std::vector<std::string> result3;
if (detail::parse_accept_header(accept3, result3)) {
std::cout << "\nExample 3: " << accept3 << std::endl;
std::cout << "Parsed order:" << std::endl;
for (size_t i = 0; i < result3.size(); ++i) {
std::cout << " " << (i + 1) << ". " << result3[i] << std::endl;
}
} else {
std::cout << "\nExample 3: Failed to parse Accept header" << std::endl;
// Parse accept header to get preferred content types
std::vector<std::string> preferred_types;
if (!detail::parse_accept_header(accept_header, preferred_types)) {
// Invalid Accept header
res.status = 400; // Bad Request
res.set_content("Invalid Accept header", "text/plain");
return;
}
// Example 4: Invalid Accept header examples
std::cout << "\n=== Invalid Accept Header Examples ===" << std::endl;
std::vector<std::string> invalid_examples = {
"text/html;q=1.5,application/json", // q > 1.0
"text/html;q=-0.1,application/json", // q < 0.0
"text/html;q=invalid,application/json", // invalid q value
"invalidtype,application/json", // invalid media type
",application/json" // empty entry
};
for (const auto& invalid_accept : invalid_examples) {
std::vector<std::string> temp_result;
std::cout << "\nTesting invalid: " << invalid_accept << std::endl;
if (detail::parse_accept_header(invalid_accept, temp_result)) {
std::cout << " Unexpectedly succeeded!" << std::endl;
} else {
std::cout << " Correctly rejected as invalid" << std::endl;
}
std::cout << "Client Accept header: " << accept_header << std::endl;
std::cout << "Preferred types in order:" << std::endl;
for (size_t i = 0; i < preferred_types.size(); ++i) {
std::cout << " " << (i + 1) << ". " << preferred_types[i] << std::endl;
}
// Example 4: Server usage example
std::cout << "\n=== Server Usage Example ===" << std::endl;
Server svr;
svr.Get("/api/data", [](const Request& req, Response& res) {
// Get Accept header
auto accept_header = req.get_header_value("Accept");
if (accept_header.empty()) {
accept_header = "*/*"; // Default if no Accept header
}
// Parse accept header to get preferred content types
std::vector<std::string> preferred_types;
if (!detail::parse_accept_header(accept_header, preferred_types)) {
// Invalid Accept header
res.status = 400; // Bad Request
res.set_content("Invalid Accept header", "text/plain");
return;
}
std::cout << "Client Accept header: " << accept_header << std::endl;
std::cout << "Preferred types in order:" << std::endl;
for (size_t i = 0; i < preferred_types.size(); ++i) {
std::cout << " " << (i + 1) << ". " << preferred_types[i] << std::endl;
}
// Choose response format based on client preference
std::string response_content;
std::string content_type;
for (const auto& type : preferred_types) {
if (type == "application/json" || type == "application/*" || type == "*/*") {
response_content = "{\"message\": \"Hello, World!\", \"data\": [1, 2, 3]}";
content_type = "application/json";
break;
} else if (type == "text/html" || type == "text/*") {
response_content = "<html><body><h1>Hello, World!</h1><p>Data: 1, 2, 3</p></body></html>";
content_type = "text/html";
break;
} else if (type == "text/plain") {
response_content = "Hello, World!\nData: 1, 2, 3";
content_type = "text/plain";
break;
}
}
if (response_content.empty()) {
// No supported content type found
res.status = 406; // Not Acceptable
res.set_content("No acceptable content type found", "text/plain");
return;
}
res.set_content(response_content, content_type);
std::cout << "Responding with: " << content_type << std::endl;
});
std::cout << "Server configured. You can test it with:" << std::endl;
std::cout << " curl -H \"Accept: application/json\" http://localhost:8080/api/data" << std::endl;
std::cout << " curl -H \"Accept: text/html\" http://localhost:8080/api/data" << std::endl;
std::cout << " curl -H \"Accept: text/plain\" http://localhost:8080/api/data" << std::endl;
std::cout << " curl -H \"Accept: text/html;q=0.9,application/json;q=1.0\" http://localhost:8080/api/data" << std::endl;
return 0;
// Choose response format based on client preference
std::string response_content;
std::string content_type;
for (const auto &type : preferred_types) {
if (type == "application/json" || type == "application/*" ||
type == "*/*") {
response_content =
"{\"message\": \"Hello, World!\", \"data\": [1, 2, 3]}";
content_type = "application/json";
break;
} else if (type == "text/html" || type == "text/*") {
response_content = "<html><body><h1>Hello, World!</h1><p>Data: 1, 2, "
"3</p></body></html>";
content_type = "text/html";
break;
} else if (type == "text/plain") {
response_content = "Hello, World!\nData: 1, 2, 3";
content_type = "text/plain";
break;
}
}
if (response_content.empty()) {
// No supported content type found
res.status = 406; // Not Acceptable
res.set_content("No acceptable content type found", "text/plain");
return;
}
res.set_content(response_content, content_type);
std::cout << "Responding with: " << content_type << std::endl;
});
std::cout << "Server configured. You can test it with:" << std::endl;
std::cout
<< " curl -H \"Accept: application/json\" http://localhost:8080/api/data"
<< std::endl;
std::cout << " curl -H \"Accept: text/html\" http://localhost:8080/api/data"
<< std::endl;
std::cout << " curl -H \"Accept: text/plain\" http://localhost:8080/api/data"
<< std::endl;
std::cout << " curl -H \"Accept: text/html;q=0.9,application/json;q=1.0\" "
"http://localhost:8080/api/data"
<< std::endl;
return 0;
}
+1975 -561
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -107,7 +107,7 @@ if host_machine.system() == 'windows'
elif host_machine.system() == 'darwin'
async_ns_dep = dependency('appleframeworks', modules: ['CFNetwork', 'CoreFoundation'], required: async_ns_opt)
else
has_gai_a = cxx.has_function('getaddrinfo_a', args: '-D_GNU_SOURCE')
has_gai_a = async_ns_opt.allowed() and cxx.has_function('getaddrinfo_a', args: '-D_GNU_SOURCE')
if has_gai_a
async_ns_dep = declare_dependency()
else
+7 -3
View File
@@ -164,14 +164,18 @@ if [ "$DRY_RUN" -eq 1 ]; then
echo "==> Dry run complete. No changes were made."
else
echo "==> Updating httplib.h..."
sed -i '' "s/#define CPPHTTPLIB_VERSION \"[^\"]*\"/#define CPPHTTPLIB_VERSION \"$NEW_VERSION\"/" httplib.h
sed -i '' "s/#define CPPHTTPLIB_VERSION_NUM \"0x[0-9a-fA-F]*\"/#define CPPHTTPLIB_VERSION_NUM \"$VERSION_HEX\"/" httplib.h
# `-i.bak` is the in-place form GNU and BSD sed both accept (`-i ''` is
# BSD-only: GNU sed reads the '' as the script).
sed -i.bak "s/#define CPPHTTPLIB_VERSION \"[^\"]*\"/#define CPPHTTPLIB_VERSION \"$NEW_VERSION\"/" httplib.h
sed -i.bak "s/#define CPPHTTPLIB_VERSION_NUM \"0x[0-9a-fA-F]*\"/#define CPPHTTPLIB_VERSION_NUM \"$VERSION_HEX\"/" httplib.h
rm -f httplib.h.bak
echo " CPPHTTPLIB_VERSION = \"$NEW_VERSION\""
echo " CPPHTTPLIB_VERSION_NUM = \"$VERSION_HEX\""
echo ""
echo "==> Updating docs-src/config.toml..."
sed -i '' "s/^version = \"[^\"]*\"/version = \"$NEW_VERSION\"/" docs-src/config.toml
sed -i.bak "s/^version = \"[^\"]*\"/version = \"$NEW_VERSION\"/" docs-src/config.toml
rm -f docs-src/config.toml.bak
echo " version = \"$NEW_VERSION\""
# --- Step 6: Commit, tag, and push ---
+7
View File
@@ -146,6 +146,13 @@ if(HTTPLIB_IS_USING_OPENSSL)
WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}
COMMAND_ERROR_IS_FATAL ANY
)
# cert_san_types.pem: the bytes of each SAN read as the other type:
# DNS:a.zz is 97.46.122.122, IP:42.46.122.122 is "*.zz".
execute_process(
COMMAND ${OPENSSL_COMMAND} req -x509 -key key.pem -sha256 -days 3650 -nodes -subj /CN=san-types -addext subjectAltName=DNS:a.zz,IP:42.46.122.122 -out cert_san_types.pem
WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}
COMMAND_ERROR_IS_FATAL ANY
)
endif()
add_subdirectory(fuzzing)
+5 -1
View File
@@ -1,5 +1,5 @@
CXX = clang++
CXXFLAGS = -g -std=c++11 -I. -Wall -Wextra -Wtype-limits -Wconversion -Wshadow $(EXTRA_CXXFLAGS) -DCPPHTTPLIB_USE_NON_BLOCKING_GETADDRINFO -fsanitize=address # -fno-exceptions -DCPPHTTPLIB_NO_EXCEPTIONS
CXXFLAGS = -g -std=c++11 -I. -Wall -Wextra -Wtype-limits -Wconversion -Wshadow -DCPPHTTPLIB_USE_NON_BLOCKING_GETADDRINFO -fsanitize=address $(EXTRA_CXXFLAGS) # -fno-exceptions -DCPPHTTPLIB_NO_EXCEPTIONS
ifneq ($(OS), Windows_NT)
UNAME_S := $(shell uname -s)
@@ -264,6 +264,10 @@ test_websocket_heartbeat : test_websocket_heartbeat.cc ../httplib.h Makefile
$(CXX) -o $@ -I.. $(CXXFLAGS) test_websocket_heartbeat.cc $(TEST_ARGS)
@file $@
test_websocket_thread_safety : test_websocket_thread_safety.cc ../httplib.h Makefile cert.pem
$(CXX) -o $@ -I.. $(CXXFLAGS) test_websocket_thread_safety.cc $(TEST_ARGS)
@file $@
test_proxy : test_proxy.cc ../httplib.h Makefile cert.pem
$(CXX) -o $@ -I.. $(CXXFLAGS) test_proxy.cc $(TEST_ARGS)
+4
View File
@@ -33,3 +33,7 @@ openssl req -x509 -key key.pem -sha256 -days 3650 -nodes -subj "/CN=127.0.0.1" -
# different address. The SAN address must match; the CN address
# must be ignored.
openssl req -x509 -key key.pem -sha256 -days 3650 -nodes -subj "/CN=::1" -addext "subjectAltName=IP:2001:db8::1" -out cert_ipv6.pem
# cert_san_types.pem: the bytes of each SAN read as the other type:
# DNS:a.zz is 97.46.122.122, IP:42.46.122.122 is "*.zz".
openssl req -x509 -key key.pem -sha256 -days 3650 -nodes -subj "/CN=san-types" -addext "subjectAltName=DNS:a.zz,IP:42.46.122.122" -out cert_san_types.pem
+11 -1
View File
@@ -137,6 +137,15 @@ cert_ipv6_pem = custom_target(
command: [openssl, 'req', '-x509', '-key', '@INPUT@', '-sha256', '-days', '3650', '-nodes', '-subj', '/CN=::1', '-addext', 'subjectAltName=IP:2001:db8::1', '-out', '@OUTPUT@']
)
# cert_san_types.pem: the bytes of each SAN read as the other type: DNS:a.zz is
# 97.46.122.122, IP:42.46.122.122 is "*.zz".
cert_san_types_pem = custom_target(
'cert_san_types_pem',
input: key_pem,
output: 'cert_san_types.pem',
command: [openssl, 'req', '-x509', '-key', '@INPUT@', '-sha256', '-days', '3650', '-nodes', '-subj', '/CN=san-types', '-addext', 'subjectAltName=DNS:a.zz,IP:42.46.122.122', '-out', '@OUTPUT@']
)
# Copy test files to the build directory
configure_file(input: 'ca-bundle.crt', output: 'ca-bundle.crt', copy: true)
configure_file(input: 'image.jpg', output: 'image.jpg', copy: true)
@@ -178,7 +187,8 @@ test(
client_encrypted_pbes1_key_pem,
client_encrypted_cert_pem,
cert_ip_cn_pem,
cert_ipv6_pem
cert_ipv6_pem,
cert_san_types_pem
],
workdir: meson.current_build_dir(),
timeout: 300
+21
View File
@@ -18,3 +18,24 @@ services:
context: ./
args:
auth: digest
# Self-hosted stand-in for the httpbin.org-style auth-testing endpoints
# (/basic-auth, /digest-auth) that BaseAuthTest/DigestAuthTest exercise
# through the proxies above, so those tests don't depend on an external
# site's uptime.
httpbin_backend:
image: mccutchen/go-httpbin:latest
restart: always
# TLS termination in front of httpbin_backend (which only speaks plain
# HTTP) so the SSL variants of those tests can CONNECT-tunnel through the
# proxies to "httpbin" on port 443, same as the NoSSL variants do on 80.
httpbin:
image: nginx:alpine
restart: always
depends_on:
- httpbin_backend
volumes:
- ./httpbin_nginx.conf:/etc/nginx/conf.d/default.conf:ro
- ../cert.pem:/etc/nginx/certs/cert.pem:ro
- ../key.pem:/etc/nginx/certs/key.pem:ro
+22
View File
@@ -0,0 +1,22 @@
server {
listen 80;
server_name httpbin;
location / {
proxy_pass http://httpbin_backend:8080;
proxy_set_header Host $host;
}
}
server {
listen 443 ssl;
server_name httpbin;
ssl_certificate /etc/nginx/certs/cert.pem;
ssl_certificate_key /etc/nginx/certs/key.pem;
location / {
proxy_pass http://httpbin_backend:8080;
proxy_set_header Host $host;
}
}
+5083 -311
View File
File diff suppressed because it is too large Load Diff
+36 -25
View File
@@ -22,23 +22,25 @@ template <typename T> void ProxyTest(T &cli, bool basic) {
}
TEST(ProxyTest, NoSSLBasic) {
Client cli("httpbingo.org");
Client cli("httpbin");
ProxyTest(cli, true);
}
#ifdef CPPHTTPLIB_SSL_ENABLED
TEST(ProxyTest, SSLBasic) {
SSLClient cli("httpbingo.org");
SSLClient cli("httpbin");
cli.enable_server_certificate_verification(false);
ProxyTest(cli, true);
}
TEST(ProxyTest, NoSSLDigest) {
Client cli("httpbingo.org");
Client cli("httpbin");
ProxyTest(cli, false);
}
TEST(ProxyTest, SSLDigest) {
SSLClient cli("httpbingo.org");
SSLClient cli("httpbin");
cli.enable_server_certificate_verification(false);
ProxyTest(cli, false);
}
#endif
@@ -63,23 +65,25 @@ void RedirectProxyText(T &cli, const char *path, bool basic) {
}
TEST(RedirectTest, HTTPBinNoSSLBasic) {
Client cli("httpbingo.org");
Client cli("httpbin");
RedirectProxyText(cli, "/redirect/2", true);
}
#ifdef CPPHTTPLIB_SSL_ENABLED
TEST(RedirectTest, HTTPBinNoSSLDigest) {
Client cli("httpbingo.org");
Client cli("httpbin");
RedirectProxyText(cli, "/redirect/2", false);
}
TEST(RedirectTest, HTTPBinSSLBasic) {
SSLClient cli("httpbingo.org");
SSLClient cli("httpbin");
cli.enable_server_certificate_verification(false);
RedirectProxyText(cli, "/redirect/2", true);
}
TEST(RedirectTest, HTTPBinSSLDigest) {
SSLClient cli("httpbingo.org");
SSLClient cli("httpbin");
cli.enable_server_certificate_verification(false);
RedirectProxyText(cli, "/redirect/2", false);
}
#endif
@@ -173,7 +177,8 @@ template <typename T> void BaseAuthTestFromHTTPWatch(T &cli) {
cli.Get("/basic-auth/hello/world",
Headers{make_basic_authentication_header("hello", "world")});
ASSERT_TRUE(res != nullptr);
EXPECT_EQ(normalizeJson("{\"authenticated\":true,\"user\":\"hello\"}\n"),
EXPECT_EQ(normalizeJson("{\"authenticated\":true,\"user\":\"hello\","
"\"authorized\":true}\n"),
normalizeJson(res->body));
EXPECT_EQ(StatusCode::OK_200, res->status);
}
@@ -182,7 +187,8 @@ template <typename T> void BaseAuthTestFromHTTPWatch(T &cli) {
cli.set_basic_auth("hello", "world");
auto res = cli.Get("/basic-auth/hello/world");
ASSERT_TRUE(res != nullptr);
EXPECT_EQ(normalizeJson("{\"authenticated\":true,\"user\":\"hello\"}\n"),
EXPECT_EQ(normalizeJson("{\"authenticated\":true,\"user\":\"hello\","
"\"authorized\":true}\n"),
normalizeJson(res->body));
EXPECT_EQ(StatusCode::OK_200, res->status);
}
@@ -203,13 +209,14 @@ template <typename T> void BaseAuthTestFromHTTPWatch(T &cli) {
}
TEST(BaseAuthTest, NoSSL) {
Client cli("httpcan.org");
Client cli("httpbin");
BaseAuthTestFromHTTPWatch(cli);
}
#ifdef CPPHTTPLIB_SSL_ENABLED
TEST(BaseAuthTest, SSL) {
SSLClient cli("httpcan.org");
SSLClient cli("httpbin");
cli.enable_server_certificate_verification(false);
BaseAuthTestFromHTTPWatch(cli);
}
#endif
@@ -228,21 +235,21 @@ template <typename T> void DigestAuthTestFromHTTPWatch(T &cli) {
}
{
// go-httpbin (the "httpbin" test double) only implements MD5 and
// SHA-256 for digest auth, so SHA-256 is as far as this can exercise
// the client's digest-auth algorithm selection end-to-end.
std::vector<std::string> paths = {
"/digest-auth/auth/hello/world/MD5",
"/digest-auth/auth/hello/world/SHA-256",
"/digest-auth/auth/hello/world/SHA-512",
};
cli.set_digest_auth("hello", "world");
for (auto path : paths) {
auto res = cli.Get(path.c_str());
ASSERT_TRUE(res != nullptr);
std::string algo(path.substr(path.rfind('/') + 1));
EXPECT_EQ(
normalizeJson("{\"algorithm\":\"" + algo +
"\",\"authenticated\":true,\"user\":\"hello\"}\n"),
normalizeJson(res->body));
EXPECT_EQ(normalizeJson("{\"authenticated\":true,\"user\":\"hello\","
"\"authorized\":true}\n"),
normalizeJson(res->body));
EXPECT_EQ(StatusCode::OK_200, res->status);
}
@@ -263,12 +270,13 @@ template <typename T> void DigestAuthTestFromHTTPWatch(T &cli) {
}
TEST(DigestAuthTest, SSL) {
SSLClient cli("httpcan.org");
SSLClient cli("httpbin");
cli.enable_server_certificate_verification(false);
DigestAuthTestFromHTTPWatch(cli);
}
TEST(DigestAuthTest, NoSSL) {
Client cli("httpcan.org");
Client cli("httpbin");
DigestAuthTestFromHTTPWatch(cli);
}
#endif
@@ -329,22 +337,24 @@ template <typename T> void KeepAliveTest(T &cli, bool basic) {
#ifdef CPPHTTPLIB_SSL_ENABLED
TEST(KeepAliveTest, NoSSLWithBasic) {
Client cli("httpbingo.org");
Client cli("httpbin");
KeepAliveTest(cli, true);
}
TEST(KeepAliveTest, SSLWithBasic) {
SSLClient cli("httpbingo.org");
SSLClient cli("httpbin");
cli.enable_server_certificate_verification(false);
KeepAliveTest(cli, true);
}
TEST(KeepAliveTest, NoSSLWithDigest) {
Client cli("httpbingo.org");
Client cli("httpbin");
KeepAliveTest(cli, false);
}
TEST(KeepAliveTest, SSLWithDigest) {
SSLClient cli("httpbingo.org");
SSLClient cli("httpbin");
cli.enable_server_certificate_verification(false);
KeepAliveTest(cli, false);
}
#endif
@@ -353,7 +363,8 @@ TEST(KeepAliveTest, SSLWithDigest) {
#ifdef CPPHTTPLIB_SSL_ENABLED
TEST(ProxyTest, SSLOpenStream) {
SSLClient cli("httpbingo.org");
SSLClient cli("httpbin");
cli.enable_server_certificate_verification(false);
cli.set_proxy("localhost", 3128);
cli.set_proxy_basic_auth("hello", "world");
+74 -1
View File
@@ -3,11 +3,14 @@
// without waiting 30 seconds.
#define CPPHTTPLIB_WEBSOCKET_PING_INTERVAL_SECOND 1
#define CPPHTTPLIB_WEBSOCKET_READ_TIMEOUT_SECOND 3
#define CPPHTTPLIB_WEBSOCKET_CLIENT_READ_TIMEOUT_SECOND 3
#define CPPHTTPLIB_WEBSOCKET_SERVER_READ_TIMEOUT_SECOND 3
#include <httplib.h>
#include "gtest/gtest.h"
#include <future>
using namespace httplib;
class WebSocketHeartbeatTest : public ::testing::Test {
@@ -191,6 +194,76 @@ TEST_F(WebSocketPongTimeoutTest, ClientDetectsNonResponsivePeer) {
EXPECT_FALSE(client.is_open());
}
// The compile-time client read timeout (3s here) was never asked for through
// set_read_timeout(), so when it elapses read() reports Fail and closes the
// connection rather than handing back a Timeout on a still-open one.
TEST_F(WebSocketPongTimeoutTest, CompileTimeClientReadTimeoutIsFail) {
ws::WebSocketClient client("ws://localhost:" + std::to_string(port_) + "/ws");
client.set_websocket_ping_interval(0);
ASSERT_TRUE(client.connect());
// Server pings are off and its handler never sends, so nothing arrives.
std::string msg;
EXPECT_EQ(client.read(msg), ws::Fail);
EXPECT_FALSE(client.is_open());
}
// The compile-time server read timeout (3s here) is a backstop that reclaims
// the worker from a peer gone quiet, not a timeout the handler asked for. When
// it elapses read() must return Fail, so a handler written as
// `while (ws.read(msg))` ends instead of re-running its body with the previous
// message still in `msg`.
class WebSocketServerReadTimeoutTest : public ::testing::Test {
protected:
void SetUp() override {
svr_.set_websocket_ping_interval(0);
svr_.WebSocket("/ws", [this](const Request &, ws::WebSocket &ws) {
std::string msg;
while (ws.read(msg)) {
iterations_++;
ws.send(msg);
}
handler_done_.set_value();
});
port_ = svr_.bind_to_any_port("localhost");
thread_ = std::thread([this]() { svr_.listen_after_bind(); });
svr_.wait_until_ready();
}
void TearDown() override {
svr_.stop();
thread_.join();
}
Server svr_;
int port_;
std::thread thread_;
std::atomic<int> iterations_{0};
std::promise<void> handler_done_;
};
TEST_F(WebSocketServerReadTimeoutTest, BackstopEndsHandlerLoop) {
ws::WebSocketClient client("ws://localhost:" + std::to_string(port_) + "/ws");
client.set_websocket_ping_interval(0); // nothing reaches the server's read()
client.set_read_timeout(10, 0); // fail rather than hang
ASSERT_TRUE(client.connect());
ASSERT_TRUE(client.send("hello"));
std::string msg;
ASSERT_EQ(client.read(msg), ws::Text);
EXPECT_EQ("hello", msg);
// The client now stays silent. The server's backstop elapses and the
// handler returns, having run its loop body exactly once.
auto done = handler_done_.get_future();
ASSERT_EQ(done.wait_for(std::chrono::seconds(6)), std::future_status::ready);
EXPECT_EQ(1, iterations_.load());
EXPECT_EQ(client.read(msg), ws::Fail);
EXPECT_FALSE(client.is_open());
}
// Verify that a responsive peer does NOT trigger the pong-timeout mechanism,
// even with a small max_missed_pongs budget. This is the positive counterpart
// of ClientDetectsNonResponsivePeer: the client must actively drive read() so
+178
View File
@@ -0,0 +1,178 @@
// Standalone test for TLS-session thread safety on wss:// connections.
//
// A wss:// WebSocket enters one TLS session from multiple threads: the read
// path, the application's send()/close(), and the heartbeat ping thread. A
// TLS session must never be entered concurrently, so httplib routes wss://
// through WebSocketSSLStream, which serializes every TLS call. These tests
// drive that concurrency directly. Built with ASan in CI, so a regression
// surfaces as a heap-buffer-overflow, not just a flaky assertion.
// Fire the heartbeat every second so the ping-vs-read case actually crosses a
// ping while the reader is idle.
#define CPPHTTPLIB_WEBSOCKET_PING_INTERVAL_SECOND 1
#include <httplib.h>
#include "gtest/gtest.h"
#include <atomic>
#include <chrono>
#include <string>
#include <thread>
#ifdef CPPHTTPLIB_SSL_ENABLED
using namespace httplib;
namespace {
const size_t kPayloadBytes = 2048;
const size_t kSendCount = 2000;
const size_t kBurstPerFrame = 100;
const int kCloseCycles = 20;
} // namespace
class WebSocketTlsThreadSafetyTest : public ::testing::Test {
protected:
WebSocketTlsThreadSafetyTest() : svr_("cert.pem", "key.pem") {}
void TearDown() override {
if (thread_.joinable()) {
svr_.stop();
thread_.join();
}
}
// Registers the handler and starts the TLS server. Called by each test
// after its own server configuration, since the heartbeat test needs the
// pings the others switch off.
bool start(Server::WebSocketHandler handler) {
if (!svr_.is_valid()) { return false; }
svr_.WebSocket("/ws", std::move(handler));
port_ = svr_.bind_to_any_port("localhost");
if (port_ <= 0) { return false; }
thread_ = std::thread([this]() { svr_.listen_after_bind(); });
svr_.wait_until_ready();
return true;
}
std::string url() const {
return "wss://localhost:" + std::to_string(port_) + "/ws";
}
SSLServer svr_;
int port_ = 0;
std::thread thread_;
};
// A sender thread hammers send() while another thread loops read(). Both
// enter the same TLS session, and every echoed frame must arrive intact.
TEST_F(WebSocketTlsThreadSafetyTest, SendWhileAnotherThreadReads) {
svr_.set_websocket_ping_interval(0);
ASSERT_TRUE(start([](const Request &, ws::WebSocket &sock) {
std::string msg;
while (sock.read(msg) != ws::ReadResult::Fail) {
if (!sock.send(msg.data(), msg.size())) { break; }
}
}));
ws::WebSocketClient cli(url());
cli.enable_server_certificate_verification(false);
ASSERT_TRUE(cli.connect());
const std::string payload(kPayloadBytes, 'x');
// close() drains the peer's Close reply with its own frame reader, so once
// it starts, two threads parse frames from one stream and can split a
// payload between them. That is frame-level, not TLS-level, and happens on
// ws:// too, so only frames completed before close() are checked here.
std::atomic<bool> closing(false);
std::atomic<size_t> frames_read(0);
std::atomic<size_t> corrupt_frames(0);
std::thread reader([&]() {
std::string msg;
while (cli.read(msg) != ws::ReadResult::Fail) {
if (msg != payload && !closing.load()) { corrupt_frames++; }
frames_read++;
}
});
size_t sent = 0;
for (size_t i = 0; i < kSendCount; i++) {
if (!cli.send(payload.data(), payload.size())) { break; }
sent++;
}
closing.store(true);
cli.close();
reader.join();
EXPECT_EQ(kSendCount, sent);
EXPECT_EQ(static_cast<size_t>(0), corrupt_frames.load());
EXPECT_GT(frames_read.load(), static_cast<size_t>(0));
}
// close() sends a Close frame and drains the peer's reply while a second
// thread is inside read(). Repeated to shake out the race.
TEST_F(WebSocketTlsThreadSafetyTest, CloseWhileAnotherThreadReads) {
svr_.set_websocket_ping_interval(0);
ASSERT_TRUE(start([](const Request &, ws::WebSocket &sock) {
const std::string burst(64, 'p');
std::string msg;
while (sock.read(msg) != ws::ReadResult::Fail) {
for (size_t i = 0; i < kBurstPerFrame; i++) {
if (!sock.send(burst.data(), burst.size())) { return; }
}
}
}));
for (int cycle = 0; cycle < kCloseCycles; cycle++) {
ws::WebSocketClient cli(url());
cli.enable_server_certificate_verification(false);
ASSERT_TRUE(cli.connect()) << "cycle " << cycle;
std::thread reader([&]() {
std::string msg;
while (cli.read(msg) != ws::ReadResult::Fail) {}
});
const std::string trigger(64, 't');
ASSERT_TRUE(cli.send(trigger.data(), trigger.size()));
cli.close();
reader.join();
}
}
// The heartbeat ping thread writes to the TLS session on its own timer while
// the application blocks in read() with no traffic. The ping's write must not
// collide with the reader. The 1-second interval above means several pings
// fire on both sides during this idle window.
TEST_F(WebSocketTlsThreadSafetyTest, HeartbeatPingWhileReaderIsIdle) {
ASSERT_TRUE(start([](const Request &, ws::WebSocket &sock) {
std::string msg;
while (sock.read(msg) != ws::ReadResult::Fail) {}
}));
ws::WebSocketClient cli(url());
cli.enable_server_certificate_verification(false);
ASSERT_TRUE(cli.connect());
// No data frames are sent, so the reader stays parked inside read() while
// both sides exchange pings and pongs on the heartbeat timer. read() only
// returns once close() below tears the connection down.
std::thread reader([&]() {
std::string msg;
while (cli.read(msg) != ws::ReadResult::Fail) {}
});
std::this_thread::sleep_for(std::chrono::seconds(4));
// The connection survived the heartbeat exchange without a TLS-session race.
EXPECT_TRUE(cli.is_open());
cli.close();
reader.join();
}
#endif // CPPHTTPLIB_SSL_ENABLED