mirror of
https://github.com/yhirose/cpp-httplib.git
synced 2026-09-30 20:52:31 +07:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f3f9ef19b | ||
|
|
6d59d1e2df | ||
|
|
9386b25dd7 | ||
|
|
91c55a4385 | ||
|
|
ad88645a83 | ||
|
|
82722fcb13 | ||
|
|
8b872605e0 | ||
|
|
52f214bf2e | ||
|
|
09c02f1335 | ||
|
|
2e5480ad65 | ||
|
|
4cb363e3f2 | ||
|
|
deb520e26b | ||
|
|
f37a5b1407 | ||
|
|
f15992c7ed |
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()`.
|
||||
|
||||
@@ -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()`の組み合わせです。
|
||||
|
||||
@@ -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ハンドラーを登録するだけです。
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user