Skip to content

Commit

Permalink
rpcdaemon: improve JWT handling in HTTP connection (#1982)
Browse files Browse the repository at this point in the history
  • Loading branch information
canepat authored Apr 25, 2024
1 parent 1f7b484 commit 4c21489
Show file tree
Hide file tree
Showing 4 changed files with 174 additions and 39 deletions.
2 changes: 0 additions & 2 deletions silkworm/rpc/common/constants.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,4 @@ inline constexpr const char* kDefaultEth1ApiSpec{"admin,debug,eth,net,parity,eri
inline constexpr const char* kDefaultEth2ApiSpec{"engine,eth"};
inline constexpr const std::chrono::milliseconds kDefaultTimeout{10000};

inline constexpr const std::size_t kHttpIncomingBufferSize{8192};

} // namespace silkworm
53 changes: 33 additions & 20 deletions silkworm/rpc/http/connection.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
#include "connection.hpp"

#include <array>
#include <chrono>
#include <exception>
#include <string_view>

Expand All @@ -38,10 +39,13 @@

namespace silkworm::rpc::http {

using namespace std::chrono_literals;

static constexpr std::string_view kMaxAge{"600"};
static constexpr auto kMaxPayloadSize{30 * kMebi}; // 30MiB
static constexpr std::array kAcceptedContentTypes{"application/json", "application/jsonrequest", "application/json-rpc"};
static constexpr auto kGzipEncoding{"gzip"};
static constexpr auto kBearerTokenPrefix{"Bearer "sv}; // space matters: format is `Bearer <token>`

Task<void> Connection::run_read_loop(std::shared_ptr<Connection> connection) {
co_await connection->read_loop();
Expand Down Expand Up @@ -125,7 +129,7 @@ Task<bool> Connection::do_read() {
co_return true;
}

Task<void> Connection::do_upgrade(const boost::beast::http::request<boost::beast::http::string_body>& req) {
Task<void> Connection::do_upgrade(const RequestWithStringBody& req) {
// Now that talking to the socket is successful, we tie the socket object to a WebSocket stream
boost::beast::websocket::stream<boost::beast::tcp_stream> stream(std::move(socket_));

Expand All @@ -137,7 +141,7 @@ Task<void> Connection::do_upgrade(const boost::beast::http::request<boost::beast
boost::asio::co_spawn(socket_.get_executor(), connection_loop(ws_connection), boost::asio::detached);
}

Task<void> Connection::handle_request(const boost::beast::http::request<boost::beast::http::string_body>& req) {
Task<void> Connection::handle_request(const RequestWithStringBody& req) {
if (req.method() == boost::beast::http::verb::options &&
!req[boost::beast::http::field::access_control_request_method].empty()) {
co_await handle_preflight(req);
Expand All @@ -146,7 +150,7 @@ Task<void> Connection::handle_request(const boost::beast::http::request<boost::b
}
}

Task<void> Connection::handle_preflight(const boost::beast::http::request<boost::beast::http::string_body>& req) {
Task<void> Connection::handle_preflight(const RequestWithStringBody& req) {
boost::beast::http::response<boost::beast::http::string_body> res{boost::beast::http::status::no_content, request_http_version_};
std::string vary = req[boost::beast::http::field::vary];

Expand Down Expand Up @@ -174,7 +178,7 @@ Task<void> Connection::handle_preflight(const boost::beast::http::request<boost:
co_await boost::beast::http::async_write(socket_, res, boost::asio::use_awaitable);
}

Task<void> Connection::handle_actual_request(const boost::beast::http::request<boost::beast::http::string_body>& req) {
Task<void> Connection::handle_actual_request(const RequestWithStringBody& req) {
if (req.body().empty()) {
co_await do_write(std::string{}, boost::beast::http::status::ok); // just like Erigon
co_return;
Expand Down Expand Up @@ -324,39 +328,48 @@ Task<void> Connection::do_write(const std::string& content, boost::beast::http::
co_return;
}

Connection::AuthorizationResult Connection::is_request_authorized(const boost::beast::http::request<boost::beast::http::string_body>& req) {
Connection::AuthorizationResult Connection::is_request_authorized(const RequestWithStringBody& req) {
if (!jwt_secret_.has_value() || (*jwt_secret_).empty()) {
return {};
}

auto it = req.find("Authorization");
if (it == req.end()) {
SILK_ERROR << "JWT request without Authorization Header: " << req.body();
// Bearer authentication system: HTTP Authorization header with expected value `Bearer <token>`
const auto authorization_it = req.find("Authorization");
if (authorization_it == req.end()) {
SILK_ERROR << "HTTP request without Authorization header received from " << socket_.remote_endpoint();
return tl::make_unexpected("missing token");
}

std::string client_token;
if (it->value().substr(0, 7) == "Bearer ") {
client_token = it->value().substr(7);
const auto authorization_value{authorization_it->value()};
if (authorization_value.starts_with(kBearerTokenPrefix)) {
client_token = authorization_value.substr(kBearerTokenPrefix.size());
} else {
SILK_ERROR << "JWT client request without token";
SILK_ERROR << "HTTP request without Bearer token in Authorization header received from " << socket_.remote_endpoint();
return tl::make_unexpected("missing token");
}
try {
// Parse token
auto decoded_client_token = jwt::decode(client_token);
// Parse JWT token payload
const auto decoded_client_token = jwt::decode(client_token);
if (decoded_client_token.has_issued_at() == 0) {
SILK_ERROR << "JWT iat (Issued At) not defined";
return tl::make_unexpected("missing issued-at");
SILK_ERROR << "JWT iat (issued-at) claim not present in token received from " << socket_.remote_endpoint();
return tl::make_unexpected("missing issued-at claim");
}
// Validate token
auto verifier = jwt::verify().allow_algorithm(jwt::algorithm::hs256{*jwt_secret_});

// Ensure JWT iat timestamp is within +-60 seconds from the current time
// https://github.com/ethereum/execution-apis/blob/main/src/engine/authentication.md#jwt-claims
const auto issued_at_timestamp{decoded_client_token.get_issued_at()};
const auto current_timestamp{std::chrono::system_clock::now()};
if (std::chrono::abs(std::chrono::duration_cast<std::chrono::seconds>(current_timestamp - issued_at_timestamp)) > 60s) {
SILK_ERROR << "JWT iat (issued-at) claim not present in token received from " << socket_.remote_endpoint();
return tl::make_unexpected("invalid issued-at claim");
}
// Validate received JWT token
const auto verifier = jwt::verify().allow_algorithm(jwt::algorithm::hs256{*jwt_secret_});
SILK_TRACE << "JWT client token: " << client_token << " secret: " << *jwt_secret_;
verifier.verify(decoded_client_token);
} catch (const boost::system::system_error& se) {
} catch (const std::system_error& se) {
SILK_ERROR << "JWT invalid token: " << se.what();
return tl::make_unexpected("invalid token");
return tl::make_unexpected(se.what());
} catch (const std::exception& se) {
SILK_ERROR << "JWT invalid token: " << se.what();
return tl::make_unexpected("invalid token");
Expand Down
14 changes: 8 additions & 6 deletions silkworm/rpc/http/connection.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@

namespace silkworm::rpc::http {

using RequestWithStringBody = boost::beast::http::request<boost::beast::http::string_body>;

//! Represents a single connection from a client.
class Connection : public StreamWriter {
public:
Expand All @@ -65,23 +67,23 @@ class Connection : public StreamWriter {
Task<void> close_stream() override;
Task<std::size_t> write(std::string_view content, bool last) override;

private:
protected:
//! Start the asynchronous read loop for the connection
Task<void> read_loop();

using AuthorizationError = std::string;
using AuthorizationResult = tl::expected<void, AuthorizationError>;
AuthorizationResult is_request_authorized(const boost::beast::http::request<boost::beast::http::string_body>& req);
AuthorizationResult is_request_authorized(const RequestWithStringBody& req);

Task<void> handle_request(const boost::beast::http::request<boost::beast::http::string_body>& req);
Task<void> handle_actual_request(const boost::beast::http::request<boost::beast::http::string_body>& req);
Task<void> handle_preflight(const boost::beast::http::request<boost::beast::http::string_body>& req);
Task<void> handle_request(const RequestWithStringBody& req);
Task<void> handle_actual_request(const RequestWithStringBody& req);
Task<void> handle_preflight(const RequestWithStringBody& req);

bool is_origin_allowed(const std::vector<std::string>& allowed_origins, const std::string& origin);
bool is_method_allowed(boost::beast::http::verb method);
bool is_accepted_content_type(const std::string& content_type);

Task<void> do_upgrade(const boost::beast::http::request<boost::beast::http::string_body>& req);
Task<void> do_upgrade(const RequestWithStringBody& req);

template <class Body>
void set_cors(boost::beast::http::response<Body>& res);
Expand Down
144 changes: 133 additions & 11 deletions silkworm/rpc/http/connection_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,11 @@

#include <boost/asio/thread_pool.hpp>
#include <catch2/catch.hpp>
#include <jwt-cpp/jwt.h>
#include <jwt-cpp/traits/nlohmann-json/defaults.h>

#include <silkworm/infra/grpc/client/client_context_pool.hpp>
#include <silkworm/infra/test_util/log.hpp>
#include <silkworm/rpc/commands/rpc_api_table.hpp>

namespace silkworm::rpc::http {

Expand All @@ -30,21 +31,142 @@ namespace silkworm::rpc::http {
// - write of size 1 thread T8 'grpc_global_tim' created by main thread
// - previous write of size 1 by main thread
#ifndef SILKWORM_SANITIZE

class Connection_ForTest : public Connection {
public:
using Connection::Connection;
using Connection::is_request_authorized;
};

TEST_CASE("connection creation", "[rpc][http][connection]") {
test_util::SetLogVerbosityGuard log_guard{log::Level::kNone};

SECTION("field initialization") {
ClientContextPool context_pool{1};
context_pool.start();
boost::asio::io_context ioc;
boost::asio::ip::tcp::socket socket{ioc};
socket.open(boost::asio::ip::tcp::v4());
RequestHandlerFactory handler_factory = [](auto*) -> RequestHandlerPtr { return nullptr; };
std::vector<std::string> allowed_origins;
std::optional<std::string> jwt_secret;
boost::asio::thread_pool workers;
// Uncommenting the following lines you got stuck into llvm-cov problem:
// error: cmd/unit_test: Failed to load coverage: Malformed coverage data
/*
commands::RpcApiTable handler_table{""};
Connection conn{context_pool.next_context(), workers, handler_table};
*/
context_pool.stop();
context_pool.join();
CHECK_NOTHROW(Connection_ForTest{std::move(socket),
handler_factory,
allowed_origins,
std::move(jwt_secret),
false,
false,
false,
workers});
}
}

static constexpr auto kSampleJWTKey{
"NTNv7j0TuYARvmNMmWXo6fKvM4o6nv/aUi9ryX38ZH+L1bkrnD1ObOQ8JAUmHCBq7Iy7otZcyAagBLHVKvvYaIpmMuxmARQ97jUVG16Jkpkp1wXO"
"PsrF9zwew6TpczyHkHgX5EuLg2MeBuiT/qJACs1J0apruOOJCg/gOtkjB4c="sv};
static constexpr auto kSampleJWTBearer{
"Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUs"
"ImlhdCI6MTcxMzUxNDQ3MCwiZXhwIjoxNzEzNTE4MDcwfQ.IBKIdE8Bcto9cwGSkr6mqylBLvfcPZZyDOyZMWYtEaQ"sv};

static std::string create_and_sign_jwt_token(auto&& jwt_secret, bool include_issued_at = true) {
auto token_builder{jwt::create()};
if (include_issued_at) {
token_builder.set_issued_at(jwt::date::clock::now());
}
return token_builder.sign(jwt::algorithm::hs256{std::forward<decltype(jwt_secret)>(jwt_secret)});
}

static RequestWithStringBody create_request_with_authorization(std::string_view auth_value) {
RequestWithStringBody req;
req.insert(boost::beast::http::field::authorization, auth_value);
return req;
}

static RequestWithStringBody create_request_with_bearer_token(const std::string& jwt_token) {
return create_request_with_authorization("Bearer " + jwt_token);
}

TEST_CASE("is_request_authorized", "[rpc][http][connection]") {
test_util::SetLogVerbosityGuard log_guard{log::Level::kNone};
boost::asio::io_context ioc;
RequestHandlerFactory handler_factory = [](auto*) -> RequestHandlerPtr { return nullptr; };
std::vector<std::string> allowed_origins;
boost::asio::thread_pool workers;
auto make_connection = [&](auto&& j) -> Connection_ForTest {
boost::asio::ip::tcp::socket socket{ioc};
socket.open(boost::asio::ip::tcp::v4());
return {std::move(socket), handler_factory, allowed_origins, std::forward<decltype(j)>(j), false, false, false, workers};
};
std::optional<std::string> jwt_secret{kSampleJWTKey};
// Pass the expected JWT secret to the HTTP connection
Connection_ForTest connection{make_connection(*jwt_secret)};

SECTION("no HTTP Authorization header") {
const auto auth_result{connection.is_request_authorized(RequestWithStringBody{})};
CHECK(!auth_result);
CHECK(auth_result.error() == "missing token");
}

SECTION("empty HTTP Authorization header") {
RequestWithStringBody req;
req.insert(boost::beast::http::field::authorization, "");
const auto auth_result{connection.is_request_authorized(req)};
CHECK(!auth_result);
CHECK(auth_result.error() == "missing token");
}

SECTION("invalid Bearer token") {
RequestWithStringBody req{create_request_with_authorization("Bear")};
const auto auth_result{connection.is_request_authorized(req)};
CHECK(!auth_result);
CHECK(auth_result.error() == "missing token");
}

SECTION("invalid JWT token") {
RequestWithStringBody req = create_request_with_bearer_token("INVALID_TOKEN");
const auto auth_result{connection.is_request_authorized(req)};
CHECK(!auth_result);
CHECK(auth_result.error() == "invalid token");
}

SECTION("invalid JWT issued-at claim") {
// Create the HTTP request using a valid-but-too-old Bearer token
RequestWithStringBody req = create_request_with_authorization(kSampleJWTBearer);

const auto auth_result{connection.is_request_authorized(req)};
CHECK(!auth_result);
CHECK(auth_result.error() == "invalid issued-at claim");
}

SECTION("invalid JWT signature") {
// Create *now* a new JWT token and sign it using `another_jwt_secret`
std::optional<std::string> another_jwt_secret{"00112233"};
const auto jwt_token{create_and_sign_jwt_token(*another_jwt_secret)};
// Create the HTTP request using the JWT token
RequestWithStringBody req = create_request_with_bearer_token(jwt_token);

const auto auth_result{connection.is_request_authorized(req)};
CHECK(!auth_result);
CHECK(auth_result.error() == "invalid signature");
}

SECTION("missing JWT issued-at claim") {
// Create *now* a new JWT token w/o issued-at claim and sign it using the same `jwt_secret`
const auto jwt_token{create_and_sign_jwt_token(*jwt_secret, /*include_issued_at=*/false)};
// Create the HTTP request using the JWT token
RequestWithStringBody req = create_request_with_bearer_token(jwt_token);

const auto auth_result{connection.is_request_authorized(req)};
CHECK(!auth_result);
CHECK(auth_result.error() == "missing issued-at claim");
}

SECTION("valid JWT token") {
// Create *now* a new JWT token and sign it using the same `jwt_secret`
const auto jwt_token{create_and_sign_jwt_token(*jwt_secret)};
// Create the HTTP request using the JWT token
RequestWithStringBody req = create_request_with_bearer_token(jwt_token);

CHECK(connection.is_request_authorized(req));
}
}
#endif // SILKWORM_SANITIZE
Expand Down

0 comments on commit 4c21489

Please sign in to comment.