diff --git a/docs/guides/app.md b/docs/guides/app.md index daa17217a2..afb2906062 100644 --- a/docs/guides/app.md +++ b/docs/guides/app.md @@ -52,6 +52,25 @@ app.tcp_nodelay(true) .run(); ``` +## Request body size +[:octicons-feed-tag-16: master](https://github.com/CrowCpp/Crow) + +By default Crow accepts a request body of any size. `#!cpp app.max_body_size(bytes)` sets an app-wide limit (`UINT64_MAX` is unlimited). A route can override it with `#!cpp .max_body_size(bytes)`. + +The advertised `Content-Length` is checked when headers complete, before `100 Continue`; no body bytes are read at all if it is already over the cap. Chunked bodies, and bodies with neither `Content-Length` nor `Transfer-Encoding` (read until the connection closes), are counted as they arrive instead, since their size isn't known upfront. An over-limit request is answered `413 Payload Too Large`; the body is not stored and the route handler does not run. If the body was still arriving when the cap was hit, the connection lingers rather than closing outright: the write side is shut down once the 413 is sent, and whatever the client keeps sending is drained and discarded, so a client still mid-upload isn't reset before it can read the response. The cap applies to every request that reaches this check, including matched routes, 404, 405, and slash-redirects — but not an unmatched `HEAD` or `OPTIONS` request, which is answered before headers complete and reads no body either way. Only global after-handlers run on the 413; route-local middleware and before-handlers do not. + +```cpp +crow::SimpleApp app; +app.max_body_size(64ull * 1024 * 1024); + +CROW_ROUTE(app, "/upload") + .methods(crow::HTTPMethod::Post) + .max_body_size(8ull * 1024 * 1024) + ([](const crow::request& req) { + return crow::response(200); + }); +``` +

For more info on middlewares, check out [this page](middleware.md).

diff --git a/docs/guides/routes.md b/docs/guides/routes.md index 4552905fac..781cfc6c4f 100644 --- a/docs/guides/routes.md +++ b/docs/guides/routes.md @@ -82,6 +82,7 @@ Handlers can also use information from the request by adding it as a parameter ` You can also access the URL parameters in the handler using `#!cpp req.url_params.get("param_name");`. If the parameter doesn't exist, `nullptr` is returned.

+To cap the request body for one route, use `#!cpp .max_body_size(bytes)` (see [Request body size](app.md#request-body-size)). Without a per-route value, `#!cpp app.max_body_size()` applies, including on 404 and 405. !!! note "Note      [:octicons-feed-tag-16: master](https://github.com/CrowCpp/Crow)" diff --git a/include/crow/app.h b/include/crow/app.h index 7aa60522db..4180f896aa 100644 --- a/include/crow/app.h +++ b/include/crow/app.h @@ -349,6 +349,31 @@ namespace crow return max_payload_; } + /// \brief Set the maximum request body size in bytes (`UINT64_MAX` = unlimited, the default). + /// + /// Applied to every request, including 404, 405, and slash-redirects. + /// Advertised `Content-Length` is checked at headers-complete, before `100 Continue`. + /// Chunked bodies are counted as they arrive. An over-limit request is answered 413 + /// and the connection is closed; the body is not stored, but leftover bytes are + /// drained and discarded rather than left unread. + self_t& max_body_size(uint64_t bytes) + { + max_body_size_ = bytes; + return *this; + } + + /// \brief Get the app-wide request body size limit + uint64_t max_body_size() const + { + return max_body_size_; + } + + /// \brief Effective body size limit for a routing result (used by the connection). + uint64_t effective_max_body_size(const routing_handle_result& found) const + { + return router_.effective_max_body_size(found, max_body_size_); + } + self_t& signal_clear() { signals_.clear(); @@ -914,6 +939,7 @@ namespace crow unsigned int concurrency_ = 2; std::atomic_bool is_bound_ = false; uint64_t max_payload_{UINT64_MAX}; + uint64_t max_body_size_{UINT64_MAX}; std::string server_name_ = std::string("Crow/") + VERSION; std::string bindaddr_ = "0.0.0.0"; bool use_unix_ = false; diff --git a/include/crow/common.h b/include/crow/common.h index a8e58f4abc..8af19afb59 100644 --- a/include/crow/common.h +++ b/include/crow/common.h @@ -291,7 +291,7 @@ namespace crow size_t rule_index; std::vector blueprint_indices; routing_params r_params; - HTTPMethod method; + HTTPMethod method{HTTPMethod::InternalMethodCount}; routing_handle_result() {} diff --git a/include/crow/http_connection.h b/include/crow/http_connection.h index 2ebf72a956..e500b36ed6 100644 --- a/include/crow/http_connection.h +++ b/include/crow/http_connection.h @@ -121,8 +121,21 @@ namespace crow } } - void handle_header() + /// Apply `max_body_size` before 100-continue. Return 1 to skip the body (F_SKIPBODY). + int handle_header() { + payload_too_large_ = false; + const uint64_t limit = handler_->effective_max_body_size(*routing_handle_result_); + parser_.set_max_body_size(limit); + + // limit == UINT64_MAX means unlimited, so this never rejects in the default case. + if (parser_.content_length != CROW_ULLONG_MAX && + limit != UINT64_MAX && parser_.content_length > limit) + { + payload_too_large_ = true; + return 1; + } + // HTTP 1.1 Expect: 100-continue if (req_.http_ver_major == 1 && req_.http_ver_minor == 1 && get_header_value(req_.headers, "expect") == "100-continue") { @@ -142,6 +155,18 @@ namespace crow need_to_call_after_handlers_ = true; complete_request(); } + return 0; + } + + void reject_payload_too_large() + { + payload_too_large_ = true; + handle(); + } + + bool parser_should_abort() const + { + return payload_too_large_; } void handle() @@ -160,6 +185,21 @@ namespace crow add_keep_alive_ = req_.keep_alive; close_connection_ = req_.close_connection; + if (payload_too_large_) + { + req_.body.clear(); + res = response(status::PAYLOAD_TOO_LARGE); + // Explicit, rather than relying on response::operator=(&&) happening to + // omit skip_body: a HEAD request must never get a body, even on 413. + res.skip_body = (req_.method == HTTPMethod::Head); + res.set_header("Connection", "close"); + res.end(); + close_connection_ = true; + add_keep_alive_ = false; + need_to_call_after_handlers_ = true; + complete_request(); + return; + } if (req_.check_version(1, 1)) // HTTP/1.1 { if (!req_.headers.count("host")) @@ -408,9 +448,20 @@ namespace crow { self->cancel_deadline_timer(); self->parser_.done(); - self->adaptor_.shutdown_read(); - self->adaptor_.close(); CROW_LOG_DEBUG << self << " from read(1) with description: \"" << http_errno_description(static_cast(self->parser_.http_errno)) << '\"'; + if (self->payload_too_large_) + { + // The rejection response is already written; shut down the + // write side and drain whatever the client still sends + // instead of closing on unread bytes, which can RST a peer + // that is still mid-upload before it reads the response. + self->linger_close(); + } + else + { + self->adaptor_.shutdown_read(); + self->adaptor_.close(); + } } else if (self->close_connection_) { @@ -431,6 +482,37 @@ namespace crow }); } + /// Shut down the write side (FIN, not RST) after a rejection response and + /// discard whatever the client still sends, instead of closing on unread + /// bytes. Bounded by the same deadline timer a slow client already gets. + void linger_close() + { + adaptor_.shutdown_write(); + start_deadline(); + do_linger_read(); + } + + // Discards whatever bytes come in (the count doesn't matter - this is + // draining, not parsing) and keeps re-reading until the peer closes, + // errors, or the deadline timer set by linger_close() fires. + void do_linger_read() + { + auto self = this->shared_from_this(); + adaptor_.socket().async_read_some( + asio::buffer(buffer_), + [self](const error_code& ec, std::size_t /*bytes_transferred*/) { + if (!ec && self->adaptor_.is_open()) + { + self->do_linger_read(); + } + else + { + self->cancel_deadline_timer(); + self->adaptor_.close(); + } + }); + } + void do_write() { auto self = this->shared_from_this(); @@ -480,10 +562,17 @@ namespace crow { this->continue_requested = false; } - else + else if (!this->payload_too_large_) { this->parser_.clear(); } + // else: this response is the 413 itself, written while the llhttp + // callback that triggered the reject is still on the stack + // (http_parser_execute has not returned yet), so clearing here would + // reenter the parser mid-message. The connection is always closed + // afterwards (never reused for another request), so there is no + // keep-alive state left to reset; skip the clear instead of doing it + // unsafely. return ec; } @@ -536,6 +625,7 @@ namespace crow bool need_to_call_after_handlers_{}; bool need_to_start_read_after_complete_{}; bool add_keep_alive_{}; + bool payload_too_large_{false}; std::tuple* middlewares_; detail::context ctx_; diff --git a/include/crow/http_response.h b/include/crow/http_response.h index 71a9e0c7a9..61cd83625e 100644 --- a/include/crow/http_response.h +++ b/include/crow/http_response.h @@ -412,7 +412,7 @@ namespace crow auto& status = statusCodes.find(code)->second; buffers.emplace_back(status.data(), status.size()); - if (code >= 400 && body.empty()) + if (code >= 400 && body.empty() && !skip_body) body = statusCodes[code].substr(9); for (auto& kv : headers) diff --git a/include/crow/parser.h b/include/crow/parser.h index 1417d6cb60..acf253d634 100644 --- a/include/crow/parser.h +++ b/include/crow/parser.h @@ -1,8 +1,9 @@ #pragma once +#include +#include #include #include -#include #include "crow/http_request.h" #include "crow/http_parser_merged.h" @@ -82,13 +83,20 @@ namespace crow self->set_connection_parameters(); - self->process_header(); - return 0; + return self->process_header(); } static int on_body(http_parser* self_, const char* at, size_t length) { HTTPParser* self = static_cast(self_); + if (self->max_body_size_ != UINT64_MAX && + (length > self->max_body_size_ || + self->body_bytes_ > self->max_body_size_ - length)) + { + self->handler_->reject_payload_too_large(); + return 1; + } self->req.body.insert(self->req.body.end(), at, at + length); + self->body_bytes_ += length; return 0; } static int on_message_complete(http_parser* self_) @@ -97,7 +105,8 @@ namespace crow self->message_complete = true; self->process_message(); - return 0; + // Stop so leftover skipped-body bytes are not parsed as the next request. + return self->handler_->parser_should_abort() ? 1 : 0; } HTTPParser(Handler* handler): http_parser(), @@ -145,17 +154,24 @@ namespace crow header_building_state = 0; qs_point = 0; message_complete = false; + body_bytes_ = 0; + max_body_size_ = UINT64_MAX; state = CROW_NEW_MESSAGE(); } + void set_max_body_size(uint64_t bytes) + { + max_body_size_ = bytes; + } + inline void process_url() { handler_->handle_url(); } - inline void process_header() + inline int process_header() { - handler_->handle_header(); + return handler_->handle_header(); } inline void process_message() @@ -190,6 +206,8 @@ namespace crow private: int header_building_state = 0; bool message_complete = false; + uint64_t body_bytes_{0}; + uint64_t max_body_size_{UINT64_MAX}; std::string header_field; std::string header_value; diff --git a/include/crow/routing.h b/include/crow/routing.h index 42ac0a78f9..5734e6b1ad 100644 --- a/include/crow/routing.h +++ b/include/crow/routing.h @@ -160,6 +160,7 @@ namespace crow // NOTE: Already documented in "crow/app.h" std::string rule_; std::string name_; bool added_{false}; + std::optional max_body_size_override_; std::unique_ptr rule_to_upgrade_; @@ -618,6 +619,13 @@ namespace crow // NOTE: Already documented in "crow/app.h" static_cast(this)->mw_indices_.template push(); return static_cast(*this); } + + /// Override the app-wide request body size limit for this route. + self_t& max_body_size(uint64_t bytes) + { + static_cast(this)->max_body_size_override_ = bytes; + return static_cast(*this); + } }; /// A rule that can change its parameters during runtime. @@ -1842,6 +1850,25 @@ namespace crow // NOTE: Already documented in "crow/app.h" return blueprints_; } + /// Effective request body limit for a `handle_initial()` result. + /// + /// 404, 405, slash-redirect, and unmatched OPTIONS use `app_default`. + /// A matched rule uses its override when set, otherwise `app_default`. + uint64_t effective_max_body_size(const routing_handle_result& found, uint64_t app_default) const + { + if (found.catch_all || found.rule_index <= RULE_SPECIAL_REDIRECT_SLASH) + return app_default; + if (found.method >= HTTPMethod::InternalMethodCount) + return app_default; + const auto& rules = per_methods_[static_cast(found.method)].rules; + if (found.rule_index >= rules.size()) + return app_default; + const BaseRule* rule = rules[found.rule_index]; + if (!rule || !rule->max_body_size_override_) + return app_default; + return *rule->max_body_size_override_; + } + std::function& exception_handler() { return exception_handler_; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 00db73a7be..6773e80e74 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -22,6 +22,7 @@ enable_testing() # list the test sources set(TEST_SRCS unittest.cpp + max_body_size_tests.cpp query_string_tests.cpp unit_tests/test_http_response.cpp unit_tests/test_json.cpp diff --git a/tests/fuzz/http_fuzzer.cpp b/tests/fuzz/http_fuzzer.cpp index c77956ca26..5dd653a667 100644 --- a/tests/fuzz/http_fuzzer.cpp +++ b/tests/fuzz/http_fuzzer.cpp @@ -4,8 +4,10 @@ struct DummyHandler { void handle_url() {} - void handle_header() {} + int handle_header() { return 0; } void handle() {} + void reject_payload_too_large() {} + bool parser_should_abort() const { return false; } size_t stream_threshold() { return 1024*1024; } }; diff --git a/tests/max_body_size_tests.cpp b/tests/max_body_size_tests.cpp new file mode 100644 index 0000000000..e936a718f6 --- /dev/null +++ b/tests/max_body_size_tests.cpp @@ -0,0 +1,680 @@ +#define CROW_ENABLE_DEBUG +#define CROW_LOG_LEVEL 0 + +#include +#include +#include +#include +#include +#include + +#include "catch2/catch_all.hpp" +#include "crow.h" +#include "crow/middlewares/cors.h" + +using namespace crow; + +#ifdef CROW_USE_BOOST +namespace asio = boost::asio; +using asio_error_code = boost::system::error_code; +#else +using asio_error_code = asio::error_code; +#endif + +#define LOCALHOST_ADDRESS "127.0.0.1" + +namespace +{ + // Finds the final (non-1xx) status line, skipping any interim 1xx lines + // (e.g. "100 Continue") that may precede it. Returns npos if the data + // seen so far doesn't yet contain one. + std::size_t find_final_status_line(const std::string& data) + { + auto search_from = std::size_t{0}; + while (true) + { + const auto status_line = data.find("HTTP/1.1 ", search_from); + if (status_line == std::string::npos) + return std::string::npos; + const auto code = std::atoi(data.c_str() + status_line + 9); + if (code >= 100 && code < 200) + { + const auto header_end = data.find("\r\n\r\n", status_line); + if (header_end == std::string::npos) + return std::string::npos; + search_from = header_end + 4; + continue; + } + return status_line; + } + } + + bool response_complete(const std::string& data) + { + const auto status_line = find_final_status_line(data); + if (status_line == std::string::npos) + return false; + const auto header_end = data.find("\r\n\r\n", status_line); + if (header_end == std::string::npos) + return false; + const auto length_pos = data.find("Content-Length:", status_line); + if (length_pos == std::string::npos || length_pos > header_end) + return true; + const auto length = static_cast(std::stoul(data.substr(length_pos + 15))); + return data.size() >= header_end + 4 + length; + } + + class TestClient + { + public: + TestClient(uint16_t port): + socket_(io_context_) + { + socket_.connect(asio::ip::tcp::endpoint(asio::ip::make_address(LOCALHOST_ADDRESS), port)); + } + + void send(const std::string& data) + { + asio::write(socket_, asio::buffer(data)); + } + + std::string receive() + { + std::string response; + std::array buffer{}; + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5); + while (!response_complete(response)) + { + REQUIRE(std::chrono::steady_clock::now() < deadline); + asio_error_code ec; + const auto n = socket_.read_some(asio::buffer(buffer), ec); + if (ec) + break; + response.append(buffer.data(), n); + } + return response; + } + + private: + asio::io_context io_context_{}; + asio::ip::tcp::socket socket_; + }; + + int status_of(const std::string& response) + { + const auto status_line = find_final_status_line(response); + if (status_line == std::string::npos) + return 0; + return std::atoi(response.c_str() + status_line + 9); + } +} // namespace + +TEST_CASE("max_body_size advertised length", "[http][max_body_size]") +{ + std::atomic handler_ran{false}; + SimpleApp app; + app.max_body_size(8); + + CROW_ROUTE(app, "/upload") + .methods("POST"_method)([&handler_ran](const request& req) { + handler_ran = true; + return req.body; + }); + + auto server = app.bindaddr(LOCALHOST_ADDRESS).port(0).run_async(); + app.wait_for_server_start(); + const auto port = app.port(); + + auto post = [&](const std::string& target, const std::string& payload, + const std::string& extra = {}) { + TestClient client(port); + client.send( + "POST " + target + + " HTTP/1.1\r\n" + "Host: localhost\r\n" + + extra + + "Content-Length: " + std::to_string(payload.size()) + + "\r\n" + "\r\n" + + payload); + return client.receive(); + }; + + SECTION("body of exactly max is accepted") + { + const auto resp = post("/upload", "12345678"); + CHECK(status_of(resp) == 200); + CHECK(resp.find("12345678") != std::string::npos); + CHECK(handler_ran); + } + + SECTION("Content-Length above the cap is 413 at headers and does not run the handler") + { + handler_ran = false; + const auto resp = post("/upload", "123456789"); + CHECK(status_of(resp) == 413); + CHECK(resp.find("Connection: close") != std::string::npos); + CHECK_FALSE(handler_ran); + } + + SECTION("Expect 100-continue is not sent for an over-limit body") + { + handler_ran = false; + TestClient client(port); + client.send( + "POST /upload HTTP/1.1\r\n" + "Host: localhost\r\n" + "Expect: 100-continue\r\n" + "Content-Length: 5000000\r\n" + "\r\n"); + const auto resp = client.receive(); + CHECK(resp.find("100 Continue") == std::string::npos); + CHECK(status_of(resp) == 413); + CHECK_FALSE(handler_ran); + } + + SECTION("unmatched URL still applies the app limit") + { + handler_ran = false; + const auto resp = post("/missing", std::string(100, 'x')); + CHECK(status_of(resp) == 413); + CHECK_FALSE(handler_ran); + } + + SECTION("wrong method still applies the app limit") + { + handler_ran = false; + TestClient client(port); + client.send( + "PUT /upload HTTP/1.1\r\n" + "Host: localhost\r\n" + "Content-Length: 100\r\n" + "\r\n" + + std::string(100, 'x')); + const auto resp = client.receive(); + CHECK(status_of(resp) == 413); + CHECK_FALSE(handler_ran); + } + + app.stop(); +} + +TEST_CASE("max_body_size slash redirect", "[http][max_body_size]") +{ + std::atomic handler_ran{false}; + SimpleApp app; + app.max_body_size(8); + + CROW_ROUTE(app, "/probe/") + .methods("POST"_method)([&handler_ran] { + handler_ran = true; + return "ok"; + }); + + auto server = app.bindaddr(LOCALHOST_ADDRESS).port(0).run_async(); + app.wait_for_server_start(); + + TestClient client(app.port()); + client.send( + "POST /probe HTTP/1.1\r\n" + "Host: localhost\r\n" + "Content-Length: 100\r\n" + "\r\n" + + std::string(100, 'x')); + const auto resp = client.receive(); + CHECK(status_of(resp) == 413); + CHECK_FALSE(handler_ran); + + app.stop(); +} + +TEST_CASE("max_body_size chunked accumulate", "[http][max_body_size]") +{ + std::atomic handler_ran{false}; + SimpleApp app; + app.max_body_size(8); + + CROW_ROUTE(app, "/upload") + .methods("POST"_method)([&handler_ran](const request&) { + handler_ran = true; + return "ok"; + }); + + auto server = app.bindaddr(LOCALHOST_ADDRESS).port(0).run_async(); + app.wait_for_server_start(); + + TestClient client(app.port()); + client.send( + "POST /upload HTTP/1.1\r\n" + "Host: localhost\r\n" + "Transfer-Encoding: chunked\r\n" + "\r\n" + "5\r\nhello\r\n" + "5\r\nworld\r\n" + "0\r\n" + "\r\n"); + const auto resp = client.receive(); + CHECK(status_of(resp) == 413); + CHECK_FALSE(handler_ran); + + app.stop(); +} + +TEST_CASE("max_body_size chunked accumulate under limit succeeds", "[http][max_body_size]") +{ + SimpleApp app; + app.max_body_size(10); + + CROW_ROUTE(app, "/upload") + .methods("POST"_method)([](const request& req) { + return req.body; + }); + + auto server = app.bindaddr(LOCALHOST_ADDRESS).port(0).run_async(); + app.wait_for_server_start(); + + TestClient client(app.port()); + client.send( + "POST /upload HTTP/1.1\r\n" + "Host: localhost\r\n" + "Transfer-Encoding: chunked\r\n" + "\r\n" + "5\r\nhello\r\n" + "0\r\n" + "\r\n"); + const auto resp = client.receive(); + CHECK(status_of(resp) == 200); + CHECK(resp.find("hello") != std::string::npos); + + app.stop(); +} + +TEST_CASE("max_body_size per-route override", "[http][max_body_size]") +{ + SimpleApp app; + app.max_body_size(8); + + CROW_ROUTE(app, "/small") + .methods("POST"_method)([](const request& req) { + return req.body; + }); + CROW_ROUTE(app, "/large") + .methods("POST"_method) + .max_body_size(32)([](const request& req) { + return req.body; + }); + + auto server = app.bindaddr(LOCALHOST_ADDRESS).port(0).run_async(); + app.wait_for_server_start(); + const auto port = app.port(); + + const std::string payload(16, 'A'); + { + TestClient client(port); + client.send( + "POST /small HTTP/1.1\r\nHost: localhost\r\nContent-Length: 16\r\n\r\n" + payload); + CHECK(status_of(client.receive()) == 413); + } + { + TestClient client(port); + client.send( + "POST /large HTTP/1.1\r\nHost: localhost\r\nContent-Length: 16\r\n\r\n" + payload); + CHECK(status_of(client.receive()) == 200); + } + + app.stop(); +} + +TEST_CASE("max_body_size zero limit", "[http][max_body_size]") +{ + SimpleApp app; + app.max_body_size(0); + + CROW_ROUTE(app, "/upload") + .methods("POST"_method)([](const request& req) { + return std::to_string(req.body.size()); + }); + + auto server = app.bindaddr(LOCALHOST_ADDRESS).port(0).run_async(); + app.wait_for_server_start(); + const auto port = app.port(); + + SECTION("empty body is accepted") + { + TestClient client(port); + client.send( + "POST /upload HTTP/1.1\r\n" + "Host: localhost\r\n" + "Content-Length: 0\r\n" + "\r\n"); + const auto resp = client.receive(); + CHECK(status_of(resp) == 200); + CHECK(resp.find("0") != std::string::npos); + } + + SECTION("any non-empty body is 413") + { + TestClient client(port); + client.send( + "POST /upload HTTP/1.1\r\n" + "Host: localhost\r\n" + "Content-Length: 1\r\n" + "\r\n" + "x"); + const auto resp = client.receive(); + CHECK(status_of(resp) == 413); + } + + app.stop(); +} + +TEST_CASE("max_body_size default unlimited", "[http][max_body_size]") +{ + SimpleApp app; + CROW_ROUTE(app, "/upload") + .methods("POST"_method)([](const request& req) { + return std::to_string(req.body.size()); + }); + + auto server = app.bindaddr(LOCALHOST_ADDRESS).port(0).run_async(); + app.wait_for_server_start(); + + const std::string payload(64 * 1024, 'B'); + TestClient client(app.port()); + client.send( + "POST /upload HTTP/1.1\r\nHost: localhost\r\nContent-Length: " + + std::to_string(payload.size()) + "\r\n\r\n" + payload); + const auto resp = client.receive(); + CHECK(status_of(resp) == 200); + CHECK(resp.find("65536") != std::string::npos); + + app.stop(); +} + +TEST_CASE("max_body_size 413 runs after-handlers", "[http][max_body_size]") +{ + App app; + app.max_body_size(8); + + CROW_ROUTE(app, "/upload") + .methods("POST"_method)([] { + return "ok"; + }); + + auto server = app.bindaddr(LOCALHOST_ADDRESS).port(0).run_async(); + app.wait_for_server_start(); + + TestClient client(app.port()); + client.send( + "POST /upload HTTP/1.1\r\n" + "Host: localhost\r\n" + "Origin: https://example.test\r\n" + "Content-Length: 100\r\n" + "\r\n" + + std::string(100, 'x')); + const auto resp = client.receive(); + CHECK(status_of(resp) == 413); + CHECK(resp.find("Access-Control-Allow-Origin: *") != std::string::npos); + + app.stop(); +} + +TEST_CASE("max_body_size unlimited does not allocate advertised Content-Length", "[http][max_body_size]") +{ + SimpleApp app; + CROW_ROUTE(app, "/upload") + .methods("POST"_method)([](const request& req) { + return std::to_string(req.body.size()); + }); + + auto server = app.bindaddr(LOCALHOST_ADDRESS).port(0).run_async(); + app.wait_for_server_start(); + const auto port = app.port(); + + // Keep this socket open so handle_header actually sees the huge advertised + // length. Closing immediately can hide a reserve() that only runs after + // the server reads the headers. + TestClient attacker(port); + attacker.send( + "POST /upload HTTP/1.1\r\n" + "Host: localhost\r\n" + "Content-Length: 1125899906842624\r\n" + "\r\n"); + std::this_thread::sleep_for(std::chrono::milliseconds(200)); + + TestClient client(port); + client.send( + "POST /upload HTTP/1.1\r\n" + "Host: localhost\r\n" + "Content-Length: 4\r\n" + "\r\n" + "ping"); + const auto resp = client.receive(); + CHECK(status_of(resp) == 200); + CHECK(resp.find("4") != std::string::npos); + + app.stop(); +} + +TEST_CASE("max_body_size 413 skips before-handlers", "[http][max_body_size]") +{ + struct ProbeMiddleware + { + std::atomic before{false}; + std::atomic after{false}; + + struct context + {}; + + void before_handle(request& /*req*/, response& /*res*/, context& /*ctx*/) + { + before = true; + } + + void after_handle(request& /*req*/, response& /*res*/, context& /*ctx*/) + { + after = true; + } + }; + + App app; + app.max_body_size(8); + + CROW_ROUTE(app, "/upload") + .methods("POST"_method)([] { + return "ok"; + }); + + auto server = app.bindaddr(LOCALHOST_ADDRESS).port(0).run_async(); + app.wait_for_server_start(); + + TestClient client(app.port()); + client.send( + "POST /upload HTTP/1.1\r\n" + "Host: localhost\r\n" + "Content-Length: 100\r\n" + "\r\n" + + std::string(100, 'x')); + const auto resp = client.receive(); + CHECK(status_of(resp) == 413); + + auto& probe = app.get_middleware(); + CHECK_FALSE(probe.before.load()); + CHECK(probe.after.load()); + + probe.before = false; + probe.after = false; + TestClient ok_client(app.port()); + ok_client.send( + "POST /upload HTTP/1.1\r\n" + "Host: localhost\r\n" + "Content-Length: 4\r\n" + "\r\n" + "abcd"); + CHECK(status_of(ok_client.receive()) == 200); + CHECK(probe.before.load()); + CHECK(probe.after.load()); + + app.stop(); +} + +TEST_CASE("max_body_size over-limit upload can still be written and the 413 read back", "[http][max_body_size]") +{ + // Regression test: a client that writes its whole over-limit body before + // reading must not see its write fail (e.g. with ECONNRESET). The server + // must linger and keep draining the socket instead of closing on unread + // bytes, which can RST a peer that is still mid-upload and lose the + // response it already sent. A small payload cannot reproduce this: it + // needs to be big enough that the blocking write below has to wait on + // the server to keep reading, rather than completing into socket buffers + // before the server has even reacted. + SimpleApp app; + app.max_body_size(1024); + + CROW_ROUTE(app, "/upload") + .methods("POST"_method)([](const request& req) { + return req.body; + }); + + auto server = app.bindaddr(LOCALHOST_ADDRESS).port(0).run_async(); + app.wait_for_server_start(); + const auto port = app.port(); + + const std::string payload(5'000'000, 'x'); + TestClient client(port); + const std::string request = + "POST /upload HTTP/1.1\r\n" + "Host: localhost\r\n" + "Content-Length: " + + std::to_string(payload.size()) + + "\r\n" + "\r\n" + + payload; + + REQUIRE_NOTHROW(client.send(request)); + CHECK(status_of(client.receive()) == 413); + + app.stop(); +} + +TEST_CASE("max_body_size small over-limit response does not leak the connection", "[http][max_body_size]") +{ + // Regression test: a 413 for a body small enough to use the sync write + // path (not the streamed/large-body path) must still result in the + // connection being closed promptly once the client goes away, rather + // than being left open waiting for a read that will never complete. + SimpleApp app; + app.max_body_size(8); + + CROW_ROUTE(app, "/upload") + .methods("POST"_method)([](const request& req) { + return req.body; + }); + + auto server = app.bindaddr(LOCALHOST_ADDRESS).port(0).run_async(); + app.wait_for_server_start(); + const auto port = app.port(); + + const int baseline = crow::connectionCount; + { + TestClient client(port); + client.send( + "POST /upload HTTP/1.1\r\n" + "Host: localhost\r\n" + "Content-Length: 18\r\n" + "\r\n" + "012345678901234567"); + CHECK(status_of(client.receive()) == 413); + } // client socket closes here + + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(3); + while (crow::connectionCount > baseline && std::chrono::steady_clock::now() < deadline) + { + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + } + CHECK(crow::connectionCount == baseline); + + app.stop(); +} + +TEST_CASE("max_body_size does not apply to an unmatched HEAD/OPTIONS request", "[http][max_body_size]") +{ + // Documented exemption: an unmatched HEAD or OPTIONS request is answered + // (404/204) before headers finish parsing and before the max_body_size + // check runs, so an over-limit advertised length must not turn into a 413. + SimpleApp app; + app.max_body_size(8); + + auto server = app.bindaddr(LOCALHOST_ADDRESS).port(0).run_async(); + app.wait_for_server_start(); + const auto port = app.port(); + + { + TestClient client(port); + client.send( + "HEAD /missing HTTP/1.1\r\n" + "Host: localhost\r\n" + "Content-Length: 100\r\n" + "\r\n"); + CHECK(status_of(client.receive()) == 404); + } + { + TestClient client(port); + client.send( + "OPTIONS /missing HTTP/1.1\r\n" + "Host: localhost\r\n" + "Content-Length: 100\r\n" + "\r\n"); + CHECK(status_of(client.receive()) == 404); + } + + app.stop(); +} + +TEST_CASE("max_body_size applies to a matched-route HEAD/OPTIONS request", "[http][max_body_size]") +{ + // Unlike the unmatched case above, a HEAD/OPTIONS request that matches a + // route reaches the max_body_size check in handle_header(). A HEAD 413 + // must still carry no body (skip_body set explicitly in the 413 branch, + // not left to accident), while OPTIONS gets a normal 413 body. + SimpleApp app; + app.max_body_size(8); + + CROW_ROUTE(app, "/upload") + .methods("GET"_method)([] { + return "ok"; + }); + + auto server = app.bindaddr(LOCALHOST_ADDRESS).port(0).run_async(); + app.wait_for_server_start(); + const auto port = app.port(); + + { + TestClient client(port); + client.send( + "HEAD /upload HTTP/1.1\r\n" + "Host: localhost\r\n" + "Content-Length: 100\r\n" + "\r\n"); + const auto resp = client.receive(); + CHECK(status_of(resp) == 413); + CHECK(resp.find("Connection: close") != std::string::npos); + CHECK(resp.find("Content-Length: 0") != std::string::npos); + const auto header_end = resp.find("\r\n\r\n"); + REQUIRE(header_end != std::string::npos); + CHECK(resp.size() == header_end + 4); // no body after the headers + } + { + TestClient client(port); + client.send( + "OPTIONS /upload HTTP/1.1\r\n" + "Host: localhost\r\n" + "Content-Length: 100\r\n" + "\r\n"); + const auto resp = client.receive(); + CHECK(status_of(resp) == 413); + CHECK(resp.find("Connection: close") != std::string::npos); + } + + app.stop(); +}