From 88fb89b5eb3bc576443a25307dc565615d85489d Mon Sep 17 00:00:00 2001 From: SoftFever Date: Sat, 18 Jul 2026 01:24:44 +0800 Subject: [PATCH] Plugin audit (#14821) * Block plugins from reading or writing app config and cloud credentials Add a denied-filename registry to the plugin audit sandbox, seeded with OrcaSlicer's config (.conf/.ini) and the cloud refresh-token file. The deny is checked above the loading-mode read exemption and the allowed roots, so a plugin cannot reach these files even though they sit inside data_dir(), which is itself an allowed root. Case-insensitive prefix matching also covers the .bak/.tmp companions that hold the same data, and os.rename/os.remove are hooked alongside open so the files cannot be deleted or clobbered either. --- src/slic3r/Utils/OrcaCloudServiceAgent.cpp | 2 +- src/slic3r/Utils/OrcaCloudServiceAgent.hpp | 9 + src/slic3r/plugin/PluginAuditManager.cpp | 177 ++++++++++++++++--- src/slic3r/plugin/PluginAuditManager.hpp | 30 +++- tests/slic3rutils/CMakeLists.txt | 1 + tests/slic3rutils/test_plugin_audit.cpp | 187 +++++++++++++++++++++ 6 files changed, 382 insertions(+), 24 deletions(-) create mode 100644 tests/slic3rutils/test_plugin_audit.cpp diff --git a/src/slic3r/Utils/OrcaCloudServiceAgent.cpp b/src/slic3r/Utils/OrcaCloudServiceAgent.cpp index b3f05b429c..d0f80abf72 100644 --- a/src/slic3r/Utils/OrcaCloudServiceAgent.cpp +++ b/src/slic3r/Utils/OrcaCloudServiceAgent.cpp @@ -571,7 +571,7 @@ int OrcaCloudServiceAgent::init_log() { return BAMBU_NETWORK_SUCCESS; } int OrcaCloudServiceAgent::set_config_dir(std::string cfg_dir) { config_dir = cfg_dir; - wxFileName fallback(wxString::FromUTF8(cfg_dir.c_str()), "orca_refresh_token.sec"); + wxFileName fallback(wxString::FromUTF8(cfg_dir.c_str()), secret_constants::USER_SECRET_FILENAME); fallback.Normalize(); secret_fallback_path = fallback.GetFullPath().ToStdString(); return BAMBU_NETWORK_SUCCESS; diff --git a/src/slic3r/Utils/OrcaCloudServiceAgent.hpp b/src/slic3r/Utils/OrcaCloudServiceAgent.hpp index 95950570ca..2f422def07 100644 --- a/src/slic3r/Utils/OrcaCloudServiceAgent.hpp +++ b/src/slic3r/Utils/OrcaCloudServiceAgent.hpp @@ -55,6 +55,15 @@ namespace auth_constants { constexpr const char* LOGOUT_PATH = "/auth/v1/logout"; } // namespace auth_constants +// Names of the on-disk credential files inside the config dir. +namespace secret_constants { + // Encrypted refresh-token fallback file, written when wxSecretStore is unavailable. + // Also seeded into PluginAuditManager's denied-filename registry (install_hook), which + // additionally covers the ".tmp" staging file the token is written to before its atomic + // rename, so plugins can read neither. + constexpr const char* USER_SECRET_FILENAME = "orca_refresh_token.sec"; +} // namespace secret_constants + // ============================================================================ // Sync Protocol Data Structures (per Orca Cloud Sync Protocol Specification) // ============================================================================ diff --git a/src/slic3r/plugin/PluginAuditManager.cpp b/src/slic3r/plugin/PluginAuditManager.cpp index 2f955c5945..29aa59b253 100644 --- a/src/slic3r/plugin/PluginAuditManager.cpp +++ b/src/slic3r/plugin/PluginAuditManager.cpp @@ -1,6 +1,8 @@ #include "PluginAuditManager.hpp" +#include "../Utils/OrcaCloudServiceAgent.hpp" #include "libslic3r/Utils.hpp" +#include "libslic3r/libslic3r.h" // GCODEVIEWER_APP_KEY, and SLIC3R_APP_KEY via libslic3r_version.h #include #include @@ -146,6 +148,56 @@ void PluginAuditManager::add_scoped_allowed_root(const boost::filesystem::path& BOOST_LOG_TRIVIAL(info) << "[AUDIT] Scoped allowed root for plugin " << current_plugin() << ": " << root.string(); } +// --------------------------------------------------------------------------- +// Denied filenames +// --------------------------------------------------------------------------- + +void PluginAuditManager::add_denied_filename(const std::string& filename) +{ + if (filename.empty()) + return; + + std::lock_guard lock(m_mutex); + m_denied_filenames.push_back(filename); + BOOST_LOG_TRIVIAL(info) << "[AUDIT] Denied filename: " << filename; +} + +std::vector PluginAuditManager::default_denied_filenames() +{ + // AppConfig::config_path() picks .conf vs .ini on the USE_JSON_CONFIG ifdef and the app key + // on its mode, and is a non-static member we cannot call without an instance. Denying all + // four names is cheaper and more robust than replicating that; the two unused names cost one + // string comparison each. Single-sourced here so install_hook() and the tests seed from the + // same list and cannot drift apart. + return { + SLIC3R_APP_KEY ".conf", + GCODEVIEWER_APP_KEY ".conf", + SLIC3R_APP_KEY ".ini", + GCODEVIEWER_APP_KEY ".ini", + secret_constants::USER_SECRET_FILENAME, + }; +} + +bool PluginAuditManager::is_denied_filename(const boost::filesystem::path& candidate) const +{ + // Match on the base name alone, with no path resolution. Traversal is handled for free, + // because filename() of data_dir()/plugins/../OrcaSlicer.conf is already "OrcaSlicer.conf", + // and the prefix rule covers the .bak/.tmp companions that hold the same secrets plus Windows + // alternate data streams ("OrcaSlicer.conf:stream"). A plugin that launders a denied file + // through a symlink, a hardlink, a subprocess, or a Windows 8.3 short name is out of scope + // (see the design doc): this blocks direct access, not an actively evasive plugin. + const std::string filename = candidate.filename().string(); + if (filename.empty()) + return false; + + std::lock_guard lock(m_mutex); + for (const auto& denied : m_denied_filenames) { + if (boost::algorithm::istarts_with(filename, denied)) + return true; + } + return false; +} + // --------------------------------------------------------------------------- // Audit mode // --------------------------------------------------------------------------- @@ -158,32 +210,39 @@ PluginAuditManager::AuditMode PluginAuditManager::audit_mode() const { return m_ // Policy checks // --------------------------------------------------------------------------- -AuditDecision PluginAuditManager::check_open(const std::string& path_str, const std::string& mode) +AuditDecision PluginAuditManager::check_path_access(const boost::filesystem::path& path, bool is_write) { - if (path_str.empty()) + if (path.empty()) return {true, ""}; std::string plugin_key = current_plugin(); if (plugin_key.empty()) return {true, ""}; // not running inside a plugin context - // During import/loading, only block writes. Python must be able to read - // stdlib modules and the plugin file itself during import. - if (m_audit_mode == AuditMode::Loading) { - bool is_write = (mode.find('w') != std::string::npos || mode.find('a') != std::string::npos || mode.find('+') != std::string::npos); - if (!is_write) - return {true, ""}; + // Denied filenames are checked first, above both the Loading exemption below and the + // allowed roots. The app config and the cloud refresh token live directly inside + // data_dir(), which is a global allowed root, and no scope ever sets Enforcing — so a + // deny placed any lower would be unreachable for reads. + if (is_denied_filename(path)) { + BOOST_LOG_TRIVIAL(warning) << "[AUDIT] block path=" << path.string() << " is_write=" << is_write + << " plugin=" << plugin_key << " reason=denied filename"; + return {false, "denied filename"}; } + // During import/loading, only block writes. Python must be able to read + // stdlib modules and the plugin file itself during import. + if (m_audit_mode == AuditMode::Loading && !is_write) + return {true, ""}; + namespace fs = boost::filesystem; - fs::path candidate(path_str); + fs::path candidate = path; // Resolve relative paths against the current working directory if (candidate.is_relative()) { boost::system::error_code ec; - candidate = fs::absolute(candidate, ec); - if (ec) - candidate = fs::path(path_str); + fs::path absolute_candidate = fs::absolute(candidate, ec); + if (!ec) + candidate = absolute_candidate; } for (const auto& root : m_scoped_allowed_roots) { @@ -201,12 +260,19 @@ AuditDecision PluginAuditManager::check_open(const std::string& path_str, const } } - BOOST_LOG_TRIVIAL(warning) << "[AUDIT] block path=" << candidate.string() << " open_mode=" << mode + BOOST_LOG_TRIVIAL(warning) << "[AUDIT] block path=" << candidate.string() << " is_write=" << is_write << " audit_mode=" << (m_audit_mode == AuditMode::Loading ? "Loading" : "Enforcing") << " plugin=" << plugin_key; return {false, "outside allowed root"}; } +AuditDecision PluginAuditManager::check_open(const std::string& path_str, const std::string& mode) +{ + const bool is_write = mode.find('w') != std::string::npos || mode.find('a') != std::string::npos || + mode.find('+') != std::string::npos; + return check_path_access(boost::filesystem::path(path_str), is_write); +} + void PluginAuditManager::report_violation(const AuditViolation& violation) { m_last_violation = violation; @@ -235,6 +301,28 @@ bool PluginAuditManager::last_violation(AuditViolation& violation) const // The C-level audit hook // --------------------------------------------------------------------------- +namespace { + +// Records a blocked event and raises PermissionError in the calling interpreter. Returns -1 +// so an event branch can `return report_denied(...)` directly. +int report_denied(PluginAuditManager& mgr, + const std::string& event_name, + const boost::filesystem::path& path, + const AuditDecision& decision) +{ + AuditViolation violation; + violation.plugin_key = mgr.current_plugin(); + violation.event_name = event_name; + violation.path = path; + violation.reason = decision.reason; + mgr.report_violation(violation); + + PyErr_SetString(PyExc_PermissionError, "Plugin attempted to access a blocked file path"); + return -1; +} + +} // namespace + int PluginAuditManager::audit_hook(const char* event, PyObject* args, void* user_data) { auto* mgr = static_cast(user_data); @@ -266,17 +354,50 @@ int PluginAuditManager::audit_hook(const char* event, PyObject* args, void* user std::string mode_str(mode_cstr ? mode_cstr : "r"); AuditDecision decision = mgr->check_open(path_str, mode_str); - if (!decision.allowed) { - AuditViolation violation; - violation.plugin_key = mgr->current_plugin(); - violation.event_name = event_name; - violation.path = path_str; - violation.reason = decision.reason; - mgr->report_violation(violation); + if (!decision.allowed) + return report_denied(*mgr, event_name, path_str, decision); + return 0; + } - PyErr_SetString(PyExc_PermissionError, "Plugin attempted to access a blocked file path"); - return -1; + // --- os.rename event (raised by os.rename and os.replace) --- + if (event_name == "os.rename") { + const char* src_cstr = nullptr; + const char* dst_cstr = nullptr; + PyObject* src_dir_fd = nullptr; + PyObject* dst_dir_fd = nullptr; + + // os.rename(src, dst, src_dir_fd, dst_dir_fd) — paths may be str, bytes, or int fd. + // The dir_fd arguments are unused, but must be accepted for the tuple to parse. + if (!PyArg_ParseTuple(args, "ss|OO", &src_cstr, &dst_cstr, &src_dir_fd, &dst_dir_fd)) { + PyErr_Clear(); // couldn't parse; allow + return 0; } + + // A rename writes at both ends, so either end being denied blocks the call. + for (const char* path_cstr : {src_cstr, dst_cstr}) { + std::string path_str(path_cstr ? path_cstr : ""); + AuditDecision decision = mgr->check_path_access(path_str, /* is_write */ true); + if (!decision.allowed) + return report_denied(*mgr, event_name, path_str, decision); + } + return 0; + } + + // --- os.remove event (raised by os.remove and os.unlink) --- + if (event_name == "os.remove") { + const char* path_cstr = nullptr; + PyObject* dir_fd = nullptr; + + // os.remove(path, dir_fd) — path may be str, bytes, or int fd + if (!PyArg_ParseTuple(args, "s|O", &path_cstr, &dir_fd)) { + PyErr_Clear(); // couldn't parse; allow + return 0; + } + + std::string path_str(path_cstr ? path_cstr : ""); + AuditDecision decision = mgr->check_path_access(path_str, /* is_write */ true); + if (!decision.allowed) + return report_denied(*mgr, event_name, path_str, decision); return 0; } @@ -297,6 +418,18 @@ void PluginAuditManager::install_hook() // here: plugins must not write outside data_dir() (G-code plugins additionally get // the temp G-code folder via a scoped root). Reads remain permissive in Loading mode. add_global_allowed_root(data_dir()); + + // The user's app config and cloud credentials live directly inside data_dir(), so the + // root just granted would otherwise expose them to any plugin. Deny them by name. + // + // Seeded here rather than by each secret's owner because install_hook() runs during lazy + // interpreter init and therefore provably precedes any plugin bytecode, whereas + // OrcaCloudServiceAgent::set_config_dir runs during networking init — neither strictly + // precedes the other, and if Orca cloud never initializes an owner-registered token deny + // would never exist at all. default_denied_filenames() is the single source of that list + // (see its comment for why all four config names are denied); the tests seed from it too. + for (const auto& name : default_denied_filenames()) + add_denied_filename(name); } } // namespace Slic3r diff --git a/src/slic3r/plugin/PluginAuditManager.hpp b/src/slic3r/plugin/PluginAuditManager.hpp index addc63b0fa..1b24357e47 100644 --- a/src/slic3r/plugin/PluginAuditManager.hpp +++ b/src/slic3r/plugin/PluginAuditManager.hpp @@ -52,6 +52,27 @@ public: void add_global_allowed_root(const boost::filesystem::path& root); void add_scoped_allowed_root(const boost::filesystem::path& root); + // --- denied-filenames registry --- + // Filenames a plugin may never touch, in any directory, regardless of audit mode or + // enclosing allowed root. A candidate is denied when its filename starts with a + // registered name, so .bak/.tmp companions are covered by the same entry. + // + // The comparison is case-insensitive on every platform, unlike the _WIN32-only iequals + // in is_inside_allowed_root: the default macOS APFS configuration is case-insensitive + // too, so `orcaslicer.conf` reaches the real file there. Over-blocking a genuinely + // distinct name on Linux is the fail-safe direction and costs nothing real. + void add_denied_filename(const std::string& filename); + + // The list install_hook() seeds into the deny registry: the app config (both app keys and + // both extensions) and the cloud refresh token. Exposed so tests seed the exact same set + // without a live interpreter, so the test and production seeding cannot drift apart. + static std::vector default_denied_filenames(); + + // True when candidate's base name starts with a denied name (case-insensitive). No path + // resolution: laundering a denied file through a symlink, hardlink, subprocess, or Windows + // 8.3 short name is out of scope (see the design doc). This blocks direct access only. + bool is_denied_filename(const boost::filesystem::path& candidate) const; + // --- enforcement mode --- enum class AuditMode { // Import/loading phase: allow reads anywhere, only block writes @@ -68,6 +89,11 @@ public: AuditMode audit_mode() const; // --- policy checks --- + // Shared core for every audited filesystem event. The deny list is consulted above the + // Loading-mode read exemption and above the allowed roots, so a denied filename is + // blocked even though every scope currently runs in Loading and the files in question + // sit inside data_dir(), which is itself a global allowed root. + AuditDecision check_path_access(const boost::filesystem::path& candidate, bool is_write); AuditDecision check_open(const std::string& path, const std::string& mode); void report_violation(const AuditViolation& violation); @@ -90,8 +116,10 @@ private: static thread_local bool m_has_last_violation; static thread_local AuditViolation m_last_violation; - std::mutex m_mutex; + // mutable: is_denied_filename() is a const query that must lock. + mutable std::mutex m_mutex; std::vector m_global_allowed_roots; + std::vector m_denied_filenames; }; // RAII guard that sets the current plugin key and capability name, restoring the previous diff --git a/tests/slic3rutils/CMakeLists.txt b/tests/slic3rutils/CMakeLists.txt index a8cbddbc03..c25f671c25 100644 --- a/tests/slic3rutils/CMakeLists.txt +++ b/tests/slic3rutils/CMakeLists.txt @@ -12,6 +12,7 @@ add_executable(${_TEST_NAME}_tests test_slicing_pipeline_config.cpp test_plugin_sort.cpp test_plugin_cloud_metadata.cpp + test_plugin_audit.cpp ../fff_print/test_helpers.cpp ) diff --git a/tests/slic3rutils/test_plugin_audit.cpp b/tests/slic3rutils/test_plugin_audit.cpp new file mode 100644 index 0000000000..49eedbd1fb --- /dev/null +++ b/tests/slic3rutils/test_plugin_audit.cpp @@ -0,0 +1,187 @@ +#include + +#include +#include // GCODEVIEWER_APP_KEY, SLIC3R_APP_KEY (via libslic3r_version.h) +#include +#include // secret_constants::USER_SECRET_FILENAME + +#include "plugin_test_utils.hpp" + +#include + +#include + +using namespace Slic3r; +namespace fs = boost::filesystem; + +namespace { + +// Seed the deny registry with the same list install_hook() uses. Both draw from +// PluginAuditManager::default_denied_filenames(), so the test and production seeding cannot +// drift apart. The registry is a process singleton, so repeated seeding only appends harmless +// duplicates; matching is unaffected. +void seed_denied_names() +{ + PluginAuditManager& mgr = PluginAuditManager::instance(); + for (const auto& name : PluginAuditManager::default_denied_filenames()) + mgr.add_denied_filename(name); +} + +} // namespace + +TEST_CASE("Plugin audit denies app config and token filenames anywhere", "[audit]") +{ + seed_denied_names(); + const PluginAuditManager& mgr = PluginAuditManager::instance(); + + SECTION("the seeded names are denied by their base name") + { + CHECK(mgr.is_denied_filename(fs::path(SLIC3R_APP_KEY ".conf"))); + CHECK(mgr.is_denied_filename(fs::path(GCODEVIEWER_APP_KEY ".conf"))); + CHECK(mgr.is_denied_filename(fs::path(SLIC3R_APP_KEY ".ini"))); + CHECK(mgr.is_denied_filename(fs::path(GCODEVIEWER_APP_KEY ".ini"))); + CHECK(mgr.is_denied_filename(fs::path(secret_constants::USER_SECRET_FILENAME))); + } + + SECTION("companions holding the same secrets are denied by the prefix rule") + { + CHECK(mgr.is_denied_filename(fs::path(SLIC3R_APP_KEY ".conf.bak"))); + CHECK(mgr.is_denied_filename(fs::path(std::string(secret_constants::USER_SECRET_FILENAME) + ".tmp"))); + // Windows alternate data streams share the same base name. + CHECK(mgr.is_denied_filename(fs::path(SLIC3R_APP_KEY ".conf:stream"))); + } + + SECTION("the denial ignores the directory the file lives in") + { + CHECK(mgr.is_denied_filename(fs::path("/tmp") / (SLIC3R_APP_KEY ".conf"))); + CHECK(mgr.is_denied_filename(fs::path("/some/plugin/dir") / (SLIC3R_APP_KEY ".conf"))); + // Traversal is handled for free: filename() of the path below is already the denied name. + CHECK(mgr.is_denied_filename(fs::path(data_dir()) / "plugins" / ".." / (SLIC3R_APP_KEY ".conf"))); + } + + SECTION("matching is case-insensitive on every platform") + { + CHECK(mgr.is_denied_filename(fs::path("orcaslicer.conf"))); + CHECK(mgr.is_denied_filename(fs::path("ORCASLICER.CONF"))); + CHECK(mgr.is_denied_filename(fs::path("ORCA_REFRESH_TOKEN.SEC"))); + } + + SECTION("an unrelated name that merely shares a stem is not denied") + { + // The prefix is the full registered name ("OrcaSlicer.conf"), not the stem "OrcaSlicer", + // so a sibling file with a different extension/suffix stays allowed. + CHECK_FALSE(mgr.is_denied_filename(fs::path(data_dir()) / (SLIC3R_APP_KEY "_other.txt"))); + CHECK_FALSE(mgr.is_denied_filename(fs::path(data_dir()) / (SLIC3R_APP_KEY ".json"))); + CHECK_FALSE(mgr.is_denied_filename(fs::path("orca_refresh_token.txt"))); + } + + SECTION("an empty path is not denied") + { + CHECK_FALSE(mgr.is_denied_filename(fs::path())); + } +} + +TEST_CASE("Plugin audit deny beats allowed roots and the Loading read exemption", "[audit]") +{ + ScopedDataDir data_dir_guard("plugin-audit-deny"); + seed_denied_names(); + + PluginAuditManager& mgr = PluginAuditManager::instance(); + // Reproduce install_hook()'s grant: data_dir() is a global allowed root, so both the app + // config and the token would otherwise be reachable simply by living inside it. + mgr.add_global_allowed_root(data_dir()); + + // Enter a plugin context. The deny must hold in Loading mode, which every scope runs in. + ScopedPluginAuditContext ctx("test_plugin", "", PluginAuditManager::AuditMode::Loading); + + const fs::path conf = fs::path(data_dir()) / (SLIC3R_APP_KEY ".conf"); + const fs::path token = fs::path(data_dir()) / secret_constants::USER_SECRET_FILENAME; + + SECTION("a non-denied file inside the allowed root is writable (root really grants writes)") + { + AuditDecision decision = mgr.check_open((fs::path(data_dir()) / "plugin_data.txt").string(), "w"); + CHECK(decision.allowed); + } + + SECTION("writing the app config is blocked despite data_dir() being allowed") + { + AuditDecision decision = mgr.check_open(conf.string(), "w"); + CHECK_FALSE(decision.allowed); + CHECK(decision.reason == "denied filename"); + } + + SECTION("reading the app config is blocked even though Loading exempts reads") + { + // Without the deny, a read in Loading mode short-circuits to allow. The deny sits above + // that exemption, so this must still be blocked. + AuditDecision decision = mgr.check_open(conf.string(), "r"); + CHECK_FALSE(decision.allowed); + CHECK(decision.reason == "denied filename"); + } + + SECTION("reading the cloud refresh token is blocked in Loading mode") + { + AuditDecision decision = mgr.check_open(token.string(), "r"); + CHECK_FALSE(decision.allowed); + } + + SECTION("the token staging companion (.tmp) is blocked too") + { + AuditDecision decision = mgr.check_open((token.string() + ".tmp"), "w"); + CHECK_FALSE(decision.allowed); + } + + SECTION("a traversal path resolving to the config is blocked") + { + const fs::path traversal = fs::path(data_dir()) / "plugins" / ".." / (SLIC3R_APP_KEY ".conf"); + AuditDecision decision = mgr.check_open(traversal.string(), "r"); + CHECK_FALSE(decision.allowed); + } +} + +TEST_CASE("Plugin audit deny beats a plugin's own scoped root", "[audit]") +{ + ScopedDataDir data_dir_guard("plugin-audit-scoped"); + seed_denied_names(); + + PluginAuditManager& mgr = PluginAuditManager::instance(); + + // A plugin's private directory, granted as a scoped root while it runs. + const fs::path plugin_dir = fs::path(data_dir()) / "plugins" / "test_plugin"; + fs::create_directories(plugin_dir); + + ScopedPluginAuditContext ctx("test_plugin", "", PluginAuditManager::AuditMode::Loading); + mgr.add_scoped_allowed_root(plugin_dir); + + SECTION("the plugin's own non-denied file opens for read and write") + { + const std::string own_file = (plugin_dir / "state.json").string(); + CHECK(mgr.check_open(own_file, "r").allowed); + CHECK(mgr.check_open(own_file, "w").allowed); + } + + SECTION("a denied name stashed inside the plugin's own root is still blocked") + { + const std::string smuggled = (plugin_dir / (SLIC3R_APP_KEY ".conf")).string(); + AuditDecision decision = mgr.check_open(smuggled, "w"); + CHECK_FALSE(decision.allowed); + CHECK(decision.reason == "denied filename"); + } +} + +TEST_CASE("Plugin audit does not constrain non-plugin code", "[audit]") +{ + ScopedDataDir data_dir_guard("plugin-audit-noplugin"); + seed_denied_names(); + + PluginAuditManager& mgr = PluginAuditManager::instance(); + mgr.clear_current_plugin(); // no plugin context: this is OrcaSlicer's own C++/internal Python + + const fs::path conf = fs::path(data_dir()) / (SLIC3R_APP_KEY ".conf"); + + // The name is still recognised as denied... + CHECK(mgr.is_denied_filename(conf)); + // ...but with no current plugin the access check allows it: denies constrain plugin code only. + CHECK(mgr.check_open(conf.string(), "w").allowed); + CHECK(mgr.check_open(conf.string(), "r").allowed); +}