Compare commits

...
Author SHA1 Message Date
Hanif Koh 721a3eb8bb Judge Plugin Audit Keywords Only Below the Allowed Root
The audit hook denies any path with a component containing secret, cert
or conf, before the allowed roots are considered. On Linux the data
directory is ~/.config/OrcaSlicer, so every file a plugin touched under
its own data folder from a plugin callback was denied. Judge only the
components below the deepest allowed root that holds the path: the
root's own components are the host's choice, while a keyword below it
still denies, which keeps the bundled certificate folder unreachable.
2026-10-08 14:46:54 +08:00
2 changed files with 80 additions and 27 deletions
+42 -27
View File
@@ -11,6 +11,7 @@
#include <boost/log/trivial.hpp>
#include <algorithm>
#include <iterator>
#include <cctype>
#include <cstdlib>
#include <future>
@@ -144,29 +145,28 @@ static bool is_fs_category(AuditEventCategory category)
// Path safety
// ---------------------------------------------------------------------------
// weakly_canonical resolves symlinks but does NOT require the path to exist: it canonicalizes
// the prefix that exists and appends the non-existing tail lexically. Falls back to an absolute,
// lexically normal path when even that fails.
static boost::filesystem::path canonical_or_normal(const boost::filesystem::path& path)
{
namespace fs = boost::filesystem;
boost::system::error_code ec;
fs::path canon = fs::weakly_canonical(path, ec);
if (ec) {
canon = fs::absolute(path, ec).lexically_normal();
if (ec)
canon = path;
}
return canon;
}
bool is_inside_allowed_root(const boost::filesystem::path& candidate, const boost::filesystem::path& allowed_root)
{
namespace fs = boost::filesystem;
boost::system::error_code ec;
// Canonicalize both paths. weakly_canonical resolves symlinks but does
// NOT require the path to exist — it canonicalizes the prefix that exists
// and appends the non-existing tail lexically.
fs::path canon_candidate = fs::weakly_canonical(candidate, ec);
if (ec) {
// Fall back to lexically_normal + absolute
canon_candidate = fs::absolute(candidate, ec).lexically_normal();
if (ec)
canon_candidate = candidate;
}
fs::path canon_root = fs::weakly_canonical(allowed_root, ec);
if (ec) {
canon_root = fs::absolute(allowed_root, ec).lexically_normal();
if (ec)
canon_root = allowed_root;
}
fs::path canon_candidate = canonical_or_normal(candidate);
fs::path canon_root = canonical_or_normal(allowed_root);
// Component-wise comparison: the root must be a prefix of candidate,
// and the next component must not be ".." or missing.
@@ -354,19 +354,34 @@ bool PluginAuditManager::is_denied_path_keyword(const boost::filesystem::path& c
{
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;
}
fs::path canon = canonical_or_normal(candidate);
std::lock_guard<std::mutex> lock(m_mutex);
if (m_denied_path_keywords.empty())
return false;
for (const auto& component : canon) {
// Only the part of the path the plugin chose is judged. The components of the allowed root it
// sits in are the host's (on Linux the data directory is ~/.config/OrcaSlicer), so a keyword
// there must not deny everything underneath; a keyword below the root still does, which keeps
// resources_dir()/cert/... unreachable.
fs::path below = canon;
size_t depth = 0;
auto consider = [&](const AllowedRoot& root) {
if (!is_inside_allowed_root(canon, root.path))
return;
const fs::path canon_root = canonical_or_normal(root.path);
const size_t root_depth = static_cast<size_t>(std::distance(canon_root.begin(), canon_root.end()));
if (root_depth > depth) {
depth = root_depth;
below = canon.lexically_relative(canon_root);
}
};
for (const auto& root : m_scoped_allowed_roots)
consider(root);
for (const auto& root : m_global_allowed_roots)
consider(root);
for (const auto& component : below) {
std::string name = component.string();
if (name.empty())
continue;
+38
View File
@@ -253,6 +253,44 @@ TEST_CASE("Plugin audit denies secret/certificate/config-like paths by keyword",
}
}
TEST_CASE("Plugin audit keyword deny ignores the allowed root's own path", "[audit]")
{
seed_denied_names();
seed_denied_keywords();
PluginAuditManager& mgr = PluginAuditManager::instance();
// The data directory of a Linux install: its own path carries the "conf" keyword.
const fs::path root = fs::temp_directory_path() / "plugin-audit-xdg" / ".config" / "OrcaSlicer";
mgr.add_global_allowed_root(root.string());
SECTION("a plain file below the root is not denied by the root's components")
{
CHECK_FALSE(mgr.is_denied_path_keyword(root / "log" / "debug.log"));
CHECK_FALSE(mgr.is_denied_path_keyword(root / "orca_plugins" / "plugin_data" / "key" / "state.json"));
}
SECTION("a keyword below the root still denies")
{
CHECK(mgr.is_denied_path_keyword(root / "cert" / "ca.pem"));
CHECK(mgr.is_denied_path_keyword(root / "plugin" / "secrets" / "token.txt"));
CHECK(mgr.is_denied_path_keyword(root / "plugin.conf"));
}
SECTION("a keyword in a path outside every root still denies")
{
CHECK(mgr.is_denied_path_keyword(fs::path("/elsewhere/.config/app/file.txt")));
}
SECTION("inside a plugin context the log is readable and the app config stays blocked")
{
ScopedPluginAuditContext ctx("test_plugin", "");
CHECK(mgr.check_open((root / "log" / "debug.log").string(), "r").allowed);
AuditDecision decision = mgr.check_open((root / (SLIC3R_APP_KEY ".conf")).string(), "r");
CHECK_FALSE(decision.allowed);
CHECK(decision.reason == "denied filename");
}
}
TEST_CASE("Plugin audit is_denied_path combines the filename and keyword registries", "[audit]")
{
seed_denied_names();