diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fa240298465..8fdbe6e0110c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## [7.0.16] + +[7.0.16]: https://github.com/microsoft/CCF/releases/tag/ccf-7.0.16 + +### Fixed + +- JS registry tables and their namespace (`public:custom_endpoints.*` by default) are now read-only to JS endpoints. Apps requiring writes can opt out with `set_js_kv_namespace_restriction(restriction, false)` (#8359). + ## [7.0.15] [7.0.15]: https://github.com/microsoft/CCF/releases/tag/ccf-7.0.15 diff --git a/CMakeLists.txt b/CMakeLists.txt index 47c29fe35779..12bfa4fd0131 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -818,7 +818,11 @@ if(BUILD_TESTS) ${CMAKE_CURRENT_SOURCE_DIR}/src/node/test/node_client.cpp ) - add_unit_test(js_test ${CMAKE_CURRENT_SOURCE_DIR}/src/js/test/js.cpp) + add_unit_test( + js_test + ${CMAKE_CURRENT_SOURCE_DIR}/src/js/test/js.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/src/node/uvm_endorsements.cpp + ) target_link_libraries( js_test PRIVATE ccf_js ccf_kv ccf_endpoints ccfcrypto http_parser diff --git a/include/ccf/js/interpreter_cache_interface.h b/include/ccf/js/interpreter_cache_interface.h index 0c9ff9e02c4b..834584f3fdca 100644 --- a/include/ccf/js/interpreter_cache_interface.h +++ b/include/ccf/js/interpreter_cache_interface.h @@ -6,6 +6,8 @@ #include "ccf/js/tx_access.h" #include "ccf/node_subsystem_interface.h" +#include + namespace ccf::js { namespace core @@ -47,6 +49,12 @@ namespace ccf::js // been idle the longest when the cap is reached. virtual void set_max_cached_interpreters(size_t max) = 0; + // Discard retained interpreters without changing the cache capacity. + virtual void clear_cached_interpreters() + { + throw std::logic_error("Interpreter cache does not support clearing"); + } + virtual void set_interpreter_factory(const InterpreterFactory& ip) = 0; }; } diff --git a/include/ccf/js/registry.h b/include/ccf/js/registry.h index 9ea04c0efd9d..c6b4e2b8da2f 100644 --- a/include/ccf/js/registry.h +++ b/include/ccf/js/registry.h @@ -14,6 +14,8 @@ #include "ccf/tx_id.h" #include +#include +#include #define FMT_HEADER_ONLY #include @@ -43,8 +45,14 @@ namespace ccf::js std::shared_ptr interpreter_cache = nullptr; + // App-provided restriction, see set_js_kv_namespace_restriction ccf::js::NamespaceRestriction namespace_restriction; + const std::string registry_managed_prefix; + bool registry_tables_protected = true; + + ccf::js::NamespaceRestriction get_effective_namespace_restriction() const; + using PreExecutionHook = std::function; void do_execute_request( @@ -64,6 +72,12 @@ namespace ccf::js std::string modules_quickjs_bytecode_map; std::string runtime_options_map; + /** + * Registry-managed tables, resolved at request time. Subclasses should + * extend this set for tables outside kv_prefix + ".". + */ + virtual std::set get_registry_managed_tables() const; + public: BaseDynamicJSEndpointRegistry( ccf::AbstractNodeContext& context, @@ -103,10 +117,15 @@ namespace ccf::js const std::string& module_name); /** - * Pass a function to control which maps can be accessed by JS endpoints. + * Set the JS KV restriction. By default, registry-managed tables and the + * kv_prefix + "." namespace are also read-only. + * Pass false to apply only restriction, or ({}, false) for no namespace + * restrictions. Platform permissions still apply. Clears cached + * interpreters. */ void set_js_kv_namespace_restriction( - const ccf::js::NamespaceRestriction& restriction); + const ccf::js::NamespaceRestriction& restriction, + bool protect_registry_tables = true); /** * Set options to control JS execution. Some hard limits may be applied to @@ -161,6 +180,16 @@ namespace ccf::js std::string audit_input_map; std::string audit_info_map; + std::set get_registry_managed_tables() const override + { + auto tables = + BaseDynamicJSEndpointRegistry::get_registry_managed_tables(); + tables.insert(recent_actions_map); + tables.insert(audit_input_map); + tables.insert(audit_info_map); + return tables; + } + public: DynamicJSEndpointRegistry( ccf::AbstractNodeContext& context, diff --git a/python/pyproject.toml b/python/pyproject.toml index 50017d2f73df..900ffe42262c 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "ccf" -version = "7.0.15" +version = "7.0.16" authors = [ { name="CCF Team", email="CCF-Sec@microsoft.com" }, ] diff --git a/src/js/interpreter_cache.h b/src/js/interpreter_cache.h index b7a5907c02cf..b565f7b28888 100644 --- a/src/js/interpreter_cache.h +++ b/src/js/interpreter_cache.h @@ -104,6 +104,12 @@ namespace ccf::js lru.set_max_size(max); } + void clear_cached_interpreters() override + { + std::lock_guard guard(lock); + lru.clear(); + } + void set_interpreter_factory(const InterpreterFactory& ip) override { interpreter_factory = ip; diff --git a/src/js/registry.cpp b/src/js/registry.cpp index 42513758c194..a120d41902f3 100644 --- a/src/js/registry.cpp +++ b/src/js/registry.cpp @@ -126,7 +126,7 @@ namespace ccf::js // ccf.kv.* auto kv_extension = std::make_shared( - &endpoint_ctx.tx, namespace_restriction); + &endpoint_ctx.tx, get_effective_namespace_restriction()); local_extensions.emplace_back(kv_extension); // ccf.rpc.* @@ -495,6 +495,7 @@ namespace ccf::js BaseDynamicJSEndpointRegistry::BaseDynamicJSEndpointRegistry( ccf::AbstractNodeContext& context, const std::string& kv_prefix) : ccf::UserEndpointRegistry(context), + registry_managed_prefix(fmt::format("{}.", kv_prefix)), modules_map(fmt::format("{}.modules", kv_prefix)), metadata_map(fmt::format("{}.metadata", kv_prefix)), interpreter_flush_map(fmt::format("{}.interpreter_flush", kv_prefix)), @@ -715,9 +716,69 @@ namespace ccf::js } void BaseDynamicJSEndpointRegistry::set_js_kv_namespace_restriction( - const ccf::js::NamespaceRestriction& restriction) + const ccf::js::NamespaceRestriction& restriction, + bool protect_registry_tables) { + // Cached KV handles retain their permissions from creation. + interpreter_cache->clear_cached_interpreters(); + namespace_restriction = restriction; + registry_tables_protected = protect_registry_tables; + } + + std::set BaseDynamicJSEndpointRegistry:: + get_registry_managed_tables() const + { + return { + modules_map, + metadata_map, + interpreter_flush_map, + modules_quickjs_version_map, + modules_quickjs_bytecode_map, + runtime_options_map}; + } + + ccf::js::NamespaceRestriction BaseDynamicJSEndpointRegistry:: + get_effective_namespace_restriction() const + { + if (!registry_tables_protected) + { + return namespace_restriction; + } + + return [managed_prefix = registry_managed_prefix, + managed_tables = get_registry_managed_tables(), + app_restriction = namespace_restriction]( + const std::string& map_name, + std::string& explanation) -> ccf::js::KVAccessPermissions { + auto permission = ccf::js::KVAccessPermissions::READ_WRITE; + + if ( + map_name.starts_with(managed_prefix) || + managed_tables.contains(map_name)) + { + explanation = fmt::format( + "The {} table is managed by the endpoint registry, so is read-only " + "in JS.", + map_name); + permission = ccf::js::KVAccessPermissions::READ_ONLY; + } + + if (app_restriction != nullptr) + { + std::string app_explanation; + const auto app_permission = app_restriction(map_name, app_explanation); + const auto combined = + ccf::js::intersect_access_permissions(permission, app_permission); + if (combined != permission) + { + permission = combined; + explanation = app_explanation; + } + } + + return permission; + }; } ccf::ApiResult BaseDynamicJSEndpointRegistry::set_js_runtime_options_v1( diff --git a/src/js/test/js.cpp b/src/js/test/js.cpp index ae7dafcb6a59..3a6bcffa7462 100644 --- a/src/js/test/js.cpp +++ b/src/js/test/js.cpp @@ -4,11 +4,15 @@ #include "ccf/js/extensions/ccf/gov.h" #include "ccf/js/extensions/ccf/historical.h" #include "ccf/js/extensions/ccf/kv.h" +#include "ccf/js/registry.h" +#include "enclave/http_rpc_context.h" #include "js/global_class_ids.h" +#include "js/interpreter_cache.h" #include "js/permissions_checks.h" #include "kv/store.h" #include "kv/test/null_encryptor.h" #include "kv/untyped_map.h" +#include "node/rpc/test/node_stub.h" #include "node/tx_receipt_impl.h" #define DOCTEST_CONFIG_IMPLEMENT @@ -258,6 +262,192 @@ bool table_contains(ccf::kv::Tx& tx, const std::string& table_name) return handle->has({'k'}); } +TEST_CASE("Interpreter cache without a clearing override") +{ + class LegacyInterpreterCache : public AbstractInterpreterCache + { + InterpreterCache cache{1}; + + public: + std::shared_ptr get_interpreter( + TxAccess access, + const std::optional& reuse, + size_t freshness_marker) override + { + return cache.get_interpreter(access, reuse, freshness_marker); + } + + void set_max_cached_interpreters(size_t max) override + { + cache.set_max_cached_interpreters(max); + } + + void set_interpreter_factory(const InterpreterFactory& factory) override + { + cache.set_interpreter_factory(factory); + } + }; + + LegacyInterpreterCache cache; + CHECK_THROWS_WITH_AS( + cache.clear_cached_interpreters(), + "Interpreter cache does not support clearing", + std::logic_error); +} + +TEST_CASE("JS registry namespace restrictions") +{ + ccf::AbstractNodeContext context; + context.install_subsystem(std::make_shared()); + context.install_subsystem(std::make_shared(1)); + + DynamicJSEndpointRegistry registry(context); + const NamespaceRestriction app_restriction = + [](const std::string& name, std::string& explanation) { + if ( + name == "public:app_restricted" || + name == "public:custom_endpoints.app_restricted") + { + explanation = "Restricted by the application"; + return KVAccessPermissions::ILLEGAL; + } + return KVAccessPermissions::READ_WRITE; + }; + + bool protect_registry_tables = true; + bool restrict_app_table = false; + bool reenable_after_execution = false; + auto mode = ccf::endpoints::Mode::ReadWrite; + SUBCASE("Registry protection applies without calling the setter") {} + SUBCASE("Setter protects registry tables by default") + { + registry.set_js_kv_namespace_restriction(app_restriction); + restrict_app_table = true; + } + SUBCASE("Empty callback preserves default registry protection") + { + registry.set_js_kv_namespace_restriction({}); + } + SUBCASE("Opt-out preserves the app restriction") + { + registry.set_js_kv_namespace_restriction(app_restriction, false); + protect_registry_tables = false; + restrict_app_table = true; + } + SUBCASE("Empty callback and opt-out disable all namespace restrictions") + { + registry.set_js_kv_namespace_restriction({}, false); + protect_registry_tables = false; + } + SUBCASE("Full opt-out preserves read-only execution") + { + registry.set_js_kv_namespace_restriction({}, false); + protect_registry_tables = false; + mode = ccf::endpoints::Mode::ReadOnly; + } + SUBCASE("One-argument setter re-enables registry protection") + { + registry.set_js_kv_namespace_restriction({}, false); + protect_registry_tables = false; + reenable_after_execution = true; + } + + ccf::kv::Store store; + store.set_encryptor(std::make_shared()); + Bundle bundle; + auto& properties = bundle.metadata.endpoints["/write"]["POST"]; + properties.js_module = "/write.js"; + properties.js_function = "write"; + properties.mode = mode; + properties.interpreter_reuse = + ccf::endpoints::InterpreterReusePolicy{.key = "namespace-restrictions"}; + bundle.modules.push_back({"/write.js", R"JS( +const handles = new Map(); +export function write(request) { + try { + const table = request.body.text(); + if (!handles.has(table)) { + handles.set(table, ccf.kv[table]); + } + handles.get(table).set( + new Uint8Array([107]).buffer, new Uint8Array([118]).buffer); + } catch (e) { + return {statusCode: 400, body: e.message}; + } + return {statusCode: 200}; +} +)JS"}); + { + auto tx = store.create_tx(); + REQUIRE( + registry.install_custom_endpoints_v1(tx, bundle) == ccf::ApiResult::OK); + REQUIRE(tx.commit() == ccf::kv::CommitResult::SUCCESS); + } + + auto check_write = [&](const std::string& table, bool permitted) { + INFO(table); + auto rpc_ctx = std::make_shared( + std::make_shared(0, std::vector{}), + ccf::HttpVersion::HTTP1, + HTTP_POST, + "/write", + ccf::http::HeaderMap{}, + std::vector(table.begin(), table.end())); + auto tx = store.create_tx(); + // Remove any earlier write so a rejected attempt must leave the key absent. + tx.rw(table)->remove({'k'}); + auto endpoint = registry.find_endpoint(tx, *rpc_ctx); + REQUIRE(endpoint != nullptr); + ccf::endpoints::EndpointContext endpoint_ctx(rpc_ctx, tx); + registry.execute_endpoint(endpoint, endpoint_ctx); + CHECK( + rpc_ctx->get_response_status() == + (permitted ? HTTP_STATUS_OK : HTTP_STATUS_BAD_REQUEST)); + REQUIRE(tx.commit() == ccf::kv::CommitResult::SUCCESS); + auto read_tx = store.create_tx(); + CHECK(table_contains(read_tx, table) == permitted); + }; + + const bool read_write = mode == ccf::endpoints::Mode::ReadWrite; + for (const auto* suffix : + {"modules", + "modules_quickjs_bytecode", + "modules_quickjs_version", + "metadata", + "interpreter_flush", + "runtime_options", + "recent_actions", + "audit.input", + "audit.info", + "my_table"}) + { + check_write( + fmt::format("public:custom_endpoints.{}", suffix), + !protect_registry_tables && read_write); + } + check_write( + "public:custom_endpoints.app_restricted", + !protect_registry_tables && !restrict_app_table && read_write); + check_write("public:app_restricted", !restrict_app_table && read_write); + check_write("public:ordinary_app_table", read_write); + check_write("public:ccf.gov.table", false); + check_write("public:ccf.internal.table", false); + check_write("ccf.gov.table", false); + check_write("ccf.internal.table", false); + + if (reenable_after_execution) + { + registry.set_js_kv_namespace_restriction(app_restriction); + check_write("public:custom_endpoints.my_table", false); + check_write("public:app_restricted", false); + check_write("public:ordinary_app_table", true); + + registry.set_js_kv_namespace_restriction({}, false); + check_write("public:custom_endpoints.my_table", true); + check_write("public:app_restricted", true); + } +} + // Access is resolved once, when a handle is created. These cases confirm that // decision cannot then be bypassed by re-targeting a method at another // receiver, or by mutating the handle from JS. diff --git a/tests/programmability.py b/tests/programmability.py index 710d77108ef5..3a2e5a3106fe 100644 --- a/tests/programmability.py +++ b/tests/programmability.py @@ -353,6 +353,40 @@ def test_custom_endpoints_kv_restrictions(network, args): r = c.post("/app/try_write", {"table": "public:programmability.foo"}) assert r.status_code == http.HTTPStatus.BAD_REQUEST.value, r.status_code + LOG.info("The JS registry's entire table namespace is read-only") + for suffix in [ + "modules", + "modules_quickjs_bytecode", + "modules_quickjs_version", + "metadata", + "interpreter_flush", + "runtime_options", + "recent_actions", + "audit.input", + "audit.info", + "my_table", + "nested.table", + "", + ]: + table = f"public:custom_endpoints.{suffix}" + r = c.post("/app/try_read", {"table": table}) + assert r.status_code == http.HTTPStatus.OK.value, (table, r.status_code) + r = c.post("/app/try_write", {"table": table}) + assert r.status_code == http.HTTPStatus.BAD_REQUEST.value, ( + table, + r.status_code, + ) + assert "managed by the endpoint registry" in r.body.text(), r.body.text() + + LOG.info("Tables outside the registry's namespace remain writable") + for table in [ + "public:custom_endpoints", + "public:custom_endpoints_other.my_table", + "custom_endpoints.my_table", + ]: + r = c.post("/app/try_write", {"table": table}) + assert r.status_code == http.HTTPStatus.OK.value, (table, r.status_code) + LOG.info("Cannot grant access to gov/internal tables") r = c.post("/app/try_read", {"table": "public:ccf.gov.foo"}) assert r.status_code == http.HTTPStatus.OK.value, r.status_code