mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-07-30 06:12:12 +00:00
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.
This commit is contained in:
@@ -571,7 +571,7 @@ int OrcaCloudServiceAgent::init_log() { return BAMBU_NETWORK_SUCCESS; }
|
|||||||
int OrcaCloudServiceAgent::set_config_dir(std::string cfg_dir)
|
int OrcaCloudServiceAgent::set_config_dir(std::string cfg_dir)
|
||||||
{
|
{
|
||||||
config_dir = 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();
|
fallback.Normalize();
|
||||||
secret_fallback_path = fallback.GetFullPath().ToStdString();
|
secret_fallback_path = fallback.GetFullPath().ToStdString();
|
||||||
return BAMBU_NETWORK_SUCCESS;
|
return BAMBU_NETWORK_SUCCESS;
|
||||||
|
|||||||
@@ -55,6 +55,15 @@ namespace auth_constants {
|
|||||||
constexpr const char* LOGOUT_PATH = "/auth/v1/logout";
|
constexpr const char* LOGOUT_PATH = "/auth/v1/logout";
|
||||||
} // namespace auth_constants
|
} // 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)
|
// Sync Protocol Data Structures (per Orca Cloud Sync Protocol Specification)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
#include "PluginAuditManager.hpp"
|
#include "PluginAuditManager.hpp"
|
||||||
|
|
||||||
|
#include "../Utils/OrcaCloudServiceAgent.hpp"
|
||||||
#include "libslic3r/Utils.hpp"
|
#include "libslic3r/Utils.hpp"
|
||||||
|
#include "libslic3r/libslic3r.h" // GCODEVIEWER_APP_KEY, and SLIC3R_APP_KEY via libslic3r_version.h
|
||||||
|
|
||||||
#include <boost/algorithm/string/predicate.hpp>
|
#include <boost/algorithm/string/predicate.hpp>
|
||||||
#include <boost/log/trivial.hpp>
|
#include <boost/log/trivial.hpp>
|
||||||
@@ -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();
|
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<std::mutex> lock(m_mutex);
|
||||||
|
m_denied_filenames.push_back(filename);
|
||||||
|
BOOST_LOG_TRIVIAL(info) << "[AUDIT] Denied filename: " << filename;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<std::string> 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<std::mutex> lock(m_mutex);
|
||||||
|
for (const auto& denied : m_denied_filenames) {
|
||||||
|
if (boost::algorithm::istarts_with(filename, denied))
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Audit mode
|
// Audit mode
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -158,32 +210,39 @@ PluginAuditManager::AuditMode PluginAuditManager::audit_mode() const { return m_
|
|||||||
// Policy checks
|
// 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, ""};
|
return {true, ""};
|
||||||
|
|
||||||
std::string plugin_key = current_plugin();
|
std::string plugin_key = current_plugin();
|
||||||
if (plugin_key.empty())
|
if (plugin_key.empty())
|
||||||
return {true, ""}; // not running inside a plugin context
|
return {true, ""}; // not running inside a plugin context
|
||||||
|
|
||||||
// During import/loading, only block writes. Python must be able to read
|
// Denied filenames are checked first, above both the Loading exemption below and the
|
||||||
// stdlib modules and the plugin file itself during import.
|
// allowed roots. The app config and the cloud refresh token live directly inside
|
||||||
if (m_audit_mode == AuditMode::Loading) {
|
// data_dir(), which is a global allowed root, and no scope ever sets Enforcing — so a
|
||||||
bool is_write = (mode.find('w') != std::string::npos || mode.find('a') != std::string::npos || mode.find('+') != std::string::npos);
|
// deny placed any lower would be unreachable for reads.
|
||||||
if (!is_write)
|
if (is_denied_filename(path)) {
|
||||||
return {true, ""};
|
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;
|
namespace fs = boost::filesystem;
|
||||||
fs::path candidate(path_str);
|
fs::path candidate = path;
|
||||||
|
|
||||||
// Resolve relative paths against the current working directory
|
// Resolve relative paths against the current working directory
|
||||||
if (candidate.is_relative()) {
|
if (candidate.is_relative()) {
|
||||||
boost::system::error_code ec;
|
boost::system::error_code ec;
|
||||||
candidate = fs::absolute(candidate, ec);
|
fs::path absolute_candidate = fs::absolute(candidate, ec);
|
||||||
if (ec)
|
if (!ec)
|
||||||
candidate = fs::path(path_str);
|
candidate = absolute_candidate;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const auto& root : m_scoped_allowed_roots) {
|
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")
|
<< " audit_mode=" << (m_audit_mode == AuditMode::Loading ? "Loading" : "Enforcing")
|
||||||
<< " plugin=" << plugin_key;
|
<< " plugin=" << plugin_key;
|
||||||
return {false, "outside allowed root"};
|
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)
|
void PluginAuditManager::report_violation(const AuditViolation& violation)
|
||||||
{
|
{
|
||||||
m_last_violation = violation;
|
m_last_violation = violation;
|
||||||
@@ -235,6 +301,28 @@ bool PluginAuditManager::last_violation(AuditViolation& violation) const
|
|||||||
// The C-level audit hook
|
// 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)
|
int PluginAuditManager::audit_hook(const char* event, PyObject* args, void* user_data)
|
||||||
{
|
{
|
||||||
auto* mgr = static_cast<PluginAuditManager*>(user_data);
|
auto* mgr = static_cast<PluginAuditManager*>(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");
|
std::string mode_str(mode_cstr ? mode_cstr : "r");
|
||||||
|
|
||||||
AuditDecision decision = mgr->check_open(path_str, mode_str);
|
AuditDecision decision = mgr->check_open(path_str, mode_str);
|
||||||
if (!decision.allowed) {
|
if (!decision.allowed)
|
||||||
AuditViolation violation;
|
return report_denied(*mgr, event_name, path_str, decision);
|
||||||
violation.plugin_key = mgr->current_plugin();
|
return 0;
|
||||||
violation.event_name = event_name;
|
}
|
||||||
violation.path = path_str;
|
|
||||||
violation.reason = decision.reason;
|
|
||||||
mgr->report_violation(violation);
|
|
||||||
|
|
||||||
PyErr_SetString(PyExc_PermissionError, "Plugin attempted to access a blocked file path");
|
// --- os.rename event (raised by os.rename and os.replace) ---
|
||||||
return -1;
|
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;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,6 +418,18 @@ void PluginAuditManager::install_hook()
|
|||||||
// here: plugins must not write outside data_dir() (G-code plugins additionally get
|
// 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.
|
// the temp G-code folder via a scoped root). Reads remain permissive in Loading mode.
|
||||||
add_global_allowed_root(data_dir());
|
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
|
} // namespace Slic3r
|
||||||
|
|||||||
@@ -52,6 +52,27 @@ public:
|
|||||||
void add_global_allowed_root(const boost::filesystem::path& root);
|
void add_global_allowed_root(const boost::filesystem::path& root);
|
||||||
void add_scoped_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<std::string> 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 ---
|
// --- enforcement mode ---
|
||||||
enum class AuditMode {
|
enum class AuditMode {
|
||||||
// Import/loading phase: allow reads anywhere, only block writes
|
// Import/loading phase: allow reads anywhere, only block writes
|
||||||
@@ -68,6 +89,11 @@ public:
|
|||||||
AuditMode audit_mode() const;
|
AuditMode audit_mode() const;
|
||||||
|
|
||||||
// --- policy checks ---
|
// --- 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);
|
AuditDecision check_open(const std::string& path, const std::string& mode);
|
||||||
|
|
||||||
void report_violation(const AuditViolation& violation);
|
void report_violation(const AuditViolation& violation);
|
||||||
@@ -90,8 +116,10 @@ private:
|
|||||||
static thread_local bool m_has_last_violation;
|
static thread_local bool m_has_last_violation;
|
||||||
static thread_local AuditViolation m_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<boost::filesystem::path> m_global_allowed_roots;
|
std::vector<boost::filesystem::path> m_global_allowed_roots;
|
||||||
|
std::vector<std::string> m_denied_filenames;
|
||||||
};
|
};
|
||||||
|
|
||||||
// RAII guard that sets the current plugin key and capability name, restoring the previous
|
// RAII guard that sets the current plugin key and capability name, restoring the previous
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ add_executable(${_TEST_NAME}_tests
|
|||||||
test_slicing_pipeline_config.cpp
|
test_slicing_pipeline_config.cpp
|
||||||
test_plugin_sort.cpp
|
test_plugin_sort.cpp
|
||||||
test_plugin_cloud_metadata.cpp
|
test_plugin_cloud_metadata.cpp
|
||||||
|
test_plugin_audit.cpp
|
||||||
../fff_print/test_helpers.cpp
|
../fff_print/test_helpers.cpp
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
187
tests/slic3rutils/test_plugin_audit.cpp
Normal file
187
tests/slic3rutils/test_plugin_audit.cpp
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
#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 "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);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // 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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user