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/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/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..9e8200b6ec39 --- /dev/null +++ b/core/src/server/http/error_pages.cpp @@ -0,0 +1,150 @@ +#include "error_pages.hpp" + +#include + +#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)) { + 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; +} + +} // 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..712244c6bac6 --- /dev/null +++ b/core/src/server/http/error_pages_test.cpp @@ -0,0 +1,144 @@ +#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 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 + ); +} + +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/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/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 { 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/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); 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"