From 2dd45fdf507f8d75cfc032bd547a0f2941104570 Mon Sep 17 00:00:00 2001 From: SSE4 Date: Tue, 18 Aug 2026 18:11:13 +0700 Subject: [PATCH 1/3] feat core: add error_page-style substitute error responses Some requests never reach a handler: the URL matches no handler, the method is not allowed for the matched handler, the request is malformed, or the request is throttled. The server reports those errors on the failsafe path, which runs neither the handler nor the middlewares, so there was no way to customize such a response at all. Add an `error-pages` option to the listener, an analog of the nginx `error_page` directive: for a configured set of error statuses respond with a substitute status, body and headers. Whatever an entry does not set is left as is, so an entry can remap the status only, replace the body only, or both. The option is per listener rather than server-wide, so `listener-monitor` keeps truthful statuses unless it is configured otherwise. Responses produced by handlers are not affected -- middlewares already cover those. --- core/functional_tests/CMakeLists.txt | 3 + .../error_pages/CMakeLists.txt | 6 + core/functional_tests/error_pages/index.html | 1 + core/functional_tests/error_pages/main.cpp | 28 ++++ .../error_pages/static_config.yaml | 41 +++++ .../error_pages/tests/conftest.py | 18 +++ .../error_pages/tests/test_error_pages.py | 59 ++++++++ core/src/server/component.yaml | 46 ++++++ core/src/server/http/error_pages.cpp | 140 +++++++++++++++++ core/src/server/http/error_pages.hpp | 49 ++++++ core/src/server/http/error_pages_test.cpp | 141 ++++++++++++++++++ core/src/server/http/http_request_handler.cpp | 14 +- core/src/server/http/http_request_handler.hpp | 5 +- core/src/server/net/listener_config.cpp | 1 + core/src/server/net/listener_config.hpp | 3 + core/src/server/server.cpp | 10 +- scripts/docs/en/userver/http_server.md | 22 +++ 17 files changed, 581 insertions(+), 6 deletions(-) create mode 100644 core/functional_tests/error_pages/CMakeLists.txt create mode 100644 core/functional_tests/error_pages/index.html create mode 100644 core/functional_tests/error_pages/main.cpp create mode 100644 core/functional_tests/error_pages/static_config.yaml create mode 100644 core/functional_tests/error_pages/tests/conftest.py create mode 100644 core/functional_tests/error_pages/tests/test_error_pages.py create mode 100644 core/src/server/http/error_pages.cpp create mode 100644 core/src/server/http/error_pages.hpp create mode 100644 core/src/server/http/error_pages_test.cpp diff --git a/core/functional_tests/CMakeLists.txt b/core/functional_tests/CMakeLists.txt index 85d2d9353eb7..c1561992afeb 100644 --- a/core/functional_tests/CMakeLists.txt +++ b/core/functional_tests/CMakeLists.txt @@ -14,6 +14,9 @@ add_dependencies(${PROJECT_NAME} ${PROJECT_NAME}-early-monitor-port-open) add_subdirectory(dynamic_configs) add_dependencies(${PROJECT_NAME} ${PROJECT_NAME}-dynamic-configs) +add_subdirectory(error_pages) +add_dependencies(${PROJECT_NAME} ${PROJECT_NAME}-error-pages) + add_subdirectory(graceful_shutdown) add_dependencies(${PROJECT_NAME} ${PROJECT_NAME}-graceful-shutdown) diff --git a/core/functional_tests/error_pages/CMakeLists.txt b/core/functional_tests/error_pages/CMakeLists.txt new file mode 100644 index 000000000000..d0ac61bc5988 --- /dev/null +++ b/core/functional_tests/error_pages/CMakeLists.txt @@ -0,0 +1,6 @@ +project(userver-core-tests-error-pages CXX) + +add_executable(${PROJECT_NAME} "main.cpp") +target_link_libraries(${PROJECT_NAME} userver::core) + +userver_testsuite_add_simple() diff --git a/core/functional_tests/error_pages/index.html b/core/functional_tests/error_pages/index.html new file mode 100644 index 000000000000..c7b934f87c4e --- /dev/null +++ b/core/functional_tests/error_pages/index.html @@ -0,0 +1 @@ +Nothing to see here. diff --git a/core/functional_tests/error_pages/main.cpp b/core/functional_tests/error_pages/main.cpp new file mode 100644 index 000000000000..a1060d6b0eb9 --- /dev/null +++ b/core/functional_tests/error_pages/main.cpp @@ -0,0 +1,28 @@ +#include + +#include +#include +#include +#include + +namespace functional_tests { + +/// The only handler of the service: everything else ends up on the failsafe +/// path, where the error pages are applied. +class HelloHandler final : public server::handlers::HttpHandlerBase { +public: + static constexpr std::string_view kName = "handler-hello"; + + using HttpHandlerBase::HttpHandlerBase; + + std::string HandleRequestThrow(const server::http::HttpRequest&, server::request::RequestContext&) const override { + return "Hello world!\n"; + } +}; + +} // namespace functional_tests + +int main(int argc, char* argv[]) { + const auto component_list = components::MinimalServerComponentList().Append(); + return utils::DaemonMain(argc, argv, component_list); +} diff --git a/core/functional_tests/error_pages/static_config.yaml b/core/functional_tests/error_pages/static_config.yaml new file mode 100644 index 000000000000..80bfbc1920c6 --- /dev/null +++ b/core/functional_tests/error_pages/static_config.yaml @@ -0,0 +1,41 @@ +components_manager: + task_processors: + main-task-processor: + worker_threads: 4 + + fs-task-processor: + worker_threads: 2 + + default_task_processor: main-task-processor + + components: + logging: + fs-task-processor: fs-task-processor + loggers: + default: + file_path: '@stderr' + level: debug + overflow_behavior: discard + + server: + listener: + port: 8080 + task_processor: main-task-processor + # [error pages] + error-pages: + - statuses: [400, 404, 405] + status: 200 + # A relative path is resolved against the working directory + # of the service; the tests make this one absolute. + body-path: index.html + headers: + Content-Type: text/html + X-Powered-By: userver + - statuses: [414] + body: 'the URI is too long' + # [error pages] + + handler-hello: + path: /hello + method: GET + task_processor: main-task-processor diff --git a/core/functional_tests/error_pages/tests/conftest.py b/core/functional_tests/error_pages/tests/conftest.py new file mode 100644 index 000000000000..ef29dff3542a --- /dev/null +++ b/core/functional_tests/error_pages/tests/conftest.py @@ -0,0 +1,18 @@ +import pytest + +pytest_plugins = ['pytest_userver.plugins.core'] + +USERVER_CONFIG_HOOKS = ['error_pages_config_hook'] + + +@pytest.fixture(scope='session') +def error_pages_config_hook(service_source_dir): + """Makes the 'body-path' of the error page absolute.""" + + def _patch_config(config_yaml, config_vars): + listener = config_yaml['components_manager']['components']['server']['listener'] + for page in listener['error-pages']: + if 'body-path' in page: + page['body-path'] = str(service_source_dir / page['body-path']) + + return _patch_config diff --git a/core/functional_tests/error_pages/tests/test_error_pages.py b/core/functional_tests/error_pages/tests/test_error_pages.py new file mode 100644 index 000000000000..57ea8df775ee --- /dev/null +++ b/core/functional_tests/error_pages/tests/test_error_pages.py @@ -0,0 +1,59 @@ +import socket + +import pytest + + +@pytest.fixture(scope='session') +def error_page(service_source_dir) -> str: + return (service_source_dir / 'index.html').read_text() + + +async def test_handler_response_is_not_affected(service_client): + response = await service_client.get('/hello') + assert response.status == 200 + assert response.text == 'Hello world!\n' + assert 'X-Powered-By' not in response.headers + + +async def test_unknown_path(service_client, error_page): + response = await service_client.get('/no/such/path') + assert response.status == 200 + assert response.text == error_page + assert response.headers['Content-Type'] == 'text/html' + assert response.headers['X-Powered-By'] == 'userver' + + +async def test_method_not_allowed(service_client, error_page): + response = await service_client.post('/hello') + assert response.status == 200 + assert response.text == error_page + assert response.headers['X-Powered-By'] == 'userver' + + +async def test_status_is_kept_if_not_configured(service_client): + # The URI is longer than the default 'max_url_size' of 8192 bytes. + response = await service_client.get('/' + 'x' * 9000) + assert response.status == 414 + assert response.text == 'the URI is too long' + + +async def test_head_request_has_no_body(service_client, error_page): + response = await service_client.request('HEAD', '/no/such/path') + assert response.status == 200 + assert response.content == b'' + assert response.headers['Content-Length'] == str(len(error_page)) + + +# A malformed request is rejected by the parser, before the routing takes +# place; both layers must end up on the same error page. +async def test_malformed_request(service_client, service_port, error_page): + with socket.create_connection(('localhost', service_port), timeout=10) as sock: + sock.sendall(b'FOOBAR / HTTP/1.1\r\nHost: localhost\r\n\r\n') + sock.shutdown(socket.SHUT_WR) + response = b'' + while chunk := sock.recv(4096): + response += chunk + + assert response.startswith(b'HTTP/1.1 200 OK\r\n'), response + assert response.endswith(error_page.encode()), response + assert b'\r\nX-Powered-By: userver\r\n' in response diff --git a/core/src/server/component.yaml b/core/src/server/component.yaml index dc85f4307afd..aaec9a9732f8 100644 --- a/core/src/server/component.yaml +++ b/core/src/server/component.yaml @@ -151,6 +151,52 @@ properties: description: whether to write handler statistics default: true + error-pages: + type: array + description: | + substitute responses for the errors that the server reports by itself, i.e. for the requests that + never reach a handler (unknown path, method not allowed, malformed request, throttling); an analog + of the nginx `error_page` directive. Responses produced by handlers are not affected. + default: '[] (the default server error responses are used)' + items: + type: object + description: substitute response for a set of error statuses + additionalProperties: false + required: + - statuses + properties: + statuses: + type: array + description: error statuses to substitute the response for + minItems: 1 + items: + type: integer + description: error status + minimum: 400 + maximum: 599 + status: + type: integer + description: status to respond with instead of the original one + default: the original status is kept + minimum: 100 + maximum: 599 + body: + type: string + description: response body; mutually exclusive with 'body-path' + default: the server-generated body is kept + body-path: + type: string + description: path to a file with the response body, read at service start; mutually exclusive + with 'body' + headers: + type: object + description: headers to set on the response + default: '{}' + properties: {} + additionalProperties: + type: string + description: header value + connection: type: object description: connection options diff --git a/core/src/server/http/error_pages.cpp b/core/src/server/http/error_pages.cpp new file mode 100644 index 000000000000..b4f8120debdd --- /dev/null +++ b/core/src/server/http/error_pages.cpp @@ -0,0 +1,140 @@ +#include "error_pages.hpp" + +#include + +#include + +#include +#include +#include +#include + +USERVER_NAMESPACE_BEGIN + +namespace server::http { + +namespace { + +// Only the statuses that the server reports by itself can be substituted, +// and those are always errors. +constexpr int kMinOriginalStatus = 400; +constexpr int kMaxOriginalStatus = 599; + +constexpr int kMinSubstituteStatus = 100; +constexpr int kMaxSubstituteStatus = 599; + +HttpStatus ParseStatus(const yaml_config::YamlConfig& value, int min_status, int max_status) { + const auto status = value.As(); + if (status < min_status || status > max_status) { + throw std::runtime_error(fmt::format( + "Invalid HTTP status {} in '{}': expected a value in range [{}, {}]", + status, + value.GetPath(), + min_status, + max_status + )); + } + return static_cast(status); +} + +std::optional ParseBody(const yaml_config::YamlConfig& page) { + auto body = page["body"].As>(); + const auto body_path = page["body-path"].As>(); + + if (body && body_path) { + throw std::runtime_error( + fmt::format("Both 'body' and 'body-path' are set in '{}', remove one of them", page.GetPath()) + ); + } + if (!body_path) { + return body; + } + + try { + return fs::blocking::ReadFileContents(*body_path); + } catch (const std::exception& ex) { + throw std::runtime_error(fmt::format( + "Failed to read the error page body from the file '{}' set in '{}': {}", + *body_path, + page["body-path"].GetPath(), + ex.what() + )); + } +} + +std::vector> ParseHeaders(const yaml_config::YamlConfig& headers) { + std::vector> result; + if (headers.IsMissing() || headers.IsNull()) { + return result; + } + + for (const auto& [name, value] : yaml_config::Items(headers)) { + result.emplace_back(name, value.As()); + } + return result; +} + +} // namespace + +ErrorPages::ErrorPages(std::unordered_map pages) + : pages_(std::move(pages)) +{} + +const ErrorPage* ErrorPages::Find(HttpStatus status) const noexcept { return utils::FindOrNullptr(pages_, status); } + +void ApplyErrorPage(const ErrorPage& page, HttpResponse& response) { + if (page.status) { + response.SetStatus(*page.status); + } + if (page.body) { + response.SetData(*page.body); + } + for (const auto& [name, value] : page.headers) { + response.SetHeader(name, value); + } +} + +ErrorPages Parse(const yaml_config::YamlConfig& value, formats::parse::To) { + std::unordered_map pages; + + for (const auto& item : value) { + ErrorPage page; + if (const auto& status = item["status"]; !status.IsMissing()) { + page.status = ParseStatus(status, kMinSubstituteStatus, kMaxSubstituteStatus); + } + page.body = ParseBody(item); + page.headers = ParseHeaders(item["headers"]); + + if (!page.status && !page.body && page.headers.empty()) { + throw std::runtime_error(fmt::format( + "Error page '{}' would change nothing, set at least one of " + "'status', 'body', 'body-path', 'headers'", + item.GetPath() + )); + } + + const auto& statuses = item["statuses"]; + if (statuses.IsMissing() || statuses.GetSize() == 0) { + throw std::runtime_error(fmt::format( + "No 'statuses' to substitute the response for are set in the error page '{}'", item.GetPath() + )); + } + + for (const auto& status : statuses) { + const auto original_status = ParseStatus(status, kMinOriginalStatus, kMaxOriginalStatus); + if (!pages.emplace(original_status, page).second) { + throw std::runtime_error(fmt::format( + "HTTP status {} is set in more than one error page, the last one is '{}'", + static_cast(original_status), + item.GetPath() + )); + } + } + } + + return ErrorPages{std::move(pages)}; +} + +} // namespace server::http + +USERVER_NAMESPACE_END diff --git a/core/src/server/http/error_pages.hpp b/core/src/server/http/error_pages.hpp new file mode 100644 index 000000000000..b55aa3bb4ce4 --- /dev/null +++ b/core/src/server/http/error_pages.hpp @@ -0,0 +1,49 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include +#include + +USERVER_NAMESPACE_BEGIN + +namespace server::http { + +class HttpResponse; + +/// A substitute response for the errors that the server reports by itself, +/// an analog of the nginx `error_page` directive. +struct ErrorPage final { + /// Status to respond with instead of the original one. + std::optional status; + /// Body to respond with instead of the server-generated one. + std::optional body; + /// Headers to set on the response, in the configuration order. + std::vector> headers; +}; + +/// Error pages of a single listener, indexed by the original error status. +class ErrorPages final { +public: + ErrorPages() = default; + explicit ErrorPages(std::unordered_map pages); + + /// @returns the page configured for `status`, or `nullptr` if there is none. + const ErrorPage* Find(HttpStatus status) const noexcept; + +private: + std::unordered_map pages_; +}; + +/// Overrides the status, the body and the headers of `response` as `page` says. +void ApplyErrorPage(const ErrorPage& page, HttpResponse& response); + +ErrorPages Parse(const yaml_config::YamlConfig& value, formats::parse::To); + +} // namespace server::http + +USERVER_NAMESPACE_END diff --git a/core/src/server/http/error_pages_test.cpp b/core/src/server/http/error_pages_test.cpp new file mode 100644 index 000000000000..232d8d46c8f0 --- /dev/null +++ b/core/src/server/http/error_pages_test.cpp @@ -0,0 +1,141 @@ +#include + +#include +#include + +#include + +#include +#include +#include +#include +#include +#include + +USERVER_NAMESPACE_BEGIN + +namespace { + +using server::http::HttpStatus; + +server::http::ErrorPages ParseErrorPages(std::string_view yaml) { + return yaml_config::YamlConfig{formats::yaml::FromString(std::string{yaml}), {}}.As(); +} + +constexpr std::string_view kTwoPages = R"( +- statuses: [404, 405] + status: 200 + body: "index" + headers: + Content-Type: text/html + X-Powered-By: WEETS-WA +- statuses: [500] + body: "oops" +)"; + +} // namespace + +TEST(ErrorPages, Empty) { + const auto pages = ParseErrorPages("[]"); + EXPECT_EQ(pages.Find(HttpStatus::kNotFound), nullptr); +} + +TEST(ErrorPages, FindsOnlyConfiguredStatuses) { + const auto pages = ParseErrorPages(kTwoPages); + + ASSERT_NE(pages.Find(HttpStatus::kNotFound), nullptr); + ASSERT_NE(pages.Find(HttpStatus::kMethodNotAllowed), nullptr); + ASSERT_NE(pages.Find(HttpStatus::kInternalServerError), nullptr); + EXPECT_EQ(pages.Find(HttpStatus::kBadRequest), nullptr); + EXPECT_EQ(pages.Find(HttpStatus::kOk), nullptr); +} + +TEST(ErrorPages, ParsesFields) { + const auto pages = ParseErrorPages(kTwoPages); + + const auto* page = pages.Find(HttpStatus::kMethodNotAllowed); + ASSERT_NE(page, nullptr); + ASSERT_TRUE(page->status); + EXPECT_EQ(*page->status, HttpStatus::kOk); + ASSERT_TRUE(page->body); + EXPECT_EQ(*page->body, "index"); + // The headers keep the configuration order. + EXPECT_THAT( + page->headers, + testing::ElementsAre(testing::Pair("Content-Type", "text/html"), testing::Pair("X-Powered-By", "WEETS-WA")) + ); + + const auto* server_error_page = pages.Find(HttpStatus::kInternalServerError); + ASSERT_NE(server_error_page, nullptr); + EXPECT_FALSE(server_error_page->status); + ASSERT_TRUE(server_error_page->body); + EXPECT_EQ(*server_error_page->body, "oops"); + EXPECT_TRUE(server_error_page->headers.empty()); +} + +TEST(ErrorPages, BodyFromFile) { + auto file = fs::blocking::TempFile::Create(); + constexpr std::string_view kContents = "from file"; + fs::blocking::RewriteFileContents(file.GetPath(), kContents); + + const auto pages = ParseErrorPages(fmt::format("- statuses: [404]\n body-path: {}\n", file.GetPath())); + + const auto* page = pages.Find(HttpStatus::kNotFound); + ASSERT_NE(page, nullptr); + ASSERT_TRUE(page->body); + EXPECT_EQ(*page->body, kContents); +} + +TEST(ErrorPages, RejectsInvalidConfigs) { + // A page that would change nothing is a configuration mistake. + EXPECT_THROW(ParseErrorPages("- statuses: [404]"), std::runtime_error); + EXPECT_THROW(ParseErrorPages("- statuses: []\n status: 200"), std::runtime_error); + EXPECT_THROW(ParseErrorPages("- status: 200"), std::runtime_error); + // 'body' and 'body-path' are mutually exclusive. + EXPECT_THROW(ParseErrorPages("- statuses: [404]\n body: a\n body-path: /dev/null"), std::runtime_error); + EXPECT_THROW(ParseErrorPages("- statuses: [404]\n body-path: /no/such/file"), std::runtime_error); + // Only errors are reported by the server itself, so only they can be substituted. + EXPECT_THROW(ParseErrorPages("- statuses: [200]\n status: 204"), std::runtime_error); + EXPECT_THROW(ParseErrorPages("- statuses: [600]\n status: 204"), std::runtime_error); + EXPECT_THROW(ParseErrorPages("- statuses: [404]\n status: 42"), std::runtime_error); + // A status must not be claimed by two pages. + EXPECT_THROW( + ParseErrorPages("- statuses: [404, 405]\n status: 200\n- statuses: [405]\n status: 201"), std::runtime_error + ); +} + +UTEST(ErrorPages, ApplyOverridesStatusBodyAndHeaders) { + server::request::ResponseDataAccounter accounter; + const auto request = server::http::HttpRequestBuilder{accounter}.Build(); + auto& response = request->GetHttpResponse(); + response.SetStatus(HttpStatus::kMethodNotAllowed); + response.SetData("method not allowed"); + + const auto pages = ParseErrorPages(kTwoPages); + const auto* page = pages.Find(HttpStatus::kMethodNotAllowed); + ASSERT_NE(page, nullptr); + server::http::ApplyErrorPage(*page, response); + + EXPECT_EQ(response.GetStatus(), HttpStatus::kOk); + EXPECT_EQ(response.GetData(), "index"); + EXPECT_EQ(response.GetHeader("Content-Type"), "text/html"); + EXPECT_EQ(response.GetHeader("X-Powered-By"), "WEETS-WA"); +} + +UTEST(ErrorPages, ApplyKeepsWhatIsNotConfigured) { + server::request::ResponseDataAccounter accounter; + const auto request = server::http::HttpRequestBuilder{accounter}.Build(); + auto& response = request->GetHttpResponse(); + response.SetStatus(HttpStatus::kInternalServerError); + response.SetData("internal server error"); + + const auto pages = ParseErrorPages(kTwoPages); + const auto* page = pages.Find(HttpStatus::kInternalServerError); + ASSERT_NE(page, nullptr); + server::http::ApplyErrorPage(*page, response); + + EXPECT_EQ(response.GetStatus(), HttpStatus::kInternalServerError); + EXPECT_EQ(response.GetData(), "oops"); +} + +USERVER_NAMESPACE_END diff --git a/core/src/server/http/http_request_handler.cpp b/core/src/server/http/http_request_handler.cpp index 046a9bee752b..adffad7f9a9d 100644 --- a/core/src/server/http/http_request_handler.cpp +++ b/core/src/server/http/http_request_handler.cpp @@ -30,10 +30,12 @@ HttpRequestHandler::HttpRequestHandler( const std::optional& logger_access_component, const std::optional& logger_access_tskv_component, bool is_monitor, - std::string server_name + std::string server_name, + ErrorPages error_pages ) : is_monitor_(is_monitor), server_name_(std::move(server_name)), + error_pages_(std::move(error_pages)), rate_limit_(utils::TokenBucket::MakeUnbounded()), metrics_(component_context.FindComponent().GetMetricsStorage()), config_source_(component_context.FindComponent().GetSource()) @@ -57,13 +59,19 @@ engine::TaskWithResult HttpRequestHandler::StartFailsafeTask(std::shared_p ) const { const auto* handler = http_request->GetHttpHandler(); - return engine::AsyncNoTracing([request = std::move(http_request), handler]() { + // Neither the handler nor the middlewares run on this path, so the error + // pages are the only way to customize the response the server reports here. + return engine::AsyncNoTracing([request = std::move(http_request), handler, error_pages = &error_pages_]() { request->SetTaskStartTime(); + auto& response = request->GetHttpResponse(); if (handler) { handler->ReportMalformedRequest(*request); } + if (const auto* error_page = error_pages->Find(response.GetStatus())) { + ApplyErrorPage(*error_page, response); + } request->SetResponseNotifyTime(); - request->GetHttpResponse().SetReady(); + response.SetReady(); }); } diff --git a/core/src/server/http/http_request_handler.hpp b/core/src/server/http/http_request_handler.hpp index eb6a7a9b092b..98d5e8933cd3 100644 --- a/core/src/server/http/http_request_handler.hpp +++ b/core/src/server/http/http_request_handler.hpp @@ -12,6 +12,7 @@ #include #include +#include "error_pages.hpp" #include "handler_info_index.hpp" USERVER_NAMESPACE_BEGIN @@ -25,7 +26,8 @@ class HttpRequestHandler final : public RequestHandlerBase { const std::optional& logger_access_component, const std::optional& logger_access_tskv_component, bool is_monitor, - std::string server_name + std::string server_name, + ErrorPages error_pages ); using NewRequestHook = std::function)>; @@ -55,6 +57,7 @@ class HttpRequestHandler final : public RequestHandlerBase { const bool is_monitor_; const std::string server_name_; + const ErrorPages error_pages_; NewRequestHook new_request_hook_; mutable utils::TokenBucket rate_limit_; std::atomic cc_status_code_{HttpStatus::kTooManyRequests}; diff --git a/core/src/server/net/listener_config.cpp b/core/src/server/net/listener_config.cpp index fe3771fc4751..825773fc78a9 100644 --- a/core/src/server/net/listener_config.cpp +++ b/core/src/server/net/listener_config.cpp @@ -108,6 +108,7 @@ ListenerConfig Parse(const yaml_config::YamlConfig& value, formats::parse::To
  • (); config.handler_defaults = value["handler-defaults"].As(); + config.error_pages = value["error-pages"].As({}); config.max_connections = value["max_connections"].As(config.max_connections); config.shards = value["shards"].As>(config.shards); config.task_processor = value["task_processor"].As>(); diff --git a/core/src/server/net/listener_config.hpp b/core/src/server/net/listener_config.hpp index d95716ece24e..5a89197be71c 100644 --- a/core/src/server/net/listener_config.hpp +++ b/core/src/server/net/listener_config.hpp @@ -12,6 +12,8 @@ #include #include +#include + #include "connection_config.hpp" USERVER_NAMESPACE_BEGIN @@ -41,6 +43,7 @@ struct PortConfig { struct ListenerConfig { ConnectionConfig connection_config; request::HttpRequestConfig handler_defaults; + http::ErrorPages error_pages; int backlog = 1024; // truncated to net.core.somaxconn size_t max_connections = 32768; std::optional shards; diff --git a/core/src/server/server.cpp b/core/src/server/server.cpp index 6ee2a5c9e2eb..402de35ff768 100644 --- a/core/src/server/server.cpp +++ b/core/src/server/server.cpp @@ -66,8 +66,14 @@ void PortInfo::Init( ? component_context.GetTaskProcessor(*listener_config.task_processor) : engine::current_task::GetTaskProcessor(); - request_handler - .emplace(component_context, config.logger_access, config.logger_access_tskv, is_monitor, config.server_name); + request_handler.emplace( + component_context, + config.logger_access, + config.logger_access_tskv, + is_monitor, + config.server_name, + listener_config.error_pages + ); endpoint_info = std::make_shared(listener_config, *request_handler); diff --git a/scripts/docs/en/userver/http_server.md b/scripts/docs/en/userver/http_server.md index 5a4c3fceeb9a..93cb26c5b63f 100644 --- a/scripts/docs/en/userver/http_server.md +++ b/scripts/docs/en/userver/http_server.md @@ -27,6 +27,7 @@ * @ref scripts/docs/en/userver/tutorial/multipart_service.md "File uploads and multipart/form-data" * @ref scripts/docs/en/userver/deadline_propagation.md . * @ref scripts/docs/en/userver/http_server_middlewares.md "Middlewares" +* Error pages - substitute responses for the errors reported by the server itself. ## Streaming API @@ -83,6 +84,27 @@ components_manager: You can set some options specific to `HTTP/2.0` in the `http2-session` section. See docs for these options in components::Server +## Error pages + +Some requests never reach a handler: the URL matches no handler, the method is +not allowed for the matched handler, the request is malformed, or the request is +throttled. The server reports such errors by itself, running neither the handler +nor the @ref scripts/docs/en/userver/http_server_middlewares.md "middlewares", +so the only way to customize those responses is the `error-pages` option of the +listener - an analog of the nginx `error_page` directive: + +@snippet core/functional_tests/error_pages/static_config.yaml error pages + +Each entry substitutes the response for every status it lists in `statuses`: +`status` replaces the status code, `body` or `body-path` replaces the body, and +`headers` are added to the response. Whatever is not set is left as is, so the +first entry above turns "404 Not Found" into "200 OK" with an HTML page, while +the second one only replaces the body of "414 URI Too Long". + +Responses produced by handlers are not affected - use +@ref scripts/docs/en/userver/http_server_middlewares.md "middlewares" for those. + + ## Components * @ref components::Server "Server" From 2aad35f536ed84a8bba15b907c0ed3e981130fea Mon Sep 17 00:00:00 2001 From: SSE4 Date: Tue, 18 Aug 2026 18:11:48 +0700 Subject: [PATCH 2/3] fix core: answer 400 to an unknown HTTP/2 method instead of dropping the connection `HttpMethodFromString` throws on a method it does not know, and the HTTP/2 `:method` pseudo-header was parsed with that exception escaping the nghttp2 header callback. It propagated all the way up to `TryParseRequests`, which tore down the whole connection, so a single TRACE or a bogus method token killed every other stream on it and the client got no response at all. Catch it and leave the method unset instead: that makes the request malformed, so the stream is answered with a "400 Bad Request", the same as an unknown method token over HTTP/1.1. RFC 9113, 8.1.1 asks for a stream error here, not a connection error. --- .../http2server/tests/test_low_level.py | 22 +++++++++++++++++++ core/src/server/http/http2_session.cpp | 10 ++++++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/core/functional_tests/http2server/tests/test_low_level.py b/core/functional_tests/http2server/tests/test_low_level.py index 62f84aec765b..07316851f34b 100644 --- a/core/functional_tests/http2server/tests/test_low_level.py +++ b/core/functional_tests/http2server/tests/test_low_level.py @@ -422,6 +422,28 @@ async def test_request_without_path_resets_stream(create_connection, service_cli assert stream_id == 3 +async def test_unknown_method_is_a_bad_request(create_connection, service_client): + await service_client.update_server_state() + async with create_connection() as (sock, conn): + # An unsupported ':method' makes the request malformed; it must not + # affect the rest of the connection (RFC 9113, 8.1.1). + headers = [(':method', 'TRACE')] + PSEUDO_HEADERS[1:] + stream_id = conn.get_next_available_stream_id() + conn.send_headers(stream_id, headers, end_stream=True) + await sock.sendall(conn.data_to_send()) + + events = [] + while not any(isinstance(event, h2.events.ResponseReceived) for event in events): + events += await utils.send_and_receive(sock, conn) + response = next(event for event in events if isinstance(event, h2.events.ResponseReceived)) + assert dict(response.headers)[b':status'] == b'400' + + stream_id = conn.get_next_available_stream_id() + conn.send_headers(stream_id, DEFAULT_HEADERS, end_stream=True) + await sock.sendall(conn.data_to_send()) + await utils.receive_simple_response(sock, conn) + + async def test_single_reset_keeps_connection_usable( create_connection, monitor_client, diff --git a/core/src/server/http/http2_session.cpp b/core/src/server/http/http2_session.cpp index 1ed167b28280..b3fd41da3360 100644 --- a/core/src/server/http/http2_session.cpp +++ b/core/src/server/http/http2_session.cpp @@ -171,7 +171,15 @@ int Http2Session::OnHeader( auto& stream = parser.GetStreamChecked(Stream::Id{frame->hd.stream_id}); auto& ctor = stream.RequestConstructor(); if (hname == USERVER_NAMESPACE::http::headers::k2::kMethod) { - ctor.SetMethod(HttpMethodFromString(hvalue)); + try { + ctor.SetMethod(HttpMethodFromString(hvalue)); + } catch (const std::exception& e) { + // Leaving the method unset makes the request malformed, so the + // stream gets a "400 Bad Request" instead of the whole connection + // being torn down (RFC 9113, 8.1.1). + LOG_LIMITED_WARNING() << "can't parse the method: " << e; + IncStat(parser.stats_.http2_stats.streams_parse_error); + } stream.CheckUrlComplete(); } else if (hname == USERVER_NAMESPACE::http::headers::k2::kPath) { try { From b583be4e04123fb5c18308dbf8003bfe4c12f316 Mon Sep 17 00:00:00 2001 From: SSE4 Date: Tue, 18 Aug 2026 18:12:37 +0700 Subject: [PATCH 3/3] feat core: validate error page headers at service start `HttpResponse::SetHeader` rejects header names and values with characters that RFC 9110 does not allow, and the error page headers come straight from the static config. A typo there used to surface only when a request actually hit that page, throwing out of the failsafe task on every such request instead of once at start. Move the two checks out of the anonymous namespace of http_response.cpp into header_validation.hpp so the config parsing can reuse them, and run them on every configured error page header. --- core/src/server/http/error_pages.cpp | 12 +++- core/src/server/http/error_pages_test.cpp | 3 + core/src/server/http/header_validation.cpp | 75 ++++++++++++++++++++++ core/src/server/http/header_validation.hpp | 19 ++++++ core/src/server/http/http_response.cpp | 62 +----------------- 5 files changed, 109 insertions(+), 62 deletions(-) create mode 100644 core/src/server/http/header_validation.cpp create mode 100644 core/src/server/http/header_validation.hpp diff --git a/core/src/server/http/error_pages.cpp b/core/src/server/http/error_pages.cpp index b4f8120debdd..9e8200b6ec39 100644 --- a/core/src/server/http/error_pages.cpp +++ b/core/src/server/http/error_pages.cpp @@ -9,6 +9,8 @@ #include #include +#include + USERVER_NAMESPACE_BEGIN namespace server::http { @@ -69,7 +71,15 @@ std::vector> ParseHeaders(const yaml_config: } for (const auto& [name, value] : yaml_config::Items(headers)) { - result.emplace_back(name, value.As()); + auto header_value = value.As(); + // Better to fail the start than to throw on every substituted response. + try { + CheckHeaderName(name); + CheckHeaderValue(header_value); + } catch (const std::exception& ex) { + throw std::runtime_error(fmt::format("Invalid header in '{}': {}", headers.GetPath(), ex.what())); + } + result.emplace_back(name, std::move(header_value)); } return result; } diff --git a/core/src/server/http/error_pages_test.cpp b/core/src/server/http/error_pages_test.cpp index 232d8d46c8f0..712244c6bac6 100644 --- a/core/src/server/http/error_pages_test.cpp +++ b/core/src/server/http/error_pages_test.cpp @@ -98,6 +98,9 @@ TEST(ErrorPages, RejectsInvalidConfigs) { EXPECT_THROW(ParseErrorPages("- statuses: [200]\n status: 204"), std::runtime_error); EXPECT_THROW(ParseErrorPages("- statuses: [600]\n status: 204"), std::runtime_error); EXPECT_THROW(ParseErrorPages("- statuses: [404]\n status: 42"), std::runtime_error); + // A header that HttpResponse would reject must be rejected at start. + EXPECT_THROW(ParseErrorPages("- statuses: [404]\n headers:\n 'Bad Name': v"), std::runtime_error); + EXPECT_THROW(ParseErrorPages("- statuses: [404]\n headers:\n Name: \"bad\\rvalue\""), std::runtime_error); // A status must not be claimed by two pages. EXPECT_THROW( ParseErrorPages("- statuses: [404, 405]\n status: 200\n- statuses: [405]\n status: 201"), std::runtime_error diff --git a/core/src/server/http/header_validation.cpp b/core/src/server/http/header_validation.cpp new file mode 100644 index 000000000000..7bc41a1653f3 --- /dev/null +++ b/core/src/server/http/header_validation.cpp @@ -0,0 +1,75 @@ +#include "header_validation.hpp" + +#include +#include +#include +#include + +#include + +USERVER_NAMESPACE_BEGIN + +namespace server::http { + +void CheckHeaderName(std::string_view name) { + static constexpr auto init = []() { + std::array res{}; // zero initialize + for (int i = 0; i < 32; i++) { + res[i] = 1; + } + for (int i = 127; i < 256; i++) { + res[i] = 1; + } + for (const unsigned char c : "()<>@,;:\\\"/[]?={} \t") { + res[c] = 1; + } + return res; + }; + static constexpr auto bad_chars = init(); + + bool check_failed = false; + + // this gets autovectorized, and we optimize for happy path here + for (const char c : name) { + const auto code = static_cast(c); + check_failed |= bad_chars[code]; + } + + if (check_failed) { + // in a presumably rare scenarios of the check failing we do a second loop + for (const char c : name) { + const auto code = static_cast(c); + if (bad_chars[code]) { + throw std::runtime_error( + fmt::format("invalid character in header name: '{}' (#{}), full header name: {}", c, code, name) + ); + } + } + } +} + +void CheckHeaderValue(std::string_view value) { + bool check_failed = false; + + // this gets autovectorized, and we optimize for happy path here + for (const char c : value) { + auto code = static_cast(c); + check_failed |= code < 32 || code == 127; + } + + if (check_failed) { + // in a presumably rare scenarios of the check failing we do a second loop + for (const char c : value) { + auto code = static_cast(c); + if (code < 32 || code == 127) { + throw std::runtime_error( + std::string("invalid character in header value: '") + c + "' (#" + std::to_string(code) + ")" + ); + } + } + } +} + +} // namespace server::http + +USERVER_NAMESPACE_END diff --git a/core/src/server/http/header_validation.hpp b/core/src/server/http/header_validation.hpp new file mode 100644 index 000000000000..e3d40eb071a0 --- /dev/null +++ b/core/src/server/http/header_validation.hpp @@ -0,0 +1,19 @@ +#pragma once + +#include + +USERVER_NAMESPACE_BEGIN + +namespace server::http { + +/// @throws std::runtime_error if `name` has a character that RFC 9110 does not +/// allow in a header name. +void CheckHeaderName(std::string_view name); + +/// @throws std::runtime_error if `value` has a character that RFC 9110 does not +/// allow in a header value. +void CheckHeaderValue(std::string_view value); + +} // namespace server::http + +USERVER_NAMESPACE_END diff --git a/core/src/server/http/http_response.cpp b/core/src/server/http/http_response.cpp index 817366707767..b3905369637b 100644 --- a/core/src/server/http/http_response.cpp +++ b/core/src/server/http/http_response.cpp @@ -1,7 +1,5 @@ #include -#include - #include #include @@ -18,6 +16,7 @@ #include #include +#include #include #include @@ -34,65 +33,6 @@ constexpr std::string_view kKeepAlive = "keep-alive"; const std::string kHostname = hostinfo::blocking::GetRealHostName(); -void CheckHeaderName(std::string_view name) { - static constexpr auto init = []() { - std::array res{}; // zero initialize - for (int i = 0; i < 32; i++) { - res[i] = 1; - } - for (int i = 127; i < 256; i++) { - res[i] = 1; - } - for (const unsigned char c : "()<>@,;:\\\"/[]?={} \t") { - res[c] = 1; - } - return res; - }; - static constexpr auto bad_chars = init(); - - bool check_failed = false; - - // this gets autovectorized, and we optimize for happy path here - for (const char c : name) { - const auto code = static_cast(c); - check_failed |= bad_chars[code]; - } - - if (check_failed) { - // in a presumably rare scenarios of the check failing we do a second loop - for (const char c : name) { - const auto code = static_cast(c); - if (bad_chars[code]) { - throw std::runtime_error( - fmt::format("invalid character in header name: '{}' (#{}), full header name: {}", c, code, name) - ); - } - } - } -} - -void CheckHeaderValue(std::string_view value) { - bool check_failed = false; - - // this gets autovectorized, and we optimize for happy path here - for (const char c : value) { - auto code = static_cast(c); - check_failed |= code < 32 || code == 127; - } - - if (check_failed) { - // in a presumably rare scenarios of the check failing we do a second loop - for (const char c : value) { - auto code = static_cast(c); - if (code < 32 || code == 127) { - throw std::runtime_error( - std::string("invalid character in header value: '") + c + "' (#" + std::to_string(code) + ")" - ); - } - } - } -} - bool IsBodyForbiddenForStatus(server::http::HttpStatus status) { return status == server::http::HttpStatus::kNoContent || status == server::http::HttpStatus::kNotModified || (static_cast(status) >= 100 && static_cast(status) < 200);