diff --git a/docs/01_nodeos/03_plugins/http_plugin/index.md b/docs/01_nodeos/03_plugins/http_plugin/index.md index 39a43687bf..cafe2fd422 100644 --- a/docs/01_nodeos/03_plugins/http_plugin/index.md +++ b/docs/01_nodeos/03_plugins/http_plugin/index.md @@ -65,8 +65,18 @@ Config Options for eosio::http_plugin: --http-keep-alive arg (=1) If set to false, do not keep HTTP connections alive, even if client requests. + --http-allow-control-plane-cors Allow Access-Control-Allow-Origin to be + configured while an unauthenticated + control-plane API (producer_rw, + snapshot) is bound to a non-loopback + address. Without this flag that + combination is refused. ``` +`access-control-allow-origin=*` cannot be combined with +`access-control-allow-credentials=true`; `http_plugin` refuses that pair at +startup. + ## Dependencies None diff --git a/docs/01_nodeos/03_plugins/producer_api_plugin/index.md b/docs/01_nodeos/03_plugins/producer_api_plugin/index.md index 6727d92894..550d9df722 100644 --- a/docs/01_nodeos/03_plugins/producer_api_plugin/index.md +++ b/docs/01_nodeos/03_plugins/producer_api_plugin/index.md @@ -15,7 +15,49 @@ nodeos ... --plugin eosio::producer_api_plugin ## Options -None +These can be specified from both the command-line or the `config.ini` file: + +```console +Config Options for eosio::producer_api_plugin: + --http-expose-nonloopback-producer-api + Allow producer_rw and snapshot HTTP + APIs to bind to non-loopback addresses. + These endpoints have no authentication + and can pause/resume production, change + runtime options, manage snapshots, and + schedule protocol features. Default is + false: non-loopback exposure is refused + at startup. Loopback and UNIX socket + bindings do not require this option. +``` + +Related `http_plugin` option: + +```console + --http-allow-control-plane-cors + Allow Access-Control-Allow-Origin while + producer_rw/snapshot are bound to a + non-loopback address. Without this flag + that combination is refused. +``` + +## Security + +`producer_api_plugin` RPCs are **unauthenticated**. Destructive calls (`pause`, +`resume`, `update_runtime_options`, snapshot schedule, whitelist/greylist, +protocol feature schedule) share the `http_plugin` listener. + +Safe defaults: + +* Bind HTTP to `127.0.0.1` or a UNIX socket (the `http-server-address` default + is loopback). Local `cleos` / operator tooling keeps working with no extra flags. +* Prefer `--http-category-address` so `producer_rw` and `snapshot` listen on + loopback or a UNIX socket while public chain APIs use a different address. +* Non-loopback exposure requires `--http-expose-nonloopback-producer-api`. +* Combining a configured `access-control-allow-origin` with non-loopback + producer/snapshot APIs requires `--http-allow-control-plane-cors`. +* `access-control-allow-origin=*` cannot be combined with + `access-control-allow-credentials=true` (refused at startup). ## Dependencies diff --git a/docs/01_nodeos/03_plugins/state_history_plugin/30_how-to-create-snapshot-with-full-history.md b/docs/01_nodeos/03_plugins/state_history_plugin/30_how-to-create-snapshot-with-full-history.md index a81bfe15f5..21d733c498 100644 --- a/docs/01_nodeos/03_plugins/state_history_plugin/30_how-to-create-snapshot-with-full-history.md +++ b/docs/01_nodeos/03_plugins/state_history_plugin/30_how-to-create-snapshot-with-full-history.md @@ -17,7 +17,7 @@ This procedure creates a database containing the chain state, with full history 1. Enable the `producer_api_plugin` on a node with full state-history. [[caution | Caution when using `producer_api_plugin`]] -| Either use a firewall to block access to `http-server-address`, or change it to `localhost:8888` to disable remote access. +| Producer/snapshot RPCs are unauthenticated. Keep `http-server-address` on loopback (`localhost:8888`, the default) or a UNIX socket. Non-loopback exposure is refused unless you pass `--http-expose-nonloopback-producer-api`. Do not combine a public CORS origin with those APIs unless you also pass `--http-allow-control-plane-cors`. 2. Create a portable snapshot: ```sh diff --git a/libraries/libfc/include/fc/container/container_detail.hpp b/libraries/libfc/include/fc/container/container_detail.hpp index 4798851367..5621683df3 100644 --- a/libraries/libfc/include/fc/container/container_detail.hpp +++ b/libraries/libfc/include/fc/container/container_detail.hpp @@ -2,6 +2,7 @@ #include #include +#include namespace fc { @@ -34,6 +35,7 @@ namespace fc { inline void unpack_flat_set( Stream& s, Set& value ) { unsigned_int size; unpack( s, size ); FC_ASSERT( size.value <= MAX_NUM_ARRAY_ELEMENTS ); + assert_claimed_container_fits( s, size.value ); value.clear(); value.reserve( size.value ); for( uint32_t i = 0; i < size.value; ++i ) { @@ -68,6 +70,7 @@ namespace fc { inline void unpack_flat_map( Stream& s, Map& value ) { unsigned_int size; unpack( s, size ); FC_ASSERT( size.value <= MAX_NUM_ARRAY_ELEMENTS ); + assert_claimed_container_fits>( s, size.value ); value.clear(); value.reserve( size.value ); for( uint32_t i = 0; i < size.value; ++i ) { diff --git a/libraries/libfc/include/fc/container/flat.hpp b/libraries/libfc/include/fc/container/flat.hpp index d9569c3daa..e79f46bb92 100644 --- a/libraries/libfc/include/fc/container/flat.hpp +++ b/libraries/libfc/include/fc/container/flat.hpp @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -28,6 +29,7 @@ namespace fc { unsigned_int size; unpack( s, size ); FC_ASSERT( size.value <= MAX_NUM_ARRAY_ELEMENTS ); + detail::assert_claimed_container_fits( s, size.value ); value.clear(); value.resize( size.value ); if( !std::is_fundamental::value ) { @@ -52,6 +54,7 @@ namespace fc { unsigned_int size; unpack( s, size ); FC_ASSERT( size.value <= MAX_SIZE_OF_BYTE_ARRAYS ); + detail::assert_claimed_container_fits( s, size.value ); value.clear(); value.resize( size.value ); if( value.size() ) diff --git a/libraries/libfc/include/fc/io/raw.hpp b/libraries/libfc/include/fc/io/raw.hpp index f8b48e8b38..c073b0f44f 100644 --- a/libraries/libfc/include/fc/io/raw.hpp +++ b/libraries/libfc/include/fc/io/raw.hpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include @@ -298,6 +299,7 @@ namespace fc { template inline void unpack( Stream& s, std::vector& value ) { unsigned_int size; fc::raw::unpack( s, size ); FC_ASSERT( size.value <= MAX_SIZE_OF_BYTE_ARRAYS ); + detail::assert_claimed_container_fits( s, size.value ); value.resize(size.value); if( value.size() ) s.read( value.data(), value.size() ); @@ -449,6 +451,7 @@ namespace fc { inline void unpack( Stream& s, std::unordered_set& value ) { unsigned_int size; fc::raw::unpack( s, size ); FC_ASSERT( size.value <= MAX_NUM_ARRAY_ELEMENTS ); + detail::assert_claimed_container_fits( s, size.value ); value.clear(); value.reserve(size.value); for( uint32_t i = 0; i < size.value; ++i ) @@ -498,6 +501,7 @@ namespace fc { { unsigned_int size; fc::raw::unpack( s, size ); FC_ASSERT( size.value <= MAX_NUM_ARRAY_ELEMENTS ); + detail::assert_claimed_container_fits>( s, size.value ); value.clear(); value.reserve(size.value); for( uint32_t i = 0; i < size.value; ++i ) @@ -545,6 +549,7 @@ namespace fc { inline void unpack( Stream& s, std::deque& value ) { unsigned_int size; fc::raw::unpack( s, size ); FC_ASSERT( size.value <= MAX_NUM_ARRAY_ELEMENTS ); + detail::assert_claimed_container_fits( s, size.value ); value.resize(size.value); for( auto& i : value ) { fc::raw::unpack( s, i ); @@ -565,6 +570,7 @@ namespace fc { unsigned_int size; fc::raw::unpack( s, size ); FC_ASSERT( size.value <= MAX_NUM_ARRAY_ELEMENTS ); + detail::assert_claimed_container_fits( s, size.value ); value.resize( size.value ); for( auto& i : value ) { fc::raw::unpack( s, i ); @@ -596,6 +602,7 @@ namespace fc { constexpr size_t word_size = sizeof(fc::dynamic_bitset::block_type) * CHAR_BIT; size_t num_blocks = (size + word_size - 1) / word_size; FC_ASSERT( num_blocks <= MAX_NUM_ARRAY_ELEMENTS ); + detail::assert_claimed_container_fits( s, num_blocks ); std::vector blocks(num_blocks); for( size_t i = 0; i < num_blocks; ++i ) { fc::raw::unpack( s, blocks[i] ); @@ -617,6 +624,7 @@ namespace fc { inline void unpack( Stream& s, std::vector& value ) { unsigned_int size; fc::raw::unpack( s, size ); FC_ASSERT( size.value <= MAX_NUM_ARRAY_ELEMENTS ); + detail::assert_claimed_container_fits( s, size.value ); value.resize(size.value); for( auto& i : value ) { fc::raw::unpack( s, i ); diff --git a/libraries/libfc/include/fc/io/raw_unpack_bounds.hpp b/libraries/libfc/include/fc/io/raw_unpack_bounds.hpp new file mode 100644 index 0000000000..b30564c10d --- /dev/null +++ b/libraries/libfc/include/fc/io/raw_unpack_bounds.hpp @@ -0,0 +1,78 @@ +#pragma once + +#include +#include +#include + +#include +#include +#include + +namespace fc { namespace raw { namespace detail { + + /** + * True when Stream::remaining() reports a trustworthy byte count of unread + * payload (in-memory datastreams and bounded_datastream). Skips: + * - datastream (size-calculation stream; remaining() is always 0) + * - streams whose remaining() is bool (streambuf in_avail() wrapper) + * - streams with no remaining() (cfile, etc.) + * + * This is a defensive bound only: valid payloads still unpack unchanged. + */ + template + inline constexpr bool stream_has_trusted_remaining() { + using S = std::remove_cvref_t; + if constexpr (std::is_same_v>) { + return false; + } else if constexpr (requires(const S& s) { s.remaining(); }) { + using rem_t = std::remove_cvref_t().remaining())>; + return std::is_integral_v && !std::is_same_v; + } else { + return false; + } + } + + template + inline uint64_t default_instance_packed_size() { + if constexpr (!std::is_default_constructible_v) { + return 0; + } else { + datastream ps; + // Default-initialize (T dummy;), not T dummy{}. Copy-list-initialization + // cannot invoke explicit default constructors such as + // chainbase::shared_cow_vector(). + T dummy; + fc::raw::pack(ps, dummy); + return static_cast(ps.tellp()); + } + } + + /** + * Fail before resize/reserve when a claimed element count cannot fit in the + * remaining stream even if every element serializes at its minimum size + * (default-constructed T). Prevents allocation amplification from a short + * frame that advertises MAX_NUM_ARRAY_ELEMENTS. + * + * Types whose default instance packs to 0 bytes (empty structs) skip the + * check so valid zero-payload vectors are not rejected. + */ + template + inline void assert_claimed_container_fits(Stream& s, uint64_t count) { + if (count == 0) + return; + if constexpr (stream_has_trusted_remaining()) { + const uint64_t rem = static_cast(s.remaining()); + const uint64_t min_elem = default_instance_packed_size(); + if (min_elem == 0) + return; + if (count > rem / min_elem) { + // Same exception as a short-read so existing unpack tests and + // call sites that catch out_of_range_exception keep working. + FC_THROW_EXCEPTION(out_of_range_exception, + "claimed container size ${c} exceeds remaining stream (${r} bytes, min ${m} per element)", + ("c", count)("r", rem)("m", min_elem)); + } + } + } + +}}} // namespace fc::raw::detail diff --git a/libraries/libfc/include/fc/network/message_buffer.hpp b/libraries/libfc/include/fc/network/message_buffer.hpp index 7457b551cc..41c4dd9230 100644 --- a/libraries/libfc/include/fc/network/message_buffer.hpp +++ b/libraries/libfc/include/fc/network/message_buffer.hpp @@ -310,6 +310,8 @@ namespace fc { inline bool get( unsigned char& c ) { return mb.read(&c, 1); } inline bool get( char& c ) { return mb.read(&c, 1); } + inline size_t remaining() const { return mb.bytes_to_read(); } + private: message_buffer& mb; }; @@ -344,6 +346,8 @@ namespace fc { inline bool get( unsigned char& c ) { return mb.peek( &c, 1, index ); } inline bool get( char& c ) { return mb.peek( &c, 1, index ); } + inline size_t remaining() const { return mb.bytes_to_read_from_index(index); } + private: const message_buffer& mb; typename message_buffer::index_t index{0,0}; diff --git a/libraries/libfc/test/CMakeLists.txt b/libraries/libfc/test/CMakeLists.txt index 57d188b95d..93210d4fbc 100644 --- a/libraries/libfc/test/CMakeLists.txt +++ b/libraries/libfc/test/CMakeLists.txt @@ -10,6 +10,7 @@ add_executable( test_fc io/test_json.cpp io/test_random_access_file.cpp io/test_raw.cpp + io/test_unpack_bounds.cpp io/test_tracked_storage.cpp io/test_bounded_datastream.cpp network/test_message_buffer.cpp diff --git a/libraries/libfc/test/io/test_unpack_bounds.cpp b/libraries/libfc/test/io/test_unpack_bounds.cpp new file mode 100644 index 0000000000..f80ee89b21 --- /dev/null +++ b/libraries/libfc/test/io/test_unpack_bounds.cpp @@ -0,0 +1,179 @@ +#include + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +using namespace fc; + +namespace { + +#ifdef __linux__ +uint64_t current_vm_rss_kb() { + std::ifstream status("/proc/self/status"); + std::string line; + while (std::getline(status, line)) { + if (line.rfind("VmRSS:", 0) == 0) { + return static_cast(std::stoull(line.substr(6))); + } + } + return 0; +} +#endif + +void append_unsigned_int(std::vector& out, uint32_t value) { + auto packed = fc::raw::pack(unsigned_int{value}); + out.insert(out.end(), packed.begin(), packed.end()); +} + +} // namespace + +// Same layout as net_plugin select_ids / notice_message.known_* . +struct notice_select_ids { + int64_t mode = 0; // enums pack as int64 + uint32_t pending = 0; + std::vector ids; +}; + +FC_REFLECT(notice_select_ids, (mode)(pending)(ids)) + +BOOST_AUTO_TEST_SUITE(unpack_remaining_bounds_tests) + +BOOST_AUTO_TEST_CASE(vector_sha256_huge_claim_on_short_stream_fails_without_large_alloc) { + // 3-byte unsigned_int(MAX_NUM_ARRAY_ELEMENTS) and no element payload. + std::vector payload; + append_unsigned_int(payload, MAX_NUM_ARRAY_ELEMENTS); + BOOST_REQUIRE_LT(payload.size(), 8u); + +#ifdef __linux__ + const uint64_t rss_before = current_vm_rss_kb(); +#endif + const auto t0 = std::chrono::steady_clock::now(); + + datastream ds(payload.data(), payload.size()); + std::vector ids; + BOOST_CHECK_THROW(fc::raw::unpack(ds, ids), fc::out_of_range_exception); + + const auto elapsed = std::chrono::steady_clock::now() - t0; + BOOST_CHECK_LT(std::chrono::duration_cast(elapsed).count(), 200); + +#ifdef __linux__ + const uint64_t rss_after = current_vm_rss_kb(); + // Unfixed resize(1<<20) of sha256 is ~32MiB. Allow modest exception/test overhead. + BOOST_CHECK_LT(rss_after > rss_before ? (rss_after - rss_before) : 0, 4096u); +#endif + BOOST_CHECK(ids.empty()); +} + +BOOST_AUTO_TEST_CASE(vector_char_huge_claim_on_short_stream_fails_without_large_alloc) { + std::vector payload; + append_unsigned_int(payload, MAX_SIZE_OF_BYTE_ARRAYS); // 20 MiB claim + +#ifdef __linux__ + const uint64_t rss_before = current_vm_rss_kb(); +#endif + + datastream ds(payload.data(), payload.size()); + std::vector bytes; + BOOST_CHECK_THROW(fc::raw::unpack(ds, bytes), fc::out_of_range_exception); + +#ifdef __linux__ + const uint64_t rss_after = current_vm_rss_kb(); + BOOST_CHECK_LT(rss_after > rss_before ? (rss_after - rss_before) : 0, 4096u); +#endif +} + +BOOST_AUTO_TEST_CASE(vector_uint32_valid_and_truncated_payload) { + std::vector src{1, 2, 3, 4, 5}; + auto packed = fc::raw::pack(src); + + { + auto out = fc::raw::unpack>(packed); + BOOST_CHECK(out == src); + } + + // Drop the last element payload but keep the claimed count. Remaining check + // (5 * 4 > remaining) must fail before resize of a lie. + BOOST_REQUIRE_GT(packed.size(), 4u); + packed.resize(packed.size() - 4); + datastream ds(packed.data(), packed.size()); + std::vector out; + BOOST_CHECK_THROW(fc::raw::unpack(ds, out), fc::out_of_range_exception); +} + +BOOST_AUTO_TEST_CASE(vector_string_huge_claim_uses_min_element_size) { + std::vector payload; + append_unsigned_int(payload, MAX_NUM_ARRAY_ELEMENTS); + datastream ds(payload.data(), payload.size()); + std::vector strings; + BOOST_CHECK_THROW(fc::raw::unpack(ds, strings), fc::out_of_range_exception); +} + +BOOST_AUTO_TEST_CASE(deque_and_flat_vector_huge_claim) { + std::vector payload; + append_unsigned_int(payload, MAX_NUM_ARRAY_ELEMENTS); + + { + datastream ds(payload.data(), payload.size()); + std::deque q; + BOOST_CHECK_THROW(fc::raw::unpack(ds, q), fc::out_of_range_exception); + } + { + datastream ds(payload.data(), payload.size()); + boost::container::vector v; + BOOST_CHECK_THROW(fc::raw::unpack(ds, v), fc::out_of_range_exception); + } +} + +BOOST_AUTO_TEST_CASE(bounded_datastream_notice_like_short_frame) { + notice_select_ids valid; + valid.mode = 1; + valid.pending = 0; + valid.ids = {sha256::hash(std::string("a")), sha256::hash(std::string("b"))}; + auto packed = fc::raw::pack(valid); + auto unpacked = fc::raw::unpack(packed); + BOOST_CHECK_EQUAL(unpacked.mode, valid.mode); + BOOST_CHECK_EQUAL(unpacked.ids.size(), 2u); + BOOST_CHECK_EQUAL(unpacked.ids[0], valid.ids[0]); + + // Craft a ~25-byte frame: mode + pending + claimed 1M ids, no id bytes. + char frame[25] = {}; + datastream write_ds(frame, sizeof(frame)); + fc::raw::pack(write_ds, int64_t{0}); + fc::raw::pack(write_ds, uint32_t{0}); + fc::raw::pack(write_ds, unsigned_int{MAX_NUM_ARRAY_ELEMENTS}); + const size_t crafted = write_ds.tellp(); + BOOST_CHECK_LT(crafted, sizeof(frame)); + BOOST_CHECK_LE(crafted, 25u); + +#ifdef __linux__ + const uint64_t rss_before = current_vm_rss_kb(); +#endif + + datastream raw_ds(frame, crafted); + bounded_datastream bds(raw_ds, crafted); + notice_select_ids evil; + BOOST_CHECK_THROW(fc::raw::unpack(bds, evil), fc::out_of_range_exception); + +#ifdef __linux__ + const uint64_t rss_after = current_vm_rss_kb(); + BOOST_CHECK_LT(rss_after > rss_before ? (rss_after - rss_before) : 0, 4096u); +#endif +} + +BOOST_AUTO_TEST_CASE(empty_vector_still_unpacks) { + auto packed = fc::raw::pack(std::vector{}); + auto out = fc::raw::unpack>(packed); + BOOST_CHECK(out.empty()); +} + +BOOST_AUTO_TEST_SUITE_END() diff --git a/plugins/http_plugin/http_plugin.cpp b/plugins/http_plugin/http_plugin.cpp index 9ddd525195..38d1bc9f0f 100644 --- a/plugins/http_plugin/http_plugin.cpp +++ b/plugins/http_plugin/http_plugin.cpp @@ -374,6 +374,10 @@ namespace eosio { "Number of worker threads in http thread pool") ("http-keep-alive", bpo::value()->default_value(true), "If set to false, do not keep HTTP connections alive, even if client requests.") + ("http-allow-control-plane-cors", bpo::bool_switch()->default_value(false), + "Allow Access-Control-Allow-Origin to be configured while an unauthenticated " + "control-plane API (producer_rw, snapshot) is bound to a non-loopback address. " + "Without this flag that combination is refused. Loopback / UNIX bindings are unaffected.") ; } @@ -471,6 +475,22 @@ namespace eosio { } my->plugin_state->server_header = current_http_plugin_defaults.server_header; + // Read CORS from the variables_map. Option notifiers may run after + // plugin_initialize, so plugin_state may still be empty here. + std::string cors_origin; + if (options.count("access-control-allow-origin")) + cors_origin = options.at("access-control-allow-origin").as(); + const bool cors_credentials = options.count("access-control-allow-credentials") && + options.at("access-control-allow-credentials").as(); + if (!cors_origin.empty()) + my->plugin_state->access_control_allow_origin = cors_origin; + my->plugin_state->access_control_allow_credentials = + my->plugin_state->access_control_allow_credentials || cors_credentials; + + EOS_ASSERT(!(cors_origin == "*" && cors_credentials), + chain::plugin_config_exception, + "access-control-allow-origin=* cannot be combined with access-control-allow-credentials=true " + "(browsers treat this as an invalid CORS configuration and it amplifies CSRF risk)"); //watch out for the returns above when adding new code here } FC_LOG_AND_RETHROW() @@ -618,6 +638,14 @@ namespace eosio { }); } + const std::string& http_plugin::access_control_allow_origin() const { + return my->plugin_state->access_control_allow_origin; + } + + bool http_plugin::access_control_allow_credentials() const { + return my->plugin_state->access_control_allow_credentials; + } + bool http_plugin::verbose_errors() { return verbose_http_errors; } diff --git a/plugins/http_plugin/include/eosio/http_plugin/http_plugin.hpp b/plugins/http_plugin/include/eosio/http_plugin/http_plugin.hpp index a82adaf604..9af0867905 100644 --- a/plugins/http_plugin/include/eosio/http_plugin/http_plugin.hpp +++ b/plugins/http_plugin/include/eosio/http_plugin/http_plugin.hpp @@ -117,6 +117,10 @@ namespace eosio { // returns true if `category` is enabled in http_plugin bool is_enabled(api_category category) const; + /// Configured Access-Control-Allow-Origin, empty if unset. + const std::string& access_control_allow_origin() const; + bool access_control_allow_credentials() const; + static bool verbose_errors(); struct get_supported_apis_result { @@ -277,6 +281,37 @@ namespace eosio { } } EOS_RETHROW_EXCEPTIONS(chain::invalid_http_request, "Unable to parse valid input from POST body"); } + + /** + * Policy for unauthenticated control-plane HTTP APIs (producer_rw, snapshot). + * Loopback / UNIX bindings stay available for local tooling; non-loopback + * exposure and CORS on those listeners require explicit opt-in. + */ + struct unauthenticated_api_http_policy { + bool expose_nonloopback = false; + bool allow_control_plane_cors = false; + }; + + inline void validate_unauthenticated_api_http(const char* api_name, + bool on_loopback, + bool cors_origin_configured, + const unauthenticated_api_http_policy& policy) { + if (!on_loopback) { + EOS_ASSERT(policy.expose_nonloopback, chain::plugin_config_exception, + "${api} HTTP API is bound to a non-loopback address and has no authentication. " + "Bind it to 127.0.0.1 or a UNIX socket (recommended), or pass the explicit " + "opt-in flag to acknowledge the risk.", + ("api", api_name)); + } + if (cors_origin_configured && !on_loopback) { + EOS_ASSERT(policy.allow_control_plane_cors, chain::plugin_config_exception, + "${api} HTTP API is reachable on a non-loopback listener while " + "access-control-allow-origin is set. A browser origin can invoke these " + "unauthenticated RPCs. Remove CORS, bind the API to loopback, or pass " + "--http-allow-control-plane-cors.", + ("api", api_name)); + } + } } FC_REFLECT(eosio::error_results::error_info::error_detail, (message)(file)(line_number)(method)) diff --git a/plugins/http_plugin/tests/unit_tests.cpp b/plugins/http_plugin/tests/unit_tests.cpp index 4ed3459859..c92b623938 100644 --- a/plugins/http_plugin/tests/unit_tests.cpp +++ b/plugins/http_plugin/tests/unit_tests.cpp @@ -750,4 +750,34 @@ BOOST_FIXTURE_TEST_CASE(requests_in_flight, http_plugin_test_fixture) { } //A warning for future tests: destruction of http_plugin_test_fixture sometimes does not destroy http_plugin's listeners. Tests -// added in the future should avoid reusing ports of other tests in http_plugin_unit_tests. \ No newline at end of file +// added in the future should avoid reusing ports of other tests in http_plugin_unit_tests. + +BOOST_AUTO_TEST_CASE(unauthenticated_api_http_policy_loopback_ok) { + unauthenticated_api_http_policy policy; + BOOST_CHECK_NO_THROW(validate_unauthenticated_api_http("producer_rw", true, false, policy)); + BOOST_CHECK_NO_THROW(validate_unauthenticated_api_http("snapshot", true, true, policy)); +} + +BOOST_AUTO_TEST_CASE(unauthenticated_api_http_policy_nonloopback_requires_opt_in) { + unauthenticated_api_http_policy policy; + BOOST_CHECK_THROW(validate_unauthenticated_api_http("producer_rw", false, false, policy), + chain::plugin_config_exception); + + policy.expose_nonloopback = true; + BOOST_CHECK_NO_THROW(validate_unauthenticated_api_http("producer_rw", false, false, policy)); + + BOOST_CHECK_THROW(validate_unauthenticated_api_http("snapshot", false, true, policy), + chain::plugin_config_exception); + + policy.allow_control_plane_cors = true; + BOOST_CHECK_NO_THROW(validate_unauthenticated_api_http("snapshot", false, true, policy)); +} + +BOOST_AUTO_TEST_CASE(reject_wildcard_cors_with_credentials) { + const char* test_name = bu::framework::current_test_case().p_name->c_str(); + BOOST_TEST(app_log({test_name, "--plugin=eosio::http_plugin", + "--http-server-address", "127.0.0.1:8893", + "--access-control-allow-origin", "*", + "--access-control-allow-credentials"}).contains( + "cannot be combined with access-control-allow-credentials")); +} \ No newline at end of file diff --git a/plugins/net_plugin/tests/test_net_plugin.cpp b/plugins/net_plugin/tests/test_net_plugin.cpp index b990e17387..08dc9d0c8a 100644 --- a/plugins/net_plugin/tests/test_net_plugin.cpp +++ b/plugins/net_plugin/tests/test_net_plugin.cpp @@ -2,6 +2,8 @@ #include #include #include +#include +#include #include #include @@ -700,5 +702,47 @@ BOOST_AUTO_TEST_CASE(test_adversarial_connect_concurrency_stress) { } } +// ============================================================================= +// F1: short P2P notice_message claiming ids.size()==MAX_NUM_ARRAY_ELEMENTS +// must fail before allocating ~32MiB of sha256. +// ============================================================================= + +BOOST_AUTO_TEST_CASE(test_notice_message_huge_ids_claim_short_frame) { + eosio::notice_message valid; + valid.known_trx.mode = eosio::id_list_modes::normal; + valid.known_blocks.mode = eosio::id_list_modes::none; + valid.known_trx.ids = {fc::sha256::hash(std::string("trx-a"))}; + auto packed_valid = fc::raw::pack(valid); + auto unpacked_valid = fc::raw::unpack(packed_valid); + BOOST_CHECK(unpacked_valid.known_trx.mode == valid.known_trx.mode); + BOOST_CHECK_EQUAL(unpacked_valid.known_trx.ids.size(), 1u); + + // select_ids layout: id_list_modes (unscoped enum, packed as sizeof=4) + + // uint32 pending + vector size. No id bytes; known_blocks never reached. + char frame[25] = {}; + fc::datastream write_ds(frame, sizeof(frame)); + fc::raw::pack(write_ds, eosio::id_list_modes::normal); + fc::raw::pack(write_ds, uint32_t{0}); + fc::raw::pack(write_ds, fc::unsigned_int{MAX_NUM_ARRAY_ELEMENTS}); + const uint32_t crafted = static_cast(write_ds.tellp()); + BOOST_CHECK_LE(crafted, 25u); + BOOST_CHECK_GE(crafted, 11u); // 4 + 4 + 3-byte unsigned_int(1<<20) + + fc::message_buffer<1024> mb; + append_to_message_buffer(mb, &crafted, sizeof(crafted)); + append_to_message_buffer(mb, frame, crafted); + + uint32_t read_len = 0; + auto idx = mb.read_index(); + mb.peek(&read_len, sizeof(read_len), idx); + BOOST_CHECK_EQUAL(read_len, crafted); + mb.advance_read_ptr(sizeof(read_len)); + + auto raw_ds = mb.create_datastream(); + fc::bounded_datastream bds(raw_ds, read_len); + eosio::notice_message evil; + BOOST_CHECK_THROW(fc::raw::unpack(bds, evil), fc::out_of_range_exception); +} + BOOST_AUTO_TEST_SUITE_END() diff --git a/plugins/producer_api_plugin/include/eosio/producer_api_plugin/producer_api_plugin.hpp b/plugins/producer_api_plugin/include/eosio/producer_api_plugin/producer_api_plugin.hpp index d933325269..449979a7e8 100644 --- a/plugins/producer_api_plugin/include/eosio/producer_api_plugin/producer_api_plugin.hpp +++ b/plugins/producer_api_plugin/include/eosio/producer_api_plugin/producer_api_plugin.hpp @@ -20,7 +20,7 @@ class producer_api_plugin : public plugin { producer_api_plugin& operator=(producer_api_plugin&&) = delete; virtual ~producer_api_plugin() override = default; - virtual void set_program_options(options_description& cli, options_description& cfg) override {} + virtual void set_program_options(options_description& cli, options_description& cfg) override; void plugin_initialize(const variables_map& vm); void plugin_startup(); void plugin_shutdown() {} diff --git a/plugins/producer_api_plugin/producer_api_plugin.cpp b/plugins/producer_api_plugin/producer_api_plugin.cpp index d5e451c21f..41b9d697bf 100644 --- a/plugins/producer_api_plugin/producer_api_plugin.cpp +++ b/plugins/producer_api_plugin/producer_api_plugin.cpp @@ -4,8 +4,11 @@ #include #include +#include #include +namespace bpo = boost::program_options; + namespace eosio { namespace detail { struct producer_api_plugin_response { std::string result; @@ -88,6 +91,16 @@ using namespace eosio; eosio::detail::producer_api_plugin_response result{"ok"}; +void producer_api_plugin::set_program_options(options_description&, options_description& cfg) { + cfg.add_options() + ("http-expose-nonloopback-producer-api", bpo::bool_switch()->default_value(false), + "Allow producer_rw and snapshot HTTP APIs to bind to non-loopback addresses. " + "These endpoints have no authentication and can pause/resume production, change " + "runtime options, manage snapshots, and schedule protocol features. Default is " + "false: non-loopback exposure is refused at startup. Loopback and UNIX socket " + "bindings do not require this option."); +} + void producer_api_plugin::plugin_startup() { dlog("starting producer_api_plugin"); // lifetime of plugin is lifetime of application @@ -147,6 +160,20 @@ void producer_api_plugin::plugin_startup() { void producer_api_plugin::plugin_initialize(const variables_map& options) { try { const auto& _http_plugin = app().get_plugin(); + unauthenticated_api_http_policy policy; + policy.expose_nonloopback = options.at("http-expose-nonloopback-producer-api").as(); + if (options.count("http-allow-control-plane-cors")) + policy.allow_control_plane_cors = options.at("http-allow-control-plane-cors").as(); + + const bool cors_origin_configured = !_http_plugin.access_control_allow_origin().empty(); + + validate_unauthenticated_api_http("producer_rw", + _http_plugin.is_on_loopback(api_category::producer_rw), + cors_origin_configured, policy); + validate_unauthenticated_api_http("snapshot", + _http_plugin.is_on_loopback(api_category::snapshot), + cors_origin_configured, policy); + if( !_http_plugin.is_on_loopback(api_category::producer_rw)) { wlog( "\n" "**********SECURITY WARNING**********\n" @@ -154,6 +181,7 @@ void producer_api_plugin::plugin_initialize(const variables_map& options) { "* -- Producer RW API -- *\n" "* - EXPOSED to the LOCAL NETWORK - *\n" "* - USE ONLY ON SECURE NETWORKS! - *\n" + "* -- http-expose-nonloopback-producer-api is set -- *\n" "* *\n" "************************************\n" ); @@ -165,6 +193,7 @@ void producer_api_plugin::plugin_initialize(const variables_map& options) { "* -- Snapshot API -- *\n" "* - EXPOSED to the LOCAL NETWORK - *\n" "* - USE ONLY ON SECURE NETWORKS! - *\n" + "* -- http-expose-nonloopback-producer-api is set -- *\n" "* *\n" "************************************\n" );