mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-08-24 18:37:39 +00:00
fix: add resources folder to the allowed roots as readonly
This commit is contained in:
@@ -8,6 +8,7 @@
|
||||
#include <boost/log/trivial.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cstdlib>
|
||||
#include <future>
|
||||
#include <slic3r/GUI/BindDialog.hpp>
|
||||
@@ -111,6 +112,22 @@ static AuditEventCategory event_category(const std::string& event_name)
|
||||
return it == audit_event_categories.end() ? AuditEventCategory::None : it->second;
|
||||
}
|
||||
|
||||
// True for the categories whose targets are filesystem paths, as opposed to a network
|
||||
// address or a process command line -- the deny-path and allowed-root checks only make sense
|
||||
// against a path.
|
||||
static bool is_fs_category(AuditEventCategory category)
|
||||
{
|
||||
switch (category) {
|
||||
case AuditEventCategory::FsRead:
|
||||
case AuditEventCategory::FsReadWrite:
|
||||
case AuditEventCategory::FsCreate:
|
||||
case AuditEventCategory::FsDelete:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Path safety
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -179,7 +196,7 @@ bool is_inside_allowed_root(const boost::filesystem::path& candidate, const boos
|
||||
|
||||
thread_local std::string PluginAuditManager::m_current_plugin_key = "";
|
||||
thread_local std::string PluginAuditManager::m_current_capability_name = "";
|
||||
thread_local std::vector<boost::filesystem::path> PluginAuditManager::m_scoped_allowed_roots;
|
||||
thread_local std::vector<AllowedRoot> PluginAuditManager::m_scoped_allowed_roots;
|
||||
thread_local bool PluginAuditManager::m_audit_denial_pending = false;
|
||||
thread_local bool PluginAuditManager::m_has_last_violation = false;
|
||||
thread_local AuditViolation PluginAuditManager::m_last_violation;
|
||||
@@ -224,23 +241,24 @@ std::string PluginAuditManager::current_capability() const { return m_current_ca
|
||||
|
||||
void PluginAuditManager::clear_current_capability() { m_current_capability_name.clear(); }
|
||||
|
||||
void PluginAuditManager::add_global_allowed_root(const boost::filesystem::path& root)
|
||||
void PluginAuditManager::add_global_allowed_root(const boost::filesystem::path& root, bool allow_write)
|
||||
{
|
||||
if (root.empty())
|
||||
return;
|
||||
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
m_global_allowed_roots.push_back(root);
|
||||
BOOST_LOG_TRIVIAL(info) << "[AUDIT] Global allowed root: " << root.string();
|
||||
m_global_allowed_roots.push_back({root, allow_write});
|
||||
BOOST_LOG_TRIVIAL(info) << "[AUDIT] Global allowed root: " << root.string() << " allow_write=" << allow_write;
|
||||
}
|
||||
|
||||
void PluginAuditManager::add_scoped_allowed_root(const boost::filesystem::path& root)
|
||||
void PluginAuditManager::add_scoped_allowed_root(const boost::filesystem::path& root, bool allow_write)
|
||||
{
|
||||
if (root.empty())
|
||||
return;
|
||||
|
||||
m_scoped_allowed_roots.push_back(root);
|
||||
BOOST_LOG_TRIVIAL(info) << "[AUDIT] Scoped allowed root for plugin " << current_plugin() << ": " << root.string();
|
||||
m_scoped_allowed_roots.push_back({root, allow_write});
|
||||
BOOST_LOG_TRIVIAL(info) << "[AUDIT] Scoped allowed root for plugin " << current_plugin() << ": " << root.string()
|
||||
<< " allow_write=" << allow_write;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -293,6 +311,67 @@ bool PluginAuditManager::is_denied_filename(const boost::filesystem::path& candi
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Denied path keywords
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void PluginAuditManager::add_denied_path_keyword(const std::string& keyword)
|
||||
{
|
||||
if (keyword.empty())
|
||||
return;
|
||||
|
||||
std::string lower = keyword;
|
||||
std::transform(lower.begin(), lower.end(), lower.begin(), [](unsigned char c) { return std::tolower(c); });
|
||||
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
m_denied_path_keywords.push_back(lower);
|
||||
BOOST_LOG_TRIVIAL(info) << "[AUDIT] Denied path keyword: " << lower;
|
||||
}
|
||||
|
||||
std::vector<std::string> PluginAuditManager::default_denied_path_keywords()
|
||||
{
|
||||
// Broad, categorical rules on top of the exact-name is_denied_filename registry: a plugin
|
||||
// must never be able to reach a secret, a certificate, or a configuration file just because
|
||||
// it happens to live inside an otherwise-allowed root (e.g. the bundled TLS client cert at
|
||||
// resources_dir()/cert/..., which would become reachable the moment resources_dir() is
|
||||
// granted as a read-only allowed root).
|
||||
return {"secret", "cert", "conf"};
|
||||
}
|
||||
|
||||
bool PluginAuditManager::is_denied_path_keyword(const boost::filesystem::path& candidate) const
|
||||
{
|
||||
namespace fs = boost::filesystem;
|
||||
|
||||
boost::system::error_code ec;
|
||||
fs::path canon = fs::weakly_canonical(candidate, ec);
|
||||
if (ec) {
|
||||
canon = fs::absolute(candidate, ec).lexically_normal();
|
||||
if (ec)
|
||||
canon = candidate;
|
||||
}
|
||||
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
if (m_denied_path_keywords.empty())
|
||||
return false;
|
||||
|
||||
for (const auto& component : canon) {
|
||||
std::string name = component.string();
|
||||
if (name.empty())
|
||||
continue;
|
||||
std::transform(name.begin(), name.end(), name.begin(), [](unsigned char c) { return std::tolower(c); });
|
||||
for (const auto& keyword : m_denied_path_keywords) {
|
||||
if (name.find(keyword) != std::string::npos)
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool PluginAuditManager::is_denied_path(const boost::filesystem::path& candidate) const
|
||||
{
|
||||
return is_denied_filename(candidate) || is_denied_path_keyword(candidate);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Policy checks
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -306,14 +385,20 @@ AuditDecision PluginAuditManager::check_path_access(const boost::filesystem::pat
|
||||
if (plugin_key.empty())
|
||||
return {true, ""}; // not running inside a plugin context
|
||||
|
||||
// Denied filenames are checked before the allowed roots. The app config and the cloud
|
||||
// refresh token live directly inside data_dir(), which is a global allowed root, so a deny
|
||||
// placed any lower would be unreachable.
|
||||
// Denied filenames/keywords are checked before the allowed roots. The app config and the
|
||||
// cloud refresh token live directly inside data_dir(), which is a global allowed root, and
|
||||
// the bundled TLS client cert lives inside resources_dir(), a read-only global allowed
|
||||
// root, so a deny placed any lower would be unreachable.
|
||||
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"};
|
||||
}
|
||||
if (is_denied_path_keyword(path)) {
|
||||
BOOST_LOG_TRIVIAL(warning) << "[AUDIT] block path=" << path.string() << " is_write=" << is_write
|
||||
<< " plugin=" << plugin_key << " reason=denied path keyword";
|
||||
return {false, "denied path keyword"};
|
||||
}
|
||||
|
||||
namespace fs = boost::filesystem;
|
||||
fs::path candidate = path;
|
||||
@@ -326,8 +411,11 @@ AuditDecision PluginAuditManager::check_path_access(const boost::filesystem::pat
|
||||
candidate = absolute_candidate;
|
||||
}
|
||||
|
||||
// A root that doesn't allow writes only matches a read-shaped request; a write/create/
|
||||
// delete-shaped one falls through to "outside allowed root" for that root even though the
|
||||
// path is physically inside it.
|
||||
for (const auto& root : m_scoped_allowed_roots) {
|
||||
if (is_inside_allowed_root(candidate, root)) {
|
||||
if ((!is_write || root.allow_write) && is_inside_allowed_root(candidate, root.path)) {
|
||||
return {true, ""};
|
||||
}
|
||||
}
|
||||
@@ -335,7 +423,7 @@ AuditDecision PluginAuditManager::check_path_access(const boost::filesystem::pat
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
for (const auto& root : m_global_allowed_roots) {
|
||||
if (is_inside_allowed_root(candidate, root)) {
|
||||
if ((!is_write || root.allow_write) && is_inside_allowed_root(candidate, root.path)) {
|
||||
return {true, ""};
|
||||
}
|
||||
}
|
||||
@@ -799,6 +887,27 @@ int PluginAuditManager::audit_hook(const char* event, PyObject* args, void* user
|
||||
return 0;
|
||||
}
|
||||
|
||||
// the open function can take in different flags that determine if it is a read or readwrite.
|
||||
const AuditEventCategory event_type =
|
||||
event_name == "open" ? PluginAuditDetail::open_category(args) : event_category(event_name);
|
||||
if (event_type == AuditEventCategory::None)
|
||||
return 0;
|
||||
|
||||
const bool fs_category = is_fs_category(event_type);
|
||||
const std::vector<std::string> targets = PluginAuditDetail::audit_targets(event_name, event_type, args);
|
||||
|
||||
// A denied path (secrets, certificates, config files -- see is_denied_path) is an
|
||||
// unconditional block: checked before the ancestor-cascade check below, so a cascade
|
||||
// approval recorded for an unrelated action can never launder access to one, and before
|
||||
// the allowed-root shortcut further down, so an allowed root (e.g. the read-only resources
|
||||
// folder) cannot make a denied path underneath it reachable.
|
||||
if (fs_category) {
|
||||
for (const auto& target : targets) {
|
||||
if (mgr->is_denied_path(boost::filesystem::path(target)))
|
||||
return PluginAuditDetail::report_denied(*mgr, event_name, {false, "denied path"});
|
||||
}
|
||||
}
|
||||
|
||||
PluginDescriptor plugin_descriptor;
|
||||
PluginManager::instance().try_get_plugin_descriptor(mgr->current_plugin(), plugin_descriptor);
|
||||
|
||||
@@ -809,11 +918,20 @@ int PluginAuditManager::audit_hook(const char* event, PyObject* args, void* user
|
||||
if (mgr->has_approved_ancestor(mgr->current_plugin(), call_site_ids))
|
||||
return 0;
|
||||
|
||||
// the open function can take in different flags that determine if it is a read or readwrite.
|
||||
const AuditEventCategory event_type =
|
||||
event_name == "open" ? PluginAuditDetail::open_category(args) : event_category(event_name);
|
||||
if (event_type == AuditEventCategory::None)
|
||||
return 0;
|
||||
// A filesystem target that resolves entirely inside a pre-determined allowed root -- the
|
||||
// plugin system's own data_dir() tree (which holds each plugin's storage folder and the
|
||||
// installed/system profile cache), the read-only bundled resources folder, or a per-call
|
||||
// scoped root such as the current G-code folder -- is part of the plugin system's normal
|
||||
// workflow and does not need a prompt.
|
||||
if (fs_category && !targets.empty()) {
|
||||
const bool is_write = event_type != AuditEventCategory::FsRead;
|
||||
const bool all_inside_allowed_root =
|
||||
std::all_of(targets.begin(), targets.end(), [&](const std::string& target) {
|
||||
return mgr->check_path_access(boost::filesystem::path(target), is_write).allowed;
|
||||
});
|
||||
if (all_inside_allowed_root)
|
||||
return 0;
|
||||
}
|
||||
|
||||
PluginInstallState state;
|
||||
const bool have_install_state = PluginManager::instance().get_install_state(mgr->current_plugin(), state);
|
||||
@@ -823,8 +941,7 @@ int PluginAuditManager::audit_hook(const char* event, PyObject* args, void* user
|
||||
|
||||
const std::string plugin_name = state.plugin_name.empty() ? mgr->current_plugin() : state.plugin_name;
|
||||
|
||||
const std::vector<std::string> targets = PluginAuditDetail::audit_targets(event_name, event_type, args);
|
||||
std::vector<std::string>* permission_list = PluginAuditDetail::permission_list_for(event_type, state.permissions);
|
||||
std::vector<std::string>* permission_list = PluginAuditDetail::permission_list_for(event_type, state.permissions);
|
||||
|
||||
return PluginAuditDetail::decide_audited_event(*mgr, state, mgr->current_plugin(), plugin_name, event_name,
|
||||
event_type, targets, permission_list, call_site_ids);
|
||||
@@ -832,12 +949,20 @@ int PluginAuditManager::audit_hook(const char* event, PyObject* args, void* user
|
||||
|
||||
void PluginAuditManager::install_hook()
|
||||
{
|
||||
// data_dir() is the only globally-allowed root during plugin execution.
|
||||
// The executable directory and resources directory are intentionally NOT allowed
|
||||
// here: plugins must not access outside data_dir() (G-code plugins additionally get
|
||||
// the temp G-code folder via a scoped root).
|
||||
// data_dir() is the primary globally-allowed root during plugin execution: read+write. It
|
||||
// covers the plugin system's own workflow needs -- each plugin's storage folder
|
||||
// (data_dir()/orca_plugins) and the installed/system profile cache (data_dir()/system) --
|
||||
// without a separate, narrower grant for either (G-code plugins additionally get the temp
|
||||
// G-code folder via a scoped root, see SlicingPipelinePluginCapabilityTrampoline).
|
||||
add_global_allowed_root(data_dir());
|
||||
|
||||
// resources_dir() holds the app's bundled, shared assets (installed system profiles, the
|
||||
// bundled TLS client cert, web assets). Plugins may read from it -- e.g. inspecting bundled
|
||||
// profiles -- but must never write into the shared, potentially multi-user app install, so
|
||||
// it is granted read-only. The bundled cert itself stays unreachable regardless, via the
|
||||
// "cert" denied-path keyword seeded below.
|
||||
add_global_allowed_root(resources_dir(), /*allow_write=*/false);
|
||||
|
||||
// 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.
|
||||
//
|
||||
@@ -850,6 +975,13 @@ void PluginAuditManager::install_hook()
|
||||
for (const auto& name : default_denied_filenames())
|
||||
add_denied_filename(name);
|
||||
|
||||
// Categorical denies on top of the exact-name list above: no path a plugin can reach may
|
||||
// contain a "secret", "cert"(ificate), or "conf"(ig) path component, regardless of which
|
||||
// allowed root it happens to sit inside. default_denied_path_keywords() is the single
|
||||
// source of this list; the tests seed from it too.
|
||||
for (const auto& keyword : default_denied_path_keywords())
|
||||
add_denied_path_keyword(keyword);
|
||||
|
||||
if (PySys_AddAuditHook(audit_hook, this) < 0) {
|
||||
BOOST_LOG_TRIVIAL(error) << "[AUDIT] Failed to install CPython audit hook";
|
||||
return;
|
||||
|
||||
@@ -24,6 +24,14 @@ struct AuditViolation {
|
||||
std::string reason;
|
||||
};
|
||||
|
||||
// A filesystem root a plugin may access while an audit context is active. allow_write is
|
||||
// false for a root that only grants reads (e.g. the bundled, shared resources folder) --
|
||||
// a write-shaped event never matches such a root, even though a read-shaped one does.
|
||||
struct AllowedRoot {
|
||||
boost::filesystem::path path;
|
||||
bool allow_write = true;
|
||||
};
|
||||
|
||||
// The set of CPython audit events PluginAuditManager recognizes, grouped by the kind of
|
||||
// operation they represent. None means the event isn't one audit_hook() acts on at all.
|
||||
enum class AuditEventCategory {
|
||||
@@ -65,8 +73,11 @@ public:
|
||||
void clear_current_capability();
|
||||
|
||||
// --- allowed-roots registry ---
|
||||
void add_global_allowed_root(const boost::filesystem::path& root);
|
||||
void add_scoped_allowed_root(const boost::filesystem::path& root);
|
||||
// allow_write = false registers a read-only root: a read-shaped event inside it is allowed,
|
||||
// but a write/create/delete-shaped event is not, so it falls through to the normal
|
||||
// prompt-or-deny path instead.
|
||||
void add_global_allowed_root(const boost::filesystem::path& root, bool allow_write = true);
|
||||
void add_scoped_allowed_root(const boost::filesystem::path& root, bool allow_write = true);
|
||||
|
||||
// --- denied-filenames registry ---
|
||||
// Filenames a plugin may never touch, in any directory, regardless of the enclosing allowed
|
||||
@@ -89,10 +100,34 @@ public:
|
||||
// 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;
|
||||
|
||||
// --- denied-path-keyword registry ---
|
||||
// Keywords that categorically deny a path if ANY of its components (directory or file
|
||||
// name), not just the base name, contains one case-insensitively -- e.g. a "secrets"
|
||||
// subfolder, a "certificates" folder, or a "conf"/"config" file anywhere the plugin can
|
||||
// otherwise reach, including inside an allowed root. This is intentionally broader and
|
||||
// fuzzier than the exact-name is_denied_filename registry: it exists to categorically rule
|
||||
// out whole classes of sensitive paths (secrets, certificates, config) rather than name
|
||||
// specific known files, at the cost of over-blocking an unrelated name that happens to
|
||||
// contain the keyword -- the fail-safe direction, same rationale as is_denied_filename.
|
||||
void add_denied_path_keyword(const std::string& keyword);
|
||||
|
||||
// The list install_hook() seeds into the keyword registry. Exposed so tests seed the exact
|
||||
// same set without a live interpreter.
|
||||
static std::vector<std::string> default_denied_path_keywords();
|
||||
|
||||
// True when any component of candidate's (canonicalized) path contains a registered
|
||||
// keyword, case-insensitively.
|
||||
bool is_denied_path_keyword(const boost::filesystem::path& candidate) const;
|
||||
|
||||
// is_denied_filename(candidate) || is_denied_path_keyword(candidate). Convenience for
|
||||
// call sites that only need to know whether a path is categorically off-limits, not which
|
||||
// specific rule fired.
|
||||
bool is_denied_path(const boost::filesystem::path& candidate) const;
|
||||
|
||||
// --- policy checks ---
|
||||
// Shared core for every audited filesystem event. The deny list is consulted above the
|
||||
// allowed roots, so a denied filename is blocked even when the file sits inside data_dir(),
|
||||
// which is itself a global allowed root.
|
||||
// Shared core for every audited filesystem event. The deny checks are consulted above the
|
||||
// allowed roots, so a denied path is blocked even when it sits inside an allowed root (e.g.
|
||||
// data_dir(), which is 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);
|
||||
|
||||
@@ -130,15 +165,16 @@ private:
|
||||
|
||||
static thread_local std::string m_current_plugin_key;
|
||||
static thread_local std::string m_current_capability_name;
|
||||
static thread_local std::vector<boost::filesystem::path> m_scoped_allowed_roots;
|
||||
static thread_local std::vector<AllowedRoot> m_scoped_allowed_roots;
|
||||
static thread_local bool m_audit_denial_pending;
|
||||
static thread_local bool m_has_last_violation;
|
||||
static thread_local AuditViolation m_last_violation;
|
||||
|
||||
// mutable: is_denied_filename() and has_approved_ancestor() are const queries that must lock.
|
||||
mutable std::mutex m_mutex;
|
||||
std::vector<boost::filesystem::path> m_global_allowed_roots;
|
||||
std::vector<AllowedRoot> m_global_allowed_roots;
|
||||
std::vector<std::string> m_denied_filenames;
|
||||
std::vector<std::string> m_denied_path_keywords;
|
||||
std::unordered_map<std::string, std::unordered_set<std::string>> m_approved_call_sites; // plugin_key -> call-site ids
|
||||
};
|
||||
|
||||
@@ -160,7 +196,7 @@ public:
|
||||
private:
|
||||
std::string m_previous_id;
|
||||
std::string m_previous_capability;
|
||||
std::vector<boost::filesystem::path> m_previous_scoped_roots;
|
||||
std::vector<AllowedRoot> m_previous_scoped_roots;
|
||||
};
|
||||
|
||||
} // namespace Slic3r
|
||||
|
||||
@@ -34,4 +34,24 @@ struct ScopedDataDir
|
||||
ScopedDataDir& operator=(const ScopedDataDir&) = delete;
|
||||
};
|
||||
|
||||
// Point resources_dir() at a throwaway directory for the lifetime of a test and restore the
|
||||
// previous value afterwards, mirroring ScopedDataDir.
|
||||
struct ScopedResourcesDir
|
||||
{
|
||||
ScopedTemporaryDir tmp;
|
||||
boost::filesystem::path dir;
|
||||
std::string previous;
|
||||
|
||||
explicit ScopedResourcesDir(const std::string& tag)
|
||||
: tmp("orca-" + tag), dir(tmp.path()), previous(resources_dir())
|
||||
{
|
||||
set_resources_dir(dir.string());
|
||||
}
|
||||
|
||||
~ScopedResourcesDir() { set_resources_dir(previous); }
|
||||
|
||||
ScopedResourcesDir(const ScopedResourcesDir&) = delete;
|
||||
ScopedResourcesDir& operator=(const ScopedResourcesDir&) = delete;
|
||||
};
|
||||
|
||||
} // namespace Slic3r
|
||||
|
||||
@@ -27,6 +27,16 @@ void seed_denied_names()
|
||||
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]")
|
||||
@@ -190,3 +200,139 @@ TEST_CASE("Plugin audit does not constrain non-plugin code", "[audit]")
|
||||
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 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")));
|
||||
}
|
||||
|
||||
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/CertBundle/ca.pem")));
|
||||
CHECK(mgr.is_denied_path_keyword(fs::path("/plugin/CONFIG.JSON")));
|
||||
}
|
||||
|
||||
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")));
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user