From 29c3db266c26b2aacd4558447e2f61af938a47c9 Mon Sep 17 00:00:00 2001 From: Eddy Ashton Date: Fri, 11 Sep 2026 12:48:00 +0000 Subject: [PATCH 1/3] Make JS registry-managed KV tables read-only for JS endpoints BaseDynamicJSEndpointRegistry stores endpoint metadata, module source, compiled QuickJS bytecode and runtime options in KV tables under a configurable prefix (default public:custom_endpoints). check_kv_map_access treats these as ordinary public app tables, so any JS endpoint executing in a read-write transaction could overwrite them, including planting bytecode consumed by KvBytecodeModuleLoader and rewriting endpoint auth policies. The registry now always intersects the app-provided namespace restriction with a built-in one that makes its own tables READ_ONLY. Table names are resolved at request time via a virtual get_registry_managed_tables(), so subclasses which reassign the map names (GovernanceDrivenJSRegistry) or add tables (DynamicJSEndpointRegistry audit tables) are covered without reserving unrelated namespaces. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 1 + include/ccf/js/registry.h | 30 ++++++++++++++++++++++++ src/js/registry.cpp | 49 ++++++++++++++++++++++++++++++++++++++- tests/programmability.py | 30 ++++++++++++++++++++++++ 4 files changed, 109 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fa240298465..e01044d9c839 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Fixed +- The KV tables managed by `BaseDynamicJSEndpointRegistry` and `DynamicJSEndpointRegistry` (module source, compiled bytecode, endpoint metadata, runtime options, interpreter flush and audit tables) are now always read-only for JS endpoints, independently of any restriction passed to `set_js_kv_namespace_restriction`. Previously, with the default `public:custom_endpoints` prefix, these tables were writable by any JS endpoint executing in a read-write transaction. Subclasses which manage additional tables can extend the protected set by overriding `get_registry_managed_tables()`. - Strengthened access checks on JavaScript KV handles, including namespace restrictions in the historical KV (#8318). - Invalid PEM construction and JSON deserialisation errors no longer include the supplied data, which may contain private key material (#8330). - Reaching the soft session cap on an unsecured RPC interface no longer terminates the node by attempting a TLS handshake without a certificate. (#8331) diff --git a/include/ccf/js/registry.h b/include/ccf/js/registry.h index 9ea04c0efd9d..f104e1ff190d 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,13 @@ namespace ccf::js std::shared_ptr interpreter_cache = nullptr; + // App-provided restriction, see set_js_kv_namespace_restriction ccf::js::NamespaceRestriction namespace_restriction; + // Combines the built-in protection of this registry's own tables with the + // app-provided restriction. Never grants more access than either. + ccf::js::NamespaceRestriction get_effective_namespace_restriction() const; + using PreExecutionHook = std::function; void do_execute_request( @@ -64,6 +71,15 @@ namespace ccf::js std::string modules_quickjs_bytecode_map; std::string runtime_options_map; + /** + * Names of all KV tables managed by this registry, evaluated at request + * time so that subclasses which reassign the map names above are + * respected. JS endpoints are never permitted to write to these tables, + * regardless of any app-provided namespace restriction. Subclasses which + * manage additional tables should override this and extend the result. + */ + virtual std::set get_registry_managed_tables() const; + public: BaseDynamicJSEndpointRegistry( ccf::AbstractNodeContext& context, @@ -104,6 +120,10 @@ namespace ccf::js /** * Pass a function to control which maps can be accessed by JS endpoints. + * This can only remove access, never grant it. The tables used by this + * registry to store endpoint definitions (see + * get_registry_managed_tables()) are always read-only for JS endpoints, + * regardless of the restriction passed here. */ void set_js_kv_namespace_restriction( const ccf::js::NamespaceRestriction& restriction); @@ -161,6 +181,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/src/js/registry.cpp b/src/js/registry.cpp index 42513758c194..74973d6b8a09 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.* @@ -720,6 +720,53 @@ namespace ccf::js namespace_restriction = restriction; } + 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 + { + return [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 (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( ccf::kv::Tx& tx, const ccf::JSRuntimeOptions& options) { diff --git a/tests/programmability.py b/tests/programmability.py index 710d77108ef5..113dfa18fd16 100644 --- a/tests/programmability.py +++ b/tests/programmability.py @@ -353,6 +353,36 @@ 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("Tables managed by the JS registry itself are read-only") + # These tables hold endpoint definitions, module source and compiled + # bytecode, so JS must never be able to write to them. This protection + # is built-in to the registry, independent of the app's restriction. + for suffix in [ + "modules", + "modules_quickjs_bytecode", + "modules_quickjs_version", + "metadata", + "interpreter_flush", + "runtime_options", + "recent_actions", + "audit.input", + "audit.info", + ]: + 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() + + # Only the registry's own tables are protected; the rest of the prefix + # remains an ordinary application namespace. + r = c.post("/app/try_write", {"table": "public:custom_endpoints.my_table"}) + assert r.status_code == http.HTTPStatus.OK.value, 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 From ab4c1ce1355173dfe742b5975ccd9228d6a5c92b Mon Sep 17 00:00:00 2001 From: Eddy Ashton Date: Fri, 11 Sep 2026 16:28:49 +0000 Subject: [PATCH 2/3] Make JS registry namespace protection configurable Reserve the registry namespace by default and allow applications to opt out through the existing namespace restriction setter. Clear cached interpreters when policy changes so retained KV handles cannot preserve stale permissions. Cover defaults, opt-outs, namespace boundaries and policy changes with unit and end-to-end tests. Keep cache extensions source-compatible and document the behavior in the next release. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 9 +- CMakeLists.txt | 6 +- include/ccf/js/interpreter_cache_interface.h | 8 + include/ccf/js/registry.h | 25 ++- python/pyproject.toml | 2 +- src/js/interpreter_cache.h | 6 + src/js/registry.cpp | 20 +- src/js/test/js.cpp | 190 +++++++++++++++++++ tests/programmability.py | 20 +- 9 files changed, 259 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e01044d9c839..2f628c643a4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,13 +5,20 @@ 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)`. + ## [7.0.15] [7.0.15]: https://github.com/microsoft/CCF/releases/tag/ccf-7.0.15 ### Fixed -- The KV tables managed by `BaseDynamicJSEndpointRegistry` and `DynamicJSEndpointRegistry` (module source, compiled bytecode, endpoint metadata, runtime options, interpreter flush and audit tables) are now always read-only for JS endpoints, independently of any restriction passed to `set_js_kv_namespace_restriction`. Previously, with the default `public:custom_endpoints` prefix, these tables were writable by any JS endpoint executing in a read-write transaction. Subclasses which manage additional tables can extend the protected set by overriding `get_registry_managed_tables()`. - Strengthened access checks on JavaScript KV handles, including namespace restrictions in the historical KV (#8318). - Invalid PEM construction and JSON deserialisation errors no longer include the supplied data, which may contain private key material (#8330). - Reaching the soft session cap on an unsecured RPC interface no longer terminates the node by attempting a TLS handshake without a certificate. (#8331) 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 f104e1ff190d..c6b4e2b8da2f 100644 --- a/include/ccf/js/registry.h +++ b/include/ccf/js/registry.h @@ -48,8 +48,9 @@ namespace ccf::js // App-provided restriction, see set_js_kv_namespace_restriction ccf::js::NamespaceRestriction namespace_restriction; - // Combines the built-in protection of this registry's own tables with the - // app-provided restriction. Never grants more access than either. + const std::string registry_managed_prefix; + bool registry_tables_protected = true; + ccf::js::NamespaceRestriction get_effective_namespace_restriction() const; using PreExecutionHook = std::function; @@ -72,11 +73,8 @@ namespace ccf::js std::string runtime_options_map; /** - * Names of all KV tables managed by this registry, evaluated at request - * time so that subclasses which reassign the map names above are - * respected. JS endpoints are never permitted to write to these tables, - * regardless of any app-provided namespace restriction. Subclasses which - * manage additional tables should override this and extend the result. + * 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; @@ -119,14 +117,15 @@ namespace ccf::js const std::string& module_name); /** - * Pass a function to control which maps can be accessed by JS endpoints. - * This can only remove access, never grant it. The tables used by this - * registry to store endpoint definitions (see - * get_registry_managed_tables()) are always read-only for JS endpoints, - * regardless of the restriction passed here. + * 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 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 74973d6b8a09..a120d41902f3 100644 --- a/src/js/registry.cpp +++ b/src/js/registry.cpp @@ -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,14 @@ 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:: @@ -735,13 +741,21 @@ namespace ccf::js ccf::js::NamespaceRestriction BaseDynamicJSEndpointRegistry:: get_effective_namespace_restriction() const { - return [managed_tables = get_registry_managed_tables(), + 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 (managed_tables.contains(map_name)) + 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 " 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 113dfa18fd16..3a2e5a3106fe 100644 --- a/tests/programmability.py +++ b/tests/programmability.py @@ -353,10 +353,7 @@ 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("Tables managed by the JS registry itself are read-only") - # These tables hold endpoint definitions, module source and compiled - # bytecode, so JS must never be able to write to them. This protection - # is built-in to the registry, independent of the app's restriction. + LOG.info("The JS registry's entire table namespace is read-only") for suffix in [ "modules", "modules_quickjs_bytecode", @@ -367,6 +364,9 @@ def test_custom_endpoints_kv_restrictions(network, args): "recent_actions", "audit.input", "audit.info", + "my_table", + "nested.table", + "", ]: table = f"public:custom_endpoints.{suffix}" r = c.post("/app/try_read", {"table": table}) @@ -378,10 +378,14 @@ def test_custom_endpoints_kv_restrictions(network, args): ) assert "managed by the endpoint registry" in r.body.text(), r.body.text() - # Only the registry's own tables are protected; the rest of the prefix - # remains an ordinary application namespace. - r = c.post("/app/try_write", {"table": "public:custom_endpoints.my_table"}) - assert r.status_code == http.HTTPStatus.OK.value, r.status_code + 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"}) From 34efd46b66571052456eeedf4f3f0dcaf17ac39d Mon Sep 17 00:00:00 2001 From: Eddy Ashton Date: Fri, 11 Sep 2026 16:29:41 +0000 Subject: [PATCH 3/3] Reference registry protection PR in changelog Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f628c643a4b..8fdbe6e0110c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### 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)`. +- 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]