Compare commits

...
14 Commits
Author SHA1 Message Date
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
12 changed files with 834 additions and 59 deletions
+12
View File
@@ -343,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
+29 -3
View File
@@ -347,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
@@ -545,14 +564,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
@@ -568,6 +588,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.
@@ -827,7 +851,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.
+1 -1
View File
@@ -4,7 +4,7 @@ langs = ["en", "ja"]
[site]
title = "cpp-httplib"
version = "0.56.0"
version = "0.58.0"
hostname = "https://yhirose.github.io"
base_path = "/cpp-httplib"
footer_message = "© 2026 Yuji Hirose. All rights reserved."
@@ -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()`.
+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`.
@@ -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()`の組み合わせです。
+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ハンドラーを登録するだけです。
+125 -44
View File
@@ -8,8 +8,8 @@
#ifndef CPPHTTPLIB_HTTPLIB_H
#define CPPHTTPLIB_HTTPLIB_H
#define CPPHTTPLIB_VERSION "0.56.0"
#define CPPHTTPLIB_VERSION_NUM "0x003800"
#define CPPHTTPLIB_VERSION "0.58.0"
#define CPPHTTPLIB_VERSION_NUM "0x003a00"
#ifdef _WIN32
#if defined(_WIN32_WINNT) && _WIN32_WINNT < 0x0A00
@@ -1756,6 +1756,7 @@ struct Request {
// private members...
bool body_consumed_ = false;
bool expect_100_continue_pending_ = false;
size_t redirect_count_ = CPPHTTPLIB_REDIRECT_MAX_COUNT;
size_t content_length_ = 0;
ContentProvider content_provider_;
@@ -2985,7 +2986,7 @@ private:
bool read_response_line(Stream &strm, const Request &req, Response &res,
bool skip_100_continue = true) const;
bool write_request(Stream &strm, Request &req, bool close_connection,
Error &error, bool skip_body = false);
Error &error, bool skip_body, bool &rejected_locally);
bool write_request_body(Stream &strm, Request &req, Error &error);
void prepare_default_headers(Request &r, bool for_stream,
const std::string &ct);
@@ -5910,6 +5911,13 @@ inline bool parse_trailers(stream_line_reader &line_reader, Headers &dest,
// to look up.
split(trailer_header.data(), trailer_header.data() + trailer_header.size(),
',', [&](const char *b, const char *e) {
// A legitimate message declares only a handful of trailers. Cap the
// set so a peer cannot grow it without bound: an oversized set only
// arises from an attempt to force many colliding names into
// quadratic lookups (case_ignore::hash is unkeyed).
if (declared_trailers.size() >= CPPHTTPLIB_HEADER_MAX_COUNT) {
return;
}
std::string key(b, e);
if (prohibited_trailers.find(key) == prohibited_trailers.end()) {
declared_trailers.insert(key);
@@ -5920,6 +5928,8 @@ inline bool parse_trailers(stream_line_reader &line_reader, Headers &dest,
size_t trailer_header_count = 0;
while (strcmp(line_reader.ptr(), "\r\n") != 0) {
if (line_reader.size() > CPPHTTPLIB_HEADER_MAX_LENGTH) { return false; }
// Count every received trailer field, not only the declared ones stored in
// dest, so undeclared fields cannot keep this loop running past the limit.
if (trailer_header_count >= CPPHTTPLIB_HEADER_MAX_COUNT) { return false; }
constexpr auto line_terminator_len = 2;
@@ -5932,12 +5942,13 @@ inline bool parse_trailers(stream_line_reader &line_reader, Headers &dest,
if (declared_trailers.find(key) !=
declared_trailers.end()) {
dest.emplace(key, val);
trailer_header_count++;
}
})) {
return false;
}
trailer_header_count++;
if (!line_reader.getline()) { return false; }
}
@@ -8506,11 +8517,13 @@ bool read_content(Stream &strm, T &x, size_t payload_max_length, int &status,
inline ssize_t write_request_line(Stream &strm, const std::string &method,
const std::string &path) {
// A request target must not carry CR/LF (or other control octets); otherwise
// a value smuggled into it splits the request line and injects headers or a
// whole request. The same field-value check already guards header values in
// check_and_write_headers and the request target in
// perform_websocket_handshake; apply it here too.
// Neither the method nor the request target may carry CR/LF (or other
// control octets); otherwise a value smuggled into either splits the request
// line and injects headers or a whole request. The method must be a token
// (RFC 9110 Section 9.1), which also rejects an empty method and embedded
// spaces. The target gets the same field-value check that already guards
// header values in check_and_write_headers.
if (!fields::is_token(method)) { return -1; }
if (!fields::is_field_value(path)) { return -1; }
std::string s = method;
@@ -13288,7 +13301,10 @@ inline bool Server::write_response_core(Stream &strm, bool close_connection,
// Prepare additional headers
if (close_connection ||
detail::has_header_token(req.headers, "Connection", "close") ||
400 <= res.status) { // Don't leave connections open after errors
400 <= res.status || // Don't leave connections open after errors
// The client withholds the body until `100 Continue`, which was never
// sent, so whether and when the body follows is unknown.
(req.expect_100_continue_pending_ && detail::has_framed_body(req))) {
res.set_header("Connection", "close");
} else {
std::string s = "timeout=";
@@ -13539,6 +13555,13 @@ inline bool Server::read_content_core(
}
#endif
// The client is waiting for this before it sends the body.
if (req.expect_100_continue_pending_) {
req.expect_100_continue_pending_ = false;
detail::write_response_line(strm, StatusCode::Continue_100);
strm.write("\r\n");
}
if (!detail::read_content(strm, req, payload_max_length_, res.status, nullptr,
out, true)) {
return false;
@@ -14376,19 +14399,20 @@ Server::process_request(Stream &strm, const std::string &remote_addr,
// case-insensitive, and a 100-continue expectation in an HTTP/1.0 request
// must be ignored. An expectation we do not recognize is left alone; the
// 417 the section allows for one is a MAY, not a requirement.
//
// `100 Continue` itself is deferred until the body is actually read (see
// read_content_core), so a request rejected by a later handler never
// invites the client to send a body nobody will read.
if (req.version != "HTTP/1.0" &&
detail::has_header_token(req.headers, "Expect", "100-continue")) {
int status = StatusCode::Continue_100;
if (expect_100_continue_handler_) {
status = expect_100_continue_handler_(req, res);
}
switch (status) {
case StatusCode::Continue_100:
case StatusCode::ExpectationFailed_417:
detail::write_response_line(strm, status);
strm.write("\r\n");
break;
default:
if (status == StatusCode::Continue_100) {
req.expect_100_continue_pending_ = true;
} else {
if (res.status == -1) { res.status = status; }
connection_closed = true;
return write_response(strm, true, req, res);
}
@@ -14401,18 +14425,25 @@ Server::process_request(Stream &strm, const std::string &remote_addr,
};
// WebSocket upgrade
// Check pre_routing_handler_ before upgrading so that authentication
// and other middleware can reject the request with an HTTP response
// (e.g., 401) before the protocol switches.
// Run pre_routing_handler_ and pre_request_handler_ before upgrading so
// that authentication and other middleware can reject the request with an
// HTTP response (e.g., 401) before the protocol switches.
if (detail::is_websocket_upgrade(req)) {
if (pre_routing_handler_ &&
pre_routing_handler_(req, res) == HandlerResponse::Handled) {
if (res.status == -1) { res.status = StatusCode::OK_200; }
return write_response(strm, close_connection, req, res);
return write_response_with_content(strm, close_connection, req, res);
}
// Find matching WebSocket handler
for (const auto &entry : websocket_handlers_) {
if (entry.matcher->match(req)) {
req.matched_route = entry.matcher->pattern();
if (pre_request_handler_ &&
pre_request_handler_(req, res) == HandlerResponse::Handled) {
if (res.status == -1) { res.status = StatusCode::OK_200; }
return write_response_with_content(strm, close_connection, req, res);
}
// Compute accept key
auto client_key = req.get_header_value("Sec-WebSocket-Key");
auto accept_key = detail::websocket_accept_key(client_key);
@@ -15088,16 +15119,30 @@ ClientImpl::open_stream(const std::string &method, const std::string &path,
prepare_default_headers(req, true, content_type);
auto &strm = *handle.stream_;
if (detail::write_request_line(strm, req.method, req.path) < 0) {
handle.error = Error::Write;
handle.response.reset();
return handle;
}
if (!detail::check_and_write_headers(strm, req.headers, header_writer_,
handle.error)) {
handle.response.reset();
return handle;
// Build the request line and headers in memory first, like write_request()
// does, so that a rejected header leaves nothing on the wire.
{
detail::BufferStream bstrm;
if (detail::write_request_line(bstrm, req.method, req.path) < 0) {
handle.error = Error::Write;
handle.response.reset();
return handle;
}
if (!detail::check_and_write_headers(bstrm, req.headers, header_writer_,
handle.error)) {
handle.response.reset();
return handle;
}
const auto &data = bstrm.get_buffer();
if (!detail::write_data(strm, data.data(), data.size())) {
handle.error = Error::Write;
handle.response.reset();
return handle;
}
}
if (!body.empty()) {
@@ -15272,22 +15317,45 @@ inline ssize_t ChunkedDecoder::read_payload(char *buf, size_t len,
stream_line_reader lr(strm, line_buf, sizeof(line_buf));
if (!lr.getline()) { return -1; }
// Everything below is bounded by eol rather than by the buffer's NUL, so
// the line terminator is never mistaken for line content.
const char *eol = lr.ptr() + lr.size();
if (lr.end_with_crlf()) {
eol -= 2;
} else if (eol != lr.ptr() && eol[-1] == '\n') {
// Only reachable under CPPHTTPLIB_ALLOW_LF_AS_LINE_TERMINATOR, where
// getline() ends the line on a bare LF. That LF is the terminator, so it
// has to come off here or the check below would reject the line.
eol -= 1;
}
// RFC 9112 §7.1: chunk-size = 1*HEXDIG
const char *p = lr.ptr();
int v = 0;
if (!is_hex(*p, v)) { return -1; }
if (p == eol || !is_hex(*p, v)) { return -1; }
size_t chunk_len = 0;
constexpr size_t chunk_len_max = (std::numeric_limits<size_t>::max)();
for (; is_hex(*p, v); ++p) {
for (; p < eol && is_hex(*p, v); ++p) {
if (chunk_len > (chunk_len_max >> 4)) { return -1; }
chunk_len = (chunk_len << 4) | static_cast<size_t>(v);
}
while (is_space_or_tab(*p)) {
while (p < eol && is_space_or_tab(*p)) {
++p;
}
if (*p != '\0' && *p != ';' && *p != '\r' && *p != '\n') { return -1; }
// RFC 9112 §7.1.1: only a chunk-ext may sit between the size and the line
// terminator, and it is built from tokens and quoted-strings, so it never
// holds a CR, LF or any other control character. getline() reads up to the
// CRLF, so a bare LF left in here would be swallowed as extension text
// while an intermediary that ends the line on it delimits the chunks
// differently, and the two disagree on where the body ends (request
// smuggling).
if (p < eol && *p != ';') { return -1; }
for (; p < eol; ++p) {
if (!is_space_or_tab(*p) && !fields::is_field_vchar(*p)) { return -1; }
}
if (chunk_len == 0) {
chunk_remaining = 0;
@@ -15627,7 +15695,9 @@ inline bool ClientImpl::write_content_with_provider(Stream &strm,
inline bool ClientImpl::write_request(Stream &strm, Request &req,
bool close_connection, Error &error,
bool skip_body) {
bool skip_body, bool &rejected_locally) {
rejected_locally = false;
// Prepare additional headers
if (close_connection) {
if (!req.has_header("Connection")) {
@@ -15716,15 +15786,18 @@ inline bool ClientImpl::write_request(Stream &strm, Request &req,
// Write request line and headers
if (detail::write_request_line(bstrm, req.method, path_with_query) < 0) {
// A rejected target (e.g. CR/LF smuggled in via a decoded redirect
// Location under set_path_encode(false)) must fail the request cleanly
// instead of emitting a request-line-less, header-injecting request.
// A rejected method (not a token, e.g. carrying CR/LF) or target (e.g.
// CR/LF smuggled in via a decoded redirect Location under
// set_path_encode(false)) must fail the request cleanly instead of
// emitting a request-line-less, header-injecting request.
error = Error::Write;
rejected_locally = true;
output_error_log(error, &req);
return false;
}
if (!detail::check_and_write_headers(bstrm, req.headers, header_writer_,
error)) {
rejected_locally = true;
output_error_log(error, &req);
return false;
}
@@ -15993,8 +16066,16 @@ inline bool ClientImpl::process_request(Stream &strm, Request &req,
detail::has_header_token(req.headers, "Expect", "100-continue");
// Send request (skip body if using Expect: 100-continue)
auto rejected_locally = false;
auto write_request_success =
write_request(strm, req, close_connection, error, expect_100_continue);
write_request(strm, req, close_connection, error, expect_100_continue,
rejected_locally);
// A failed write normally still reads the response below, since the server
// may have answered early (e.g. 413/414) and closed while the body was being
// sent. A request rejected before any byte reached the socket gets no such
// response, and waiting for one would block until the read timeout.
if (rejected_locally) { return false; }
#ifdef CPPHTTPLIB_SSL_ENABLED
if (is_ssl() && !expect_100_continue) {
@@ -19312,11 +19393,11 @@ inline void shutdown(session_t session, bool graceful) {
auto ssl = static_cast<SSL *>(session);
if (graceful) {
// First call sends close_notify
if (SSL_shutdown(ssl) == 0) {
// Second call waits for peer's close_notify
SSL_shutdown(ssl);
}
// Send close_notify without waiting for the peer's. The connection is
// closed right after this, so a unidirectional shutdown is enough, and an
// idle peer that never answers would otherwise hold this thread until the
// read timeout. The other backends do not wait either.
SSL_shutdown(ssl);
}
}
+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 ---
+598 -4
View File
@@ -6987,10 +6987,9 @@ TEST_F(ServerTest, CaseInsensitiveTransferEncoding) {
EXPECT_EQ(StatusCode::OK_200, res->status);
}
// GHSA-h6wq-j5mv-f3q8: the server must reject malformed chunk-size lines
// rather than treat them as valid lengths.
template <typename ClientT>
static void expect_chunked_body_rejected(ClientT &cli, const char *body) {
static void expect_chunked_body_status(ClientT &cli, const char *body,
int expected_status) {
Request req;
req.method = "POST";
req.path = "/chunked";
@@ -7008,7 +7007,14 @@ static void expect_chunked_body_rejected(ClientT &cli, const char *body) {
auto res = std::make_shared<Response>();
auto error = Error::Success;
ASSERT_TRUE(cli.send(req, *res, error));
EXPECT_EQ(StatusCode::BadRequest_400, res->status);
EXPECT_EQ(expected_status, res->status);
}
// GHSA-h6wq-j5mv-f3q8: the server must reject malformed chunk-size lines
// rather than treat them as valid lengths.
template <typename ClientT>
static void expect_chunked_body_rejected(ClientT &cli, const char *body) {
expect_chunked_body_status(cli, body, StatusCode::BadRequest_400);
}
TEST_F(ServerTest, RejectsNegativeChunkSize) {
@@ -7020,6 +7026,40 @@ TEST_F(ServerTest, RejectsChunkSizeWithLeadingPlus) {
cli_, "+4\r\ndech\r\nf\r\nunked post body\r\n0\r\n\r\n");
}
// RFC 9112 §7.1.1: a chunk-ext is made of tokens and quoted-strings, so the
// chunk-size line carries no CR, LF or other control character ahead of its
// terminator. Such a line must be refused rather than read as extension text.
TEST_F(ServerTest, RejectsBareLFInChunkExtension) {
expect_chunked_body_rejected(
cli_, "4;\nxx\r\ndech\r\nf\r\nunked post body\r\n0\r\n\r\n");
}
TEST_F(ServerTest, RejectsBareLFAfterChunkSize) {
expect_chunked_body_rejected(
cli_, "4\nxx\r\ndech\r\nf\r\nunked post body\r\n0\r\n\r\n");
}
TEST_F(ServerTest, RejectsBareCRInChunkExtension) {
expect_chunked_body_rejected(
cli_, "4;a\rb\r\ndech\r\nf\r\nunked post body\r\n0\r\n\r\n");
}
TEST_F(ServerTest, RejectsControlCharacterInChunkExtension) {
// The literal stays split: a hex escape consumes every hex digit that
// follows, so "\x01b" would be the single byte \x1b, not \x01 then 'b'.
expect_chunked_body_rejected(
cli_, "4;a\x01"
"b\r\ndech\r\nf\r\nunked post body\r\n0\r\n\r\n");
}
TEST_F(ServerTest, AcceptsChunkExtension) {
expect_chunked_body_status(cli_,
"4;name=value\r\ndech\r\n"
"f ; note=\"a;b c\"\r\nunked post body\r\n"
"0;last\r\n\r\n",
StatusCode::OK_200);
}
TEST_F(ServerTest, GetStreamed2) {
auto res = cli_.Get("/streamed", Headers{{make_range_header({{2, 3}})}});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
@@ -10101,6 +10141,90 @@ TEST(RequestLineInjectionTest, ClientRejectsCRLFTargetEndToEnd) {
}
}
TEST(RequestLineInjectionTest, RejectsNonTokenMethod) {
// Methods that are tokens (RFC 9110 Section 9.1) are written verbatim,
// including extension methods.
const std::string good_methods[] = {"GET", "PROPFIND", "M-SEARCH"};
for (const auto &method : good_methods) {
detail::BufferStream strm;
auto n = detail::write_request_line(strm, method, "/");
EXPECT_GT(n, 0);
EXPECT_EQ(method + " / HTTP/1.1\r\n", strm.get_buffer());
}
// A method carrying CR/LF would split the request line and smuggle a whole
// request ahead of the real one. A space or an empty method corrupts the
// request line. All must be rejected before anything reaches the wire.
const std::string evil_methods[] = {
"GET /smuggled HTTP/1.1\r\nHost: x\r\n\r\nGET",
"GET\r\nInjected: pwned",
"GET\r",
"GET\n",
"GE T",
"",
};
for (const auto &evil : evil_methods) {
detail::BufferStream strm;
auto n = detail::write_request_line(strm, evil, "/");
EXPECT_LT(n, 0);
EXPECT_TRUE(strm.get_buffer().empty());
}
}
TEST(RequestLineInjectionTest, ClientRejectsNonTokenMethodEndToEnd) {
// End-to-end counterpart to RejectsNonTokenMethod: a smuggling method passed
// through Client::send must fail with Error::Write and no request, neither
// the smuggled one nor the real one, may reach the server.
Server svr;
std::atomic<int> request_count(0);
svr.set_pre_routing_handler([&](const Request &, Response &res) {
request_count++;
res.status = StatusCode::OK_200;
return Server::HandlerResponse::Handled;
});
auto port = svr.bind_to_any_port(HOST);
auto thread = std::thread([&]() { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
thread.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
{
Client cli(HOST, port);
const std::string evil_methods[] = {
"GET /smuggled HTTP/1.1\r\nHost: x\r\n\r\nGET",
"GE T",
"",
};
for (const auto &evil : evil_methods) {
Request req;
req.method = evil;
req.path = "/";
auto res = cli.send(req);
EXPECT_FALSE(res);
EXPECT_EQ(Error::Write, res.error());
}
auto handle =
cli.open_stream("GET /smuggled HTTP/1.1\r\nHost: x\r\n\r\nGET", "/");
EXPECT_FALSE(handle.is_valid());
EXPECT_EQ(Error::Write, handle.error);
// A valid request on the same client still goes through.
auto res = cli.Get("/");
ASSERT_TRUE(res);
EXPECT_EQ(StatusCode::OK_200, res->status);
}
EXPECT_EQ(1, request_count.load());
}
// Sends a raw request and verifies that there isn't a crash or exception.
static void test_raw_request(const std::string &req,
std::string *out = nullptr) {
@@ -11982,6 +12106,48 @@ TEST(KeepAliveTest, ReadTimeoutSSL) {
EXPECT_EQ(StatusCode::OK_200, resb->status);
EXPECT_EQ("b", resb->body);
}
// Closing an idle keep-alive connection sends close_notify and returns. The
// server must not wait for the client's close_notify: an idle client never
// sends one, so the worker would be held until the read timeout expires, and
// stop() would wait for it.
TEST(KeepAliveTest, SSLIdleCloseDoesNotWaitForPeer) {
SSLServer svr(SERVER_CERT_FILE, SERVER_PRIVATE_KEY_FILE);
ASSERT_TRUE(svr.is_valid());
svr.set_keep_alive_timeout(1);
svr.set_read_timeout(10, 0);
svr.Get("/", [](const Request &, Response &res) {
res.set_content("ok", "text/plain");
});
auto port = svr.bind_to_any_port(HOST);
auto listen_thread = std::thread([&svr]() { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
if (listen_thread.joinable()) {
svr.stop();
listen_thread.join();
}
});
svr.wait_until_ready();
SSLClient cli(HOST, port);
cli.enable_server_certificate_verification(false);
cli.set_keep_alive(true);
auto res = cli.Get("/");
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
// Stay idle past the keep-alive timeout so the server closes the connection.
std::this_thread::sleep_for(std::chrono::milliseconds(1500));
auto start = std::chrono::steady_clock::now();
svr.stop();
listen_thread.join();
auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - start)
.count();
EXPECT_LT(elapsed, 3000);
}
#endif
class ServerTestWithAI_PASSIVE : public ::testing::Test {
@@ -17246,6 +17412,96 @@ TEST(VulnerabilityTest, CRLFInjectionInHeaders) {
server_thread.join();
}
// A request rejected before any byte reaches the socket must fail right away
// instead of waiting for a response the server will never send.
TEST(ClientRejectedRequestTest, DoesNotWaitForResponse) {
// The kernel completes the TCP handshake from the listen backlog, so the
// client connects, but nothing ever reads, responds or closes.
auto srv = ::socket(AF_INET, SOCK_STREAM, 0);
default_socket_options(srv);
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_port = htons(static_cast<uint16_t>(PORT + 1));
::inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr);
ASSERT_EQ(0, ::bind(srv, reinterpret_cast<sockaddr *>(&addr), sizeof(addr)));
ASSERT_EQ(0, ::listen(srv, 8));
auto cli = Client("127.0.0.1", PORT + 1);
cli.set_read_timeout(10, 0);
auto elapsed_ms = [](std::chrono::steady_clock::time_point start) {
return std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - start)
.count();
};
{
Request req;
req.method = "GE T";
req.path = "/";
auto start = std::chrono::steady_clock::now();
auto res = cli.send(req);
EXPECT_FALSE(res);
EXPECT_EQ(Error::Write, res.error());
EXPECT_LT(elapsed_ms(start), 1000);
}
{
auto start = std::chrono::steady_clock::now();
auto res = cli.Get("/", Headers{{"A", "B\r\nEvil: 1"}});
EXPECT_FALSE(res);
EXPECT_EQ(Error::InvalidHeaders, res.error());
EXPECT_LT(elapsed_ms(start), 1000);
}
EXPECT_FALSE(cli.is_socket_open());
detail::close_socket(srv);
}
TEST(ClientRejectedRequestTest, OpenStreamSendsNothingOnInvalidHeader) {
auto srv = ::socket(AF_INET, SOCK_STREAM, 0);
default_socket_options(srv);
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_port = htons(static_cast<uint16_t>(PORT + 1));
::inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr);
ASSERT_EQ(0, ::bind(srv, reinterpret_cast<sockaddr *>(&addr), sizeof(addr)));
ASSERT_EQ(0, ::listen(srv, 1));
std::string received;
auto server_thread = std::thread([&] {
auto sock = ::accept(srv, nullptr, nullptr);
if (sock == INVALID_SOCKET) { return; }
detail::set_socket_opt_time(sock, SOL_SOCKET, SO_RCVTIMEO, 2, 0);
char buf[2048];
ssize_t n;
while ((n = ::recv(sock, buf, sizeof(buf), 0)) > 0) {
received.append(buf, static_cast<size_t>(n));
}
detail::close_socket(sock);
});
{
auto cli = Client("127.0.0.1", PORT + 1);
// "Z" sorts after the default headers, so writing straight to the socket
// would have sent the request line and those headers before the rejection.
auto handle =
cli.open_stream("GET", "/", Params{}, Headers{{"Z", "B\r\nEvil: 1"}});
EXPECT_FALSE(handle.is_valid());
EXPECT_EQ(Error::InvalidHeaders, handle.error);
}
server_thread.join();
detail::close_socket(srv);
EXPECT_TRUE(received.empty()) << received;
}
TEST(PathParamsTest, StaticMatch) {
const auto pattern = "/users/all";
detail::PathParamsMatcher matcher(pattern);
@@ -18057,6 +18313,164 @@ TEST_F(ExpectTokenTest, ExpectationAmongOthersIsRecognized) {
EXPECT_TRUE(got_100);
}
// `100 Continue` is sent only when the server starts reading the body, so a
// request rejected before that never invites the client to send it. The
// requests below carry the expectation but withhold the body, as a client
// waiting for `100 Continue` would.
// A POST that expects `100 Continue` and withholds its two-byte body.
static std::string expect_headers_only(const std::string &path) {
return "POST " + path +
" HTTP/1.1\r\n"
"Host: localhost\r\n"
"Content-Length: 2\r\n"
"Expect: 100-continue\r\n"
"\r\n";
}
class ExpectLazyContinueTest : public ::testing::Test {
protected:
void SetUp() override {
svr_.set_pre_routing_handler([](const Request &req, Response &res) {
if (req.path == "/pre-routing") {
res.status = StatusCode::Unauthorized_401;
return Server::HandlerResponse::Handled;
}
return Server::HandlerResponse::Unhandled;
});
svr_.set_pre_request_handler([](const Request &req, Response &res) {
if (req.matched_route == "/pre-request") {
res.status = StatusCode::Forbidden_403;
return Server::HandlerResponse::Handled;
}
return Server::HandlerResponse::Unhandled;
});
svr_.Post("/pre-routing", [](const Request &, Response &res) {
res.set_content("ok", "text/plain");
});
svr_.Post("/pre-request", [](const Request &, Response &res) {
res.set_content("ok", "text/plain");
});
svr_.Post("/reader-used", [](const Request &, Response &res,
const ContentReader &content_reader) {
std::string body;
content_reader([&](const char *data, size_t len) {
body.append(data, len);
return true;
});
res.set_content(body, "text/plain");
});
svr_.Post("/reader-unused",
[](const Request &, Response &res, const ContentReader &) {
res.set_content("ignored", "text/plain");
});
port_ = svr_.bind_to_any_port(HOST);
thread_ = thread([&]() { svr_.listen_after_bind(); });
svr_.wait_until_ready();
}
void TearDown() override {
svr_.stop();
if (thread_.joinable()) { thread_.join(); }
}
// Sends `req` and reads until the server closes the connection. Returns
// false if the read had to wait for the client-side timeout instead.
bool send_until_closed(const std::string &req, std::string *resp) const {
auto start = std::chrono::steady_clock::now();
if (!send_request(3, req, resp, port_)) { return false; }
auto elapsed = std::chrono::steady_clock::now() - start;
return elapsed < std::chrono::seconds(2);
}
// The final response comes without `100 Continue`, and the server closes
// the connection since the body may or may not follow.
void expect_final_without_interim(const std::string &path,
const char *status_line) const {
std::string resp;
ASSERT_TRUE(send_until_closed(expect_headers_only(path), &resp));
EXPECT_EQ(std::string::npos, resp.find("100 Continue"));
EXPECT_EQ(0u, resp.find(status_line));
EXPECT_NE(std::string::npos, resp.find("Connection: close"));
}
Server svr_;
int port_ = 0;
thread thread_;
};
TEST_F(ExpectLazyContinueTest, PreRoutingRejectsWithoutInterimResponse) {
expect_final_without_interim("/pre-routing", "HTTP/1.1 401");
}
TEST_F(ExpectLazyContinueTest, PreRequestRejectsWithoutInterimResponse) {
expect_final_without_interim("/pre-request", "HTTP/1.1 403");
}
TEST_F(ExpectLazyContinueTest, UnknownRouteRejectsWithoutInterimResponse) {
expect_final_without_interim("/nowhere", "HTTP/1.1 404");
}
TEST_F(ExpectLazyContinueTest, UnreadContentReaderClosesWithoutInterim) {
expect_final_without_interim("/reader-unused", "HTTP/1.1 200");
}
TEST_F(ExpectLazyContinueTest, ContentReaderGetsInterimResponse) {
// The body follows once `100 Continue` has had time to arrive.
auto req = expect_headers_only("/reader-used");
req.insert(req.size() - 2, "Connection: close\r\n");
std::string resp;
ASSERT_TRUE(send_request_in_parts(3, {req, "hi"}, &resp, port_));
EXPECT_EQ(0u, resp.find("HTTP/1.1 100 Continue"));
EXPECT_NE(std::string::npos, resp.find("HTTP/1.1 200"));
EXPECT_NE(std::string::npos, resp.find("hi"));
}
TEST_F(ExpectLazyContinueTest, ClientWithholdsBodyWhenRejected) {
// Large enough for the client to add `Expect: 100-continue` itself.
const size_t length = CPPHTTPLIB_EXPECT_100_THRESHOLD * 4;
std::atomic<bool> body_sent{false};
Client cli(HOST, port_);
auto res = cli.Post(
"/pre-request", length,
[&](size_t /*offset*/, size_t len, DataSink &sink) {
body_sent = true;
std::string chunk(len, 'x');
sink.write(chunk.data(), chunk.size());
return true;
},
"application/octet-stream");
ASSERT_TRUE(res);
EXPECT_EQ(StatusCode::Forbidden_403, res->status);
EXPECT_FALSE(body_sent);
}
TEST(Expect100ContinueHandlerTest, ExpectationFailedIsFinalResponse) {
Server svr;
svr.set_expect_100_continue_handler([](const Request &, Response &) {
return StatusCode::ExpectationFailed_417;
});
svr.Post("/p", [](const Request &, Response &res) {
res.set_content("ok", "text/plain");
});
auto port = svr.bind_to_any_port(HOST);
thread t = thread([&] { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
});
svr.wait_until_ready();
std::string resp;
ASSERT_TRUE(send_request(3, expect_headers_only("/p"), &resp, port));
EXPECT_EQ(0u, resp.find("HTTP/1.1 417"));
EXPECT_NE(std::string::npos, resp.find("Connection: close"));
// Exactly one response: the route handler must not run after the 417.
EXPECT_EQ(std::string::npos, resp.find("HTTP/1.1", 1));
}
#ifndef _WIN32
TEST(Expect100ContinueTest, ServerClosesConnection) {
static constexpr char reject[] = "Unauthorized";
@@ -18665,6 +19079,108 @@ TEST(HeaderSmugglingTest, DuplicateTrailerFieldLinesDeclareAllTrailers) {
EXPECT_FALSE(observed_content_length);
}
// Undeclared trailer fields must count toward the trailer limit too. Otherwise
// a peer can keep the trailer-parsing loop running indefinitely by sending an
// unbounded run of fields that are never declared, because the counter would
// only advance for declared fields.
TEST(HeaderSmugglingTest, UndeclaredTrailerFieldsCountTowardLimit) {
Server svr;
bool handler_called = false;
svr.Get("/", [&](const Request & /*req*/, Response &res) {
handler_called = true;
res.set_content("ok", "text/plain");
});
auto port = svr.bind_to_any_port(HOST);
thread t = thread([&]() { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
// Declare nothing, then send far more undeclared trailer fields than the
// header-count limit. Parsing must stop and reject the request rather than
// read every line.
std::string req = "GET / HTTP/1.1\r\n"
"Transfer-Encoding: chunked\r\n"
"\r\n"
"0\r\n";
for (int i = 0; i < CPPHTTPLIB_HEADER_MAX_COUNT + 10; i++) {
req += "X-Undeclared-" + std::to_string(i) + ": v\r\n";
}
req += "\r\n";
std::string res;
ASSERT_TRUE(send_request(1, req, &res, port));
// The request is rejected before the handler runs.
EXPECT_FALSE(handler_called);
EXPECT_EQ("HTTP/1.1 400 Bad Request", res.substr(0, res.find("\r\n")));
}
// The set of declared trailer names is capped so a peer cannot grow it without
// bound (an unkeyed hash set would otherwise be a hash-flooding target). A name
// declared past the cap is not honored, even if the field is actually sent.
TEST(HeaderSmugglingTest, DeclaredTrailerNamesAreCappedAtHeaderMaxCount) {
Server svr;
// One name inside the cap and one past it, so the test tracks the cap rather
// than a fixed count.
constexpr int declared_count = CPPHTTPLIB_HEADER_MAX_COUNT + 50;
const std::string within_cap_name = "X-T-0";
const std::string past_cap_name = "X-T-" + std::to_string(declared_count - 1);
bool observed_within_cap = false;
bool observed_past_cap = false;
svr.Get("/", [&](const Request &req, Response &res) {
observed_within_cap = req.has_trailer(within_cap_name);
observed_past_cap = req.has_trailer(past_cap_name);
res.set_content("ok", "text/plain");
});
auto port = svr.bind_to_any_port(HOST);
thread t = thread([&]() { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
// Declare more trailer names than the cap in a single Trailer field, then
// actually send the first (within the cap) and the last (past it).
std::string trailer_decl = "Trailer: ";
for (int i = 0; i < declared_count; i++) {
if (i != 0) { trailer_decl += ", "; }
trailer_decl += "X-T-" + std::to_string(i);
}
trailer_decl += "\r\n";
const std::string req = "GET / HTTP/1.1\r\n"
"Transfer-Encoding: chunked\r\n" +
trailer_decl +
"\r\n"
"0\r\n" +
within_cap_name + ": a\r\n" + past_cap_name +
": b\r\n"
"\r\n";
std::string res;
ASSERT_TRUE(send_request(1, req, &res, port));
EXPECT_EQ("HTTP/1.1 200 OK", res.substr(0, res.find("\r\n")));
// A name within the cap is honored; one declared past the cap is dropped.
EXPECT_TRUE(observed_within_cap);
EXPECT_FALSE(observed_past_cap);
}
// A direct client that is not listed in trusted_proxies must not be able to
// spoof req.remote_addr by sending an arbitrary X-Forwarded-For header. Only
// the peer address on the actual TCP connection determines whether the
@@ -23360,6 +23876,20 @@ TEST(WebSocketPreRoutingTest, RejectWithoutAuth) {
ws::WebSocketClient client1("ws://localhost:" + std::to_string(port) + "/ws");
EXPECT_FALSE(client1.connect());
// The rejection is framed like any other HTTP response
{
Client cli("localhost", port);
Headers headers = {{"Upgrade", "websocket"},
{"Connection", "Upgrade"},
{"Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ=="},
{"Sec-WebSocket-Version", "13"}};
auto res = cli.Get("/ws", headers);
ASSERT_TRUE(res);
EXPECT_EQ(StatusCode::Unauthorized_401, res->status);
EXPECT_EQ("12", res->get_header_value("Content-Length"));
EXPECT_EQ("Unauthorized", res->body);
}
// With Authorization header - should succeed
Headers headers = {{"Authorization", "Bearer token123"}};
ws::WebSocketClient client2("ws://localhost:" + std::to_string(port) + "/ws",
@@ -23375,6 +23905,70 @@ TEST(WebSocketPreRoutingTest, RejectWithoutAuth) {
t.join();
}
TEST(WebSocketPreRequestTest, RejectWithoutAuth) {
Server svr;
std::atomic<int> pre_request_calls{0};
std::atomic<bool> route_matched{false};
svr.set_pre_request_handler([&](const Request &req, Response &res) {
pre_request_calls++;
if (req.matched_route == "/ws/:id") { route_matched = true; }
if (req.get_header_value("Authorization") != "Bearer token123") {
res.status = StatusCode::Unauthorized_401;
res.set_content("Unauthorized", "text/plain");
return Server::HandlerResponse::Handled;
}
return Server::HandlerResponse::Unhandled;
});
std::atomic<bool> handler_called{false};
svr.WebSocket("/ws/:id", [&](const Request &req, ws::WebSocket &ws) {
handler_called = true;
ws.send(req.matched_route + " " + req.path_params.at("id"));
});
auto port = svr.bind_to_any_port("localhost");
std::thread t([&]() { svr.listen_after_bind(); });
svr.wait_until_ready();
// Without Authorization header - should be rejected before upgrade
ws::WebSocketClient client1("ws://localhost:" + std::to_string(port) +
"/ws/1");
EXPECT_FALSE(client1.connect());
EXPECT_FALSE(handler_called);
EXPECT_EQ(1, pre_request_calls);
EXPECT_TRUE(route_matched);
// The rejection is an ordinary HTTP response, not a protocol switch
{
Client cli("localhost", port);
Headers headers = {{"Upgrade", "websocket"},
{"Connection", "Upgrade"},
{"Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ=="},
{"Sec-WebSocket-Version", "13"}};
auto res = cli.Get("/ws/1", headers);
ASSERT_TRUE(res);
EXPECT_EQ(StatusCode::Unauthorized_401, res->status);
EXPECT_EQ("12", res->get_header_value("Content-Length"));
EXPECT_EQ("Unauthorized", res->body);
}
EXPECT_FALSE(handler_called);
// With Authorization header - should succeed
Headers headers = {{"Authorization", "Bearer token123"}};
ws::WebSocketClient client2(
"ws://localhost:" + std::to_string(port) + "/ws/2", headers);
ASSERT_TRUE(client2.connect());
std::string msg;
ASSERT_TRUE(client2.read(msg));
EXPECT_EQ("/ws/:id 2", msg);
EXPECT_TRUE(handler_called);
client2.close();
svr.stop();
t.join();
}
TEST(WebSocketServerTimeoutTest, HandlerSendsWhileNothingArrives) {
Server svr;