Read request body after route matching and pre-request handler

Previously, for regular handlers the request body was read in routing()
before the route was matched, so the pre-request handler always saw an
already-read body. The ContentReader path, in contrast, ran the
pre-request handler before the body was read. This inconsistency made
it impossible to reject a request (e.g. failed per-route authentication
via req.matched_route) without buffering a potentially large body.

Move the read_content() call into dispatch_request(), after route
matching and the pre-request handler, so both paths behave the same:
route matching -> pre_request_handler -> body read -> handler. A
request rejected by the pre-request handler no longer reads the body
at all; the existing keep-alive drain logic still consumes any framed
body afterwards.

Note: code that referenced req.body or body-derived form fields inside
the pre-request handler will now see an empty body. Inspect headers,
path, query parameters, or matched_route instead.

Also document the handler execution order in README and update the
pre-request cookbook pages (en/ja).
This commit is contained in:
yhirose
2026-06-09 13:49:56 -04:00
parent 79d83feb18
commit 77bdf7921a
5 changed files with 133 additions and 25 deletions
+34
View File
@@ -450,6 +450,8 @@ svr.set_post_routing_handler([](const auto& req, auto& res) {
### Pre request handler ### Pre request handler
The pre-request handler runs after the route has been matched (so `req.matched_route` and `req.path_params` are available) but **before the request body is read**. This means you can reject a request — for example on a failed authentication or authorization check — without forcing the server to buffer a potentially large body.
```cpp ```cpp
svr.set_pre_request_handler([](const auto& req, auto& res) { svr.set_pre_request_handler([](const auto& req, auto& res) {
if (req.matched_route == "/user/:user") { if (req.matched_route == "/user/:user") {
@@ -464,6 +466,38 @@ svr.set_pre_request_handler([](const auto& req, auto& res) {
}); });
``` ```
> [!NOTE]
> Because the body has not been read yet, `req.body` and form fields parsed from the body are not available in the pre-request handler. Inspect headers, the path, query parameters, or `req.matched_route` instead.
### Handler execution order
`set_start_handler` runs once when the server starts. For each request, handlers run in the following order:
```
Request received
│
├─ 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
│ └─ returns Handled → stop here (route handler is skipped)
│
├─ route handler Get/Post/...; the request body is read first
│
└─ post_routing_handler after routing completes
On a thrown exception → exception_handler
On an error status (4xx/5xx) → error_handler
```
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.
### Response user data ### 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. `res.user_data` is a type-safe key-value store that lets pre-routing or pre-request handlers pass arbitrary data to route handlers.
@@ -8,13 +8,15 @@ The `set_pre_routing_handler()` from [S09. Add pre-processing to all routes](s09
## Pre-routing vs. pre-request ## Pre-routing vs. pre-request
| Hook | When it runs | Route info | | Hook | When it runs | Route info | Request body |
| --- | --- | --- | | --- | --- | --- | --- |
| `set_pre_routing_handler` | Before routing | Not available | | `set_pre_routing_handler` | Before routing | Not available | Not read yet |
| `set_pre_request_handler` | After routing, right before the route handler | Available via `req.matched_route` | | `set_pre_request_handler` | After routing, right before the route handler | Available via `req.matched_route` | Not read yet |
In a pre-request handler, `req.matched_route` holds the **pattern string** that matched. You can vary behavior based on the route definition itself. In a pre-request handler, `req.matched_route` holds the **pattern string** that matched. You can vary behavior based on the route definition itself.
Because the body has not been read when the pre-request handler runs, you can reject a request — for example on a failed auth check — without consuming a (potentially large) request body. Note that this also means `req.body` and form fields parsed from the body are not available here; inspect headers, the path, query parameters, or `req.matched_route` instead.
## Switch auth per route ## Switch auth per route
```cpp ```cpp
@@ -8,13 +8,15 @@ status: "draft"
## Pre-routingとの違い ## Pre-routingとの違い
| フック | 呼ばれるタイミング | ルート情報 | | フック | 呼ばれるタイミング | ルート情報 | リクエストボディ |
| --- | --- | --- | | --- | --- | --- | --- |
| `set_pre_routing_handler` | ルーティングの前 | 取得できない | | `set_pre_routing_handler` | ルーティングの前 | 取得できない | まだ読まれていない |
| `set_pre_request_handler` | ルーティング後、ルートハンドラの直前 | `req.matched_route`で取得可能 | | `set_pre_request_handler` | ルーティング後、ルートハンドラの直前 | `req.matched_route`で取得可能 | まだ読まれていない |
Pre-requestハンドラなら、`req.matched_route`に「マッチしたパターン文字列」が入っているので、ルートに応じて処理を変えられます。 Pre-requestハンドラなら、`req.matched_route`に「マッチしたパターン文字列」が入っているので、ルートに応じて処理を変えられます。
Pre-requestハンドラが呼ばれる時点ではボディがまだ読まれていないので、認証に失敗したリクエストなどを、(巨大かもしれない)ボディを読み込む前に拒否できます。その代わり、`req.body`やボディから解析されるフォームフィールドはこの時点では参照できません。ヘッダ・パス・クエリパラメータ・`req.matched_route`を使って判断してください。
## ルートごとに認証を切り替える ## ルートごとに認証を切り替える
```cpp ```cpp
+29 -17
View File
@@ -1812,8 +1812,8 @@ private:
const std::string &etag, time_t mtime) const; const std::string &etag, time_t mtime) const;
bool check_if_range(Request &req, const std::string &etag, bool check_if_range(Request &req, const std::string &etag,
time_t mtime) const; time_t mtime) const;
bool dispatch_request(Request &req, Response &res, bool dispatch_request(Request &req, Response &res, const Handlers &handlers,
const Handlers &handlers) const; Stream &strm);
bool dispatch_request_for_content_reader( bool dispatch_request_for_content_reader(
Request &req, Response &res, ContentReader content_reader, Request &req, Response &res, ContentReader content_reader,
const HandlersForContentReader &handlers) const; const HandlersForContentReader &handlers) const;
@@ -11955,26 +11955,26 @@ inline bool Server::routing(Request &req, Response &res, Stream &strm) {
} }
} }
// Read content into `req.body` // NOTE: `req.body` is not read here. For a regular handler the body is
if (!read_content(strm, req, res)) { // read inside dispatch_request(), after the route has matched and the
output_error_log(Error::Read, &req); // pre-request handler has approved the request, so that a rejected
return false; // request (e.g. failed authentication) never forces us to buffer a
} // potentially large body.
} }
// Regular handler // Regular handler
if (req.method == "GET" || req.method == "HEAD") { if (req.method == "GET" || req.method == "HEAD") {
return dispatch_request(req, res, get_handlers_); return dispatch_request(req, res, get_handlers_, strm);
} else if (req.method == "POST") { } else if (req.method == "POST") {
return dispatch_request(req, res, post_handlers_); return dispatch_request(req, res, post_handlers_, strm);
} else if (req.method == "PUT") { } else if (req.method == "PUT") {
return dispatch_request(req, res, put_handlers_); return dispatch_request(req, res, put_handlers_, strm);
} else if (req.method == "DELETE") { } else if (req.method == "DELETE") {
return dispatch_request(req, res, delete_handlers_); return dispatch_request(req, res, delete_handlers_, strm);
} else if (req.method == "OPTIONS") { } else if (req.method == "OPTIONS") {
return dispatch_request(req, res, options_handlers_); return dispatch_request(req, res, options_handlers_, strm);
} else if (req.method == "PATCH") { } else if (req.method == "PATCH") {
return dispatch_request(req, res, patch_handlers_); return dispatch_request(req, res, patch_handlers_, strm);
} }
res.status = StatusCode::BadRequest_400; res.status = StatusCode::BadRequest_400;
@@ -11982,17 +11982,29 @@ inline bool Server::routing(Request &req, Response &res, Stream &strm) {
} }
inline bool Server::dispatch_request(Request &req, Response &res, inline bool Server::dispatch_request(Request &req, Response &res,
const Handlers &handlers) const { const Handlers &handlers, Stream &strm) {
for (const auto &x : handlers) { for (const auto &x : handlers) {
const auto &matcher = x.first; const auto &matcher = x.first;
const auto &handler = x.second; const auto &handler = x.second;
if (matcher->match(req)) { if (matcher->match(req)) {
req.matched_route = matcher->pattern(); req.matched_route = matcher->pattern();
if (!pre_request_handler_ ||
pre_request_handler_(req, res) != HandlerResponse::Handled) { // Run the pre-request handler before reading the body so a rejected
handler(req, res); // request (e.g. failed authentication) never forces us to buffer a
// potentially large body. `req.matched_route` is available here.
if (pre_request_handler_ &&
pre_request_handler_(req, res) == HandlerResponse::Handled) {
return true;
} }
// The route matched and the request was approved; read the body now.
if (detail::expect_content(req) && !read_content(strm, req, res)) {
output_error_log(Error::Read, &req);
return false;
}
handler(req, res);
return true; return true;
} }
} }
+58
View File
@@ -3289,6 +3289,64 @@ TEST(RequestHandlerTest, PreRequestHandler) {
} }
} }
// The pre-request handler must run before the request body is read, so a
// rejected request never forces the server to buffer a (potentially large)
// body. Here the posted body is larger than the payload limit: if the body
// were read first the server would answer 413, but because the pre-request
// handler runs first it answers 403 and the body is never read.
TEST(RequestHandlerTest, PreRequestHandlerRunsBeforeBodyIsRead) {
Server svr;
svr.set_payload_max_length(8);
auto handler_ran = false;
svr.Post("/reject", [&](const Request &req, Response &res) {
handler_ran = true;
res.set_content(req.body, "text/plain");
});
svr.Post("/accept", [](const Request &req, Response &res) {
res.set_content(req.body, "text/plain");
});
svr.set_pre_request_handler([](const Request &req, Response &res) {
if (req.matched_route == "/reject") {
res.status = StatusCode::Forbidden_403;
res.set_content("denied", "text/plain");
return Server::HandlerResponse::Handled;
}
return Server::HandlerResponse::Unhandled;
});
auto thread = std::thread([&]() { svr.listen(HOST, PORT); });
auto se = detail::scope_exit([&] {
svr.stop();
thread.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
Client cli(HOST, PORT);
// Body (10 bytes) exceeds the 8-byte limit, yet the pre-request handler
// rejects with 403 before the body is read, so 413 is never reached.
{
auto res = cli.Post("/reject", "0123456789", "text/plain");
ASSERT_TRUE(res);
EXPECT_EQ(StatusCode::Forbidden_403, res->status);
EXPECT_EQ("denied", res->body);
EXPECT_FALSE(handler_ran);
}
// An approved route still reads the body and enforces the payload limit.
{
auto res = cli.Post("/accept", "0123456789", "text/plain");
ASSERT_TRUE(res);
EXPECT_EQ(StatusCode::PayloadTooLarge_413, res->status);
}
}
TEST(UserDataTest, BasicOperations) { TEST(UserDataTest, BasicOperations) {
httplib::UserData ud; httplib::UserData ud;