mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-10-09 16:51:12 +00:00
## Summary Fixes #15944. The plugin audit deny-list matched `secret`, `cert`, and `conf` as substrings of every path component. This blocked valid imports during plugin capability execution, for example `numpy/__config__.py`, because `conf` appeared inside the module filename. This PR changes deny keyword matching to use whole path components instead of substring matches. It keeps the intended protections for sensitive locations and config files, while allowing dependency and stdlib modules whose names merely contain those strings. ## Changes - Match denied path keywords as whole components instead of substrings. - Keep denying sensitive directory names such as: - `secret` - `secrets` - `cert` - `certs` - `certificate` - `certificates` - `conf` - `config` - Keep denying config files by extension: - `.conf` - `.ini` - Allow legitimate Python module/package paths such as: - `numpy/__config__.py` - `numpy/_core/_ufunc_config.py` - `configparser.py` - `sysconfig.py` - `logging/config.py` - `certifi/cacert.pem` - Include the denied target and reason in `PermissionError` messages when the audit hook blocks an operation. - Remove an unused `<memory>` include from `PluginAuditManager.hpp`. ## Why The previous substring matching caused false positives for common dependency and standard-library paths. It also made failures hard to diagnose because the Python exception did not include the refused path. The new behavior is narrower: it blocks sensitive path components and config file extensions without treating unrelated names like `__config__.py`, `configparser.py`, `Conference`, or `Concert` as secrets. ## Testing - Added/updated unit coverage in `tests/slic3rutils/test_plugin_audit.cpp` for: - whole-component keyword matches - `.conf` / `.ini` blocking - case-insensitive matching - false-positive paths from #15944 Plugin used for testing: [orca_audit_numpy_config_repro.py](https://github.com/user-attachments/files/33143848/orca_audit_numpy_config_repro.py)
352 lines
14 KiB
C++
352 lines
14 KiB
C++
#include <boost/filesystem/path.hpp>
|
|
#include <boost/filesystem/operations.hpp>
|
|
#include <catch2/catch_all.hpp>
|
|
|
|
#include <libslic3r/Utils.hpp>
|
|
#include <libslic3r/libslic3r.h> // GCODEVIEWER_APP_KEY, SLIC3R_APP_KEY (via libslic3r_version.h)
|
|
#include <slic3r/plugin/PluginAuditManager.hpp>
|
|
#include <slic3r/Utils/OrcaCloudServiceAgent.hpp> // secret_constants::USER_SECRET_FILENAME
|
|
|
|
#include <catch2/catch_test_macros.hpp>
|
|
#include "libslic3r_version.h"
|
|
#include "plugin_test_utils.hpp"
|
|
|
|
#include <boost/filesystem.hpp>
|
|
|
|
#include <string>
|
|
|
|
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);
|
|
}
|
|
|
|
// Seed the keyword registry with the same list install_hook() uses. Same rationale as
|
|
// seed_denied_names(): a process-singleton registry, seeded from the single shared source so
|
|
// production and tests cannot drift apart.
|
|
void seed_denied_keywords()
|
|
{
|
|
PluginAuditManager& mgr = PluginAuditManager::instance();
|
|
for (const auto& keyword : PluginAuditManager::default_denied_path_keywords())
|
|
mgr.add_denied_path_keyword(keyword);
|
|
}
|
|
|
|
} // 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", "[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 even inside a globally allowed root.
|
|
ScopedPluginAuditContext ctx("test_plugin", "");
|
|
|
|
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("a file outside the allowed root is blocked for reads as well as writes")
|
|
{
|
|
AuditDecision decision = mgr.check_open((fs::path(data_dir()).parent_path() / "outside.txt").string(), "r");
|
|
CHECK_FALSE(decision.allowed);
|
|
CHECK(decision.reason == "outside allowed root");
|
|
}
|
|
|
|
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 despite the allowed root")
|
|
{
|
|
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")
|
|
{
|
|
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", "");
|
|
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);
|
|
}
|
|
|
|
TEST_CASE("Plugin audit denies secret/certificate/config-like paths by keyword", "[audit]")
|
|
{
|
|
seed_denied_keywords();
|
|
const PluginAuditManager& mgr = PluginAuditManager::instance();
|
|
|
|
SECTION("a 'secrets' directory component is denied")
|
|
{
|
|
CHECK(mgr.is_denied_path_keyword(fs::path("/plugin/secrets/api_key.json")));
|
|
CHECK(mgr.is_denied_path_keyword(fs::path("/plugin/secret/token.txt")));
|
|
}
|
|
|
|
SECTION("a 'certificate(s)' directory component is denied")
|
|
{
|
|
CHECK(mgr.is_denied_path_keyword(fs::path("/resources/cert/slicer_base64.cer")));
|
|
CHECK(mgr.is_denied_path_keyword(fs::path("/resources/certificates/ca.pem")));
|
|
}
|
|
|
|
SECTION("a 'conf'/'config' directory or config file component is denied")
|
|
{
|
|
CHECK(mgr.is_denied_path_keyword(fs::path("/plugin/conf/settings.json")));
|
|
CHECK(mgr.is_denied_path_keyword(fs::path("/plugin/config/settings.json")));
|
|
CHECK(mgr.is_denied_path_keyword(fs::path("/plugin/plugin.conf")));
|
|
CHECK(mgr.is_denied_path_keyword(fs::path("/plugin/plugin.ini")));
|
|
}
|
|
|
|
SECTION("matching is case-insensitive")
|
|
{
|
|
CHECK(mgr.is_denied_path_keyword(fs::path("/plugin/SECRETS/token.txt")));
|
|
CHECK(mgr.is_denied_path_keyword(fs::path("/resources/Certificates/ca.pem")));
|
|
CHECK(mgr.is_denied_path_keyword(fs::path("/plugin/PLUGIN.CONF")));
|
|
}
|
|
|
|
SECTION("matching is not limited to the base name -- any ancestor component counts")
|
|
{
|
|
CHECK(mgr.is_denied_path_keyword(fs::path("/data/secrets/nested/deep/file.txt")));
|
|
}
|
|
|
|
SECTION("an unrelated path is not denied")
|
|
{
|
|
CHECK_FALSE(mgr.is_denied_path_keyword(fs::path("/plugin/output/model.gcode")));
|
|
CHECK_FALSE(mgr.is_denied_path_keyword(fs::path("/plugin/storage/state.json")));
|
|
CHECK_FALSE(mgr.is_denied_path_keyword(fs::path("/python/packages/cp312/numpy/__config__.py")));
|
|
CHECK_FALSE(mgr.is_denied_path_keyword(fs::path("/python/packages/cp312/numpy/_core/_ufunc_config.py")));
|
|
CHECK_FALSE(mgr.is_denied_path_keyword(fs::path("/python/Lib/configparser.py")));
|
|
CHECK_FALSE(mgr.is_denied_path_keyword(fs::path("/python/Lib/sysconfig.py")));
|
|
CHECK_FALSE(mgr.is_denied_path_keyword(fs::path("/python/Lib/logging/config.py")));
|
|
CHECK_FALSE(mgr.is_denied_path_keyword(fs::path("/python/packages/cp312/certifi/cacert.pem")));
|
|
CHECK_FALSE(mgr.is_denied_path_keyword(fs::path("/users/Conference/output.txt")));
|
|
CHECK_FALSE(mgr.is_denied_path_keyword(fs::path("/users/Concert/output.txt")));
|
|
}
|
|
|
|
SECTION("an empty path is not denied")
|
|
{
|
|
CHECK_FALSE(mgr.is_denied_path_keyword(fs::path()));
|
|
}
|
|
}
|
|
|
|
TEST_CASE("Plugin audit is_denied_path combines the filename and keyword registries", "[audit]")
|
|
{
|
|
seed_denied_names();
|
|
seed_denied_keywords();
|
|
const PluginAuditManager& mgr = PluginAuditManager::instance();
|
|
|
|
SECTION("a filename-registry match is denied")
|
|
{
|
|
CHECK(mgr.is_denied_path(fs::path(SLIC3R_APP_KEY ".conf")));
|
|
}
|
|
|
|
SECTION("a keyword-registry match is denied")
|
|
{
|
|
CHECK(mgr.is_denied_path(fs::path("/plugin/secrets/token.txt")));
|
|
}
|
|
|
|
SECTION("a path matching neither registry is not denied")
|
|
{
|
|
CHECK_FALSE(mgr.is_denied_path(fs::path("/plugin/output/model.gcode")));
|
|
}
|
|
}
|
|
|
|
TEST_CASE("Plugin audit a read-only allowed root blocks writes but not reads", "[audit]")
|
|
{
|
|
ScopedDataDir data_dir_guard("plugin-audit-readonly");
|
|
ScopedResourcesDir resources_dir_guard("plugin-audit-readonly-resources");
|
|
seed_denied_names();
|
|
seed_denied_keywords();
|
|
|
|
PluginAuditManager& mgr = PluginAuditManager::instance();
|
|
mgr.add_global_allowed_root(resources_dir(), /*allow_write=*/false);
|
|
|
|
ScopedPluginAuditContext ctx("test_plugin", "");
|
|
|
|
const fs::path readonly_file = fs::path(resources_dir()) / "profiles" / "vendor.json";
|
|
|
|
SECTION("a read inside the read-only root is allowed")
|
|
{
|
|
CHECK(mgr.check_open(readonly_file.string(), "r").allowed);
|
|
}
|
|
|
|
SECTION("a write inside the read-only root is blocked")
|
|
{
|
|
AuditDecision decision = mgr.check_open(readonly_file.string(), "w");
|
|
CHECK_FALSE(decision.allowed);
|
|
CHECK(decision.reason == "outside allowed root");
|
|
}
|
|
|
|
SECTION("a create inside the read-only root is blocked")
|
|
{
|
|
AuditDecision decision = mgr.check_path_access(readonly_file, /*is_write=*/true);
|
|
CHECK_FALSE(decision.allowed);
|
|
}
|
|
|
|
SECTION("the bundled cert underneath the read-only root is denied even for reads")
|
|
{
|
|
const fs::path cert = fs::path(resources_dir()) / "cert" / "slicer_base64.cer";
|
|
AuditDecision decision = mgr.check_open(cert.string(), "r");
|
|
CHECK_FALSE(decision.allowed);
|
|
CHECK(decision.reason == "denied path keyword");
|
|
}
|
|
}
|
|
|
|
TEST_CASE("Plugin audit a scoped root can also be registered read-only", "[audit]")
|
|
{
|
|
ScopedDataDir data_dir_guard("plugin-audit-scoped-readonly");
|
|
seed_denied_names();
|
|
seed_denied_keywords();
|
|
|
|
PluginAuditManager& mgr = PluginAuditManager::instance();
|
|
|
|
const fs::path readonly_dir = fs::path(data_dir()) / "readonly_scope";
|
|
fs::create_directories(readonly_dir);
|
|
|
|
ScopedPluginAuditContext ctx("test_plugin", "");
|
|
mgr.add_scoped_allowed_root(readonly_dir, /*allow_write=*/false);
|
|
|
|
SECTION("a read inside the scoped read-only root is allowed")
|
|
{
|
|
CHECK(mgr.check_open((readonly_dir / "vendor.json").string(), "r").allowed);
|
|
}
|
|
|
|
SECTION("a write inside the scoped read-only root is blocked")
|
|
{
|
|
CHECK_FALSE(mgr.check_open((readonly_dir / "vendor.json").string(), "w").allowed);
|
|
}
|
|
}
|