Compare commits

..
Author SHA1 Message Date
Ian Chua b0c15879de fix: flatpak audit denial because of config in path 2026-10-20 14:51:28 +08:00
8 changed files with 250 additions and 619 deletions

No files matched your search

+3 -25
View File
@@ -47,7 +47,7 @@ while getopts ":dpa:snt:xbc:i:j:Tuh" opt; do
u ) u )
export BUILD_TARGET="universal" export BUILD_TARGET="universal"
;; ;;
h ) echo "Usage: ./build_release_macos.sh [-d] [-- CMAKE_ARGS...]" h ) echo "Usage: ./build_release_macos.sh [-d]"
echo " -d: Build deps only" echo " -d: Build deps only"
echo " -a: Set ARCHITECTURE (arm64 or x86_64 or universal)" echo " -a: Set ARCHITECTURE (arm64 or x86_64 or universal)"
echo " -s: Build slicer only" echo " -s: Build slicer only"
@@ -60,33 +60,13 @@ while getopts ":dpa:snt:xbc:i:j:Tuh" opt; do
echo " -i: Add a prefix to ignore during CMake dependency discovery (repeatable), defaults to /opt/local:/usr/local:/opt/homebrew" echo " -i: Add a prefix to ignore during CMake dependency discovery (repeatable), defaults to /opt/local:/usr/local:/opt/homebrew"
echo " -j: Set the number of parallel build jobs (CMAKE_BUILD_PARALLEL_LEVEL)" echo " -j: Set the number of parallel build jobs (CMAKE_BUILD_PARALLEL_LEVEL)"
echo " -T: Build and run tests (set ORCA_TESTS_BUILD_ONLY=1 to build without running)" echo " -T: Build and run tests (set ORCA_TESTS_BUILD_ONLY=1 to build without running)"
echo " -- CMAKE_ARGS...: pass everything after '--' to the cmake configure step of the deps (-d) and slicer (-s) builds,"
echo " e.g. -DDESTDIR=<dir> for the deps, -DCMAKE_PREFIX_PATH=<dir>/usr/local -DSLIC3R_PCH=OFF for the slicer, --fresh."
echo " They come last on the cmake command line. Use absolute paths: cmake runs in the build directory."
exit 0 exit 0
;; ;;
: )
echo "Option -${OPTARG} requires an argument, aborting."
exit 1
;;
* ) * )
echo "Unknown option -${OPTARG}, aborting. CMake arguments must follow '--'."
exit 1
;; ;;
esac esac
done done
# getopts stops at '--'; whatever follows is handed to cmake unchanged.
if [[ ${OPTIND} -le $# ]] ; then
separator_index=$((OPTIND-1))
if [[ "${!separator_index}" != "--" ]] ; then
echo "Unexpected argument '${!OPTIND}': cmake arguments must follow '--', aborting."
exit 1
fi
fi
shift $((OPTIND-1))
EXTRA_CMAKE_ARGS=("$@")
# Set defaults # Set defaults
if [ -z "$ARCH" ]; then if [ -z "$ARCH" ]; then
@@ -231,8 +211,7 @@ function build_deps() {
-DCMAKE_OSX_DEPLOYMENT_TARGET="${OSX_DEPLOYMENT_TARGET}" \ -DCMAKE_OSX_DEPLOYMENT_TARGET="${OSX_DEPLOYMENT_TARGET}" \
-DCMAKE_IGNORE_PREFIX_PATH="${CMAKE_IGNORE_PREFIX_PATH}" \ -DCMAKE_IGNORE_PREFIX_PATH="${CMAKE_IGNORE_PREFIX_PATH}" \
"${CMAKE_CCACHE_ARGS[@]}" \ "${CMAKE_CCACHE_ARGS[@]}" \
${CMAKE_POLICY_COMPAT} \ ${CMAKE_POLICY_COMPAT}
"${EXTRA_CMAKE_ARGS[@]}"
fi fi
cmake --build . --config "$BUILD_CONFIG" --target deps cmake --build . --config "$BUILD_CONFIG" --target deps
) )
@@ -335,8 +314,7 @@ function build_slicer() {
-DCMAKE_OSX_DEPLOYMENT_TARGET="${OSX_DEPLOYMENT_TARGET}" \ -DCMAKE_OSX_DEPLOYMENT_TARGET="${OSX_DEPLOYMENT_TARGET}" \
-DCMAKE_IGNORE_PREFIX_PATH="${CMAKE_IGNORE_PREFIX_PATH}" \ -DCMAKE_IGNORE_PREFIX_PATH="${CMAKE_IGNORE_PREFIX_PATH}" \
"${CMAKE_CCACHE_ARGS[@]}" \ "${CMAKE_CCACHE_ARGS[@]}" \
${CMAKE_POLICY_COMPAT} \ ${CMAKE_POLICY_COMPAT}
"${EXTRA_CMAKE_ARGS[@]}"
fi fi
cmake --build . --config "$BUILD_CONFIG" --target "$SLICER_BUILD_TARGET" cmake --build . --config "$BUILD_CONFIG" --target "$SLICER_BUILD_TARGET"
) )
+147 -376
View File
@@ -5,13 +5,10 @@
#include "libslic3r/libslic3r.h" // GCODEVIEWER_APP_KEY, and SLIC3R_APP_KEY via libslic3r_version.h #include "libslic3r/libslic3r.h" // GCODEVIEWER_APP_KEY, and SLIC3R_APP_KEY via libslic3r_version.h
#include "libslic3r_version.h" #include "libslic3r_version.h"
#include <boost/algorithm/string/case_conv.hpp>
#include <boost/algorithm/string/predicate.hpp> #include <boost/algorithm/string/predicate.hpp>
#include <boost/algorithm/string/trim.hpp>
#include <boost/filesystem/path.hpp> #include <boost/filesystem/path.hpp>
#include <boost/filesystem/operations.hpp> #include <boost/filesystem/operations.hpp>
#include <boost/log/trivial.hpp> #include <boost/log/trivial.hpp>
#include <boost/system/error_code.hpp>
#include <algorithm> #include <algorithm>
#include <cassert> #include <cassert>
@@ -164,180 +161,6 @@ static bool is_fs_category(AuditEventCategory category)
} }
} }
// HTTP and raw-socket events are governed by the same network permission: a declared host may be
// reached through either layer, and the layers nest for one plugin action (urllib -> http.client
// -> socket).
static bool is_network_category(AuditEventCategory category)
{
return category == AuditEventCategory::Http || category == AuditEventCategory::Socket;
}
// ---------------------------------------------------------------------------
// Network permission identity
// ---------------------------------------------------------------------------
static std::string ascii_lower(std::string value)
{
boost::algorithm::to_lower(value);
return value;
}
static std::string trim_ascii_space(std::string value)
{
boost::algorithm::trim(value);
return value;
}
// Canonical storage keeps IPv6 literals bracketed (https://[::1]), while a socket address
// exposes the bare literal (::1). Compare them without the brackets.
static std::string without_ipv6_brackets(std::string host)
{
if (host.size() >= 2 && host.front() == '[' && host.back() == ']')
host = host.substr(1, host.size() - 2);
return host;
}
// Returns the host part of a URL authority, dropping userinfo and port. IPv6 literals keep their
// brackets so that a URL and a bare address compare consistently.
static std::string host_from_authority(std::string authority)
{
const size_t at = authority.rfind('@');
if (at != std::string::npos)
authority.erase(0, at + 1);
if (authority.empty())
return {};
if (authority.front() == '[') {
const size_t close = authority.find(']');
if (close == std::string::npos)
return {};
authority.erase(close + 1);
return ascii_lower(authority);
}
const size_t colon = authority.find(':');
if (colon != std::string::npos)
authority.erase(colon);
if (authority.find_first_of(" \t\r\n/?#@") != std::string::npos)
return {};
return ascii_lower(authority);
}
// Extracts a host from a target that is not a URL: "host", "host:443", "[::1]:443", "('host',
// 443)" (the form older releases persisted for socket address events), or "'host'".
static std::string network_host_from_target(const std::string& target)
{
std::string value = trim_ascii_space(target);
if (value.empty())
return {};
if (value.front() == '(') {
// Legacy persisted address tuple: ("host", 443) or ("host", 443, 0, 0).
const size_t quote = value.find_first_of("'\"");
if (quote != std::string::npos) {
const char delimiter = value[quote];
const size_t end = value.find(delimiter, quote + 1);
if (end == std::string::npos)
return {};
value = value.substr(quote + 1, end - quote - 1);
} else {
value = value.substr(1);
const size_t comma = value.find(',');
if (comma != std::string::npos)
value.erase(comma);
}
} else if (value.front() == '[') {
const size_t close = value.find(']');
if (close == std::string::npos)
return {};
value.erase(close + 1);
} else {
// Strip a ":port" suffix, but leave a bare IPv6 literal ("::1") intact.
const size_t colon = value.find(':');
if (colon != std::string::npos && value.find(':', colon + 1) == std::string::npos)
value.erase(colon);
}
value = trim_ascii_space(value);
if (value.empty() || value.find_first_of(" \t\r\n/?#@") != std::string::npos)
return {};
return ascii_lower(value);
}
std::string PluginAuditManager::normalize_network_url(const std::string& url)
{
const size_t scheme_end = url.find("://");
if (scheme_end == std::string::npos || scheme_end == 0)
return {};
const std::string scheme = ascii_lower(url.substr(0, scheme_end));
if (scheme != "http" && scheme != "https")
return {};
const size_t authority_start = scheme_end + 3;
const size_t authority_end = url.find_first_of("/?#", authority_start);
const std::string authority = url.substr(authority_start, authority_end == std::string::npos
? std::string::npos
: authority_end - authority_start);
const std::string host = host_from_authority(authority);
if (host.empty())
return {};
return scheme + "://" + host;
}
bool PluginAuditManager::network_permission_matches(const std::vector<std::string>& granted,
const std::string& target)
{
if (target.empty())
return false;
const bool target_is_url = target.find("://") != std::string::npos;
const std::string target_url = normalize_network_url(target);
if (target_is_url && target_url.empty()) {
// A non-http(s) URL has no network identity we can generalize; only an exact grant
// recorded by an earlier approval stays valid, so an unrelated scheme never matches.
return std::find(granted.begin(), granted.end(), target) != granted.end();
}
const std::string target_host =
target_is_url ? std::string{} : without_ipv6_brackets(network_host_from_target(target));
if (!target_is_url && target_host.empty())
return false;
for (const std::string& entry : granted) {
// Preserve an exact grant recorded by an older release, e.g. a full URL with a query.
if (entry == target)
return true;
const std::string entry_url = normalize_network_url(entry);
if (target_is_url) {
// Canonical scheme://host grants are host-wide. Older grants that persisted a full
// URL remain scoped to that exact URL, preserving the operation-root URL check.
if (!entry_url.empty() && entry_url == entry && entry_url == target_url)
return true;
continue;
}
if (!entry_url.empty()) {
// Bare hosts carry no scheme: match the host part of a scheme://host grant.
const std::string entry_host = entry_url.substr(entry_url.find("://") + 3);
if (without_ipv6_brackets(entry_host) == target_host)
return true;
} else if (without_ipv6_brackets(network_host_from_target(entry)) == target_host) {
// Legacy persisted address tuples, e.g. "('host', 443)".
return true;
}
}
return false;
}
// The category's name in a saved event grant, e.g. "FsRead" in "FsRead:open". // The category's name in a saved event grant, e.g. "FsRead" in "FsRead:open".
static const char* category_name(AuditEventCategory category) static const char* category_name(AuditEventCategory category)
{ {
@@ -360,47 +183,52 @@ static const char* category_name(AuditEventCategory category)
// Path safety // Path safety
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
bool is_inside_allowed_root(const boost::filesystem::path& candidate, const boost::filesystem::path& allowed_root) namespace {
// Canonicalize a path for containment comparison. 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.
boost::filesystem::path canonical_for_comparison(const boost::filesystem::path& path)
{ {
namespace fs = boost::filesystem; namespace fs = boost::filesystem;
boost::system::error_code ec; boost::system::error_code ec;
fs::path canon = fs::weakly_canonical(path, 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) { if (ec) {
// Fall back to lexically_normal + absolute // Fall back to lexically_normal + absolute
canon_candidate = fs::absolute(candidate, ec).lexically_normal(); canon = fs::absolute(path, ec).lexically_normal();
if (ec) if (ec)
canon_candidate = candidate; canon = path;
} }
return canon;
}
fs::path canon_root = fs::weakly_canonical(allowed_root, ec); bool same_path_component(const boost::filesystem::path& lhs, const boost::filesystem::path& rhs)
if (ec) { {
canon_root = fs::absolute(allowed_root, ec).lexically_normal();
if (ec)
canon_root = allowed_root;
}
// Component-wise comparison: the root must be a prefix of candidate,
// and the next component must not be ".." or missing.
auto cand_it = canon_candidate.begin();
auto cand_end = canon_candidate.end();
auto root_it = canon_root.begin();
auto root_end = canon_root.end();
const auto same_component = [](const fs::path& lhs, const fs::path& rhs) {
#ifdef _WIN32 #ifdef _WIN32
return boost::algorithm::iequals(lhs.native(), rhs.native()); return boost::algorithm::iequals(lhs.native(), rhs.native());
#else #else
return lhs == rhs; return lhs == rhs;
#endif #endif
}; }
// Component-wise containment: the whole of canonical_root must be a prefix of
// canonical_candidate, and the remaining components must not traverse upward. Both arguments are
// expected to come from canonical_for_comparison(). On a match, suffix receives the candidate's
// components below the root (empty when the two paths are equal).
bool suffix_inside_root(const boost::filesystem::path& canonical_candidate,
const boost::filesystem::path& canonical_root,
boost::filesystem::path& suffix)
{
suffix.clear();
auto cand_it = canonical_candidate.begin();
auto cand_end = canonical_candidate.end();
auto root_it = canonical_root.begin();
auto root_end = canonical_root.end();
// Consume matching components // Consume matching components
while (root_it != root_end && cand_it != cand_end && same_component(*root_it, *cand_it)) { while (root_it != root_end && cand_it != cand_end && same_path_component(*root_it, *cand_it)) {
++root_it; ++root_it;
++cand_it; ++cand_it;
} }
@@ -415,9 +243,19 @@ bool is_inside_allowed_root(const boost::filesystem::path& candidate, const boos
return false; return false;
} }
for (; cand_it != cand_end; ++cand_it)
suffix /= *cand_it;
return true; return true;
} }
} // namespace
bool is_inside_allowed_root(const boost::filesystem::path& candidate, const boost::filesystem::path& allowed_root)
{
boost::filesystem::path suffix;
return suffix_inside_root(canonical_for_comparison(candidate), canonical_for_comparison(allowed_root), suffix);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// ScopedPluginAuditContext // ScopedPluginAuditContext
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -587,7 +425,8 @@ std::vector<std::string> PluginAuditManager::default_denied_path_keywords()
// it happens to live inside an otherwise-allowed root (e.g. the bundled TLS client cert at // 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 // resources_dir()/cert/..., which would become reachable the moment resources_dir() is
// granted as a read-only allowed root). Match as whole path components, not substrings, so // granted as a read-only allowed root). Match as whole path components, not substrings, so
// imports such as numpy/__config__.py and stdlib configparser.py remain usable. // imports such as numpy/__config__.py and stdlib configparser.py remain usable. Inside
// data_dir() they match below that root only; see is_denied_path_keyword().
return {"secret", "secrets", "cert", "certs", "certificate", "certificates", "conf", "config"}; return {"secret", "secrets", "cert", "certs", "certificate", "certificates", "conf", "config"};
} }
@@ -606,19 +445,29 @@ bool PluginAuditManager::is_denied_path_keyword(const boost::filesystem::path& c
{ {
namespace fs = boost::filesystem; namespace fs = boost::filesystem;
boost::system::error_code ec; const fs::path canon = canonical_for_comparison(candidate);
fs::path canon = fs::weakly_canonical(candidate, ec);
if (ec) { // The app's data folder can itself sit below a component named like one of the keywords
canon = fs::absolute(candidate, ec).lexically_normal(); // without being one: Flatpak keeps it under <home>/.var/app/<app-id>/config/OrcaSlicer, and
if (ec) // the sandbox's home layout is not ours to change. A keyword above data_dir() therefore says
canon = candidate; // nothing about what a plugin may reach inside it, so inside data_dir() the keywords apply to
} // the suffix below data_dir() only. Everywhere else -- resources_dir() with the bundled
// certificates, a plugin's own folders, arbitrary files -- the whole path is still checked,
// and so is a keyword under data_dir() itself.
fs::path canonical_data_dir;
if (!data_dir().empty())
canonical_data_dir = canonical_for_comparison(data_dir());
fs::path scope = canon;
fs::path suffix;
if (!canonical_data_dir.empty() && suffix_inside_root(canon, canonical_data_dir, suffix))
scope = suffix;
std::lock_guard<std::mutex> lock(m_mutex); std::lock_guard<std::mutex> lock(m_mutex);
if (m_denied_path_keywords.empty()) if (m_denied_path_keywords.empty())
return false; return false;
for (const auto& component : canon) { for (const auto& component : scope) {
std::string name = component.string(); std::string name = component.string();
if (name.empty()) if (name.empty())
continue; continue;
@@ -705,39 +554,6 @@ AuditDecision PluginAuditManager::check_open(const std::string& path_str, const
return check_path_access(boost::filesystem::path(path_str), is_write); return check_path_access(boost::filesystem::path(path_str), is_write);
} }
namespace {
// Builds and shows the modal confirmation. Must run on the GUI thread.
bool show_permission_dialog(const wxString& message, const wxString& title)
{
wxMessageDialog dialog(nullptr, message, title, wxYES_NO | wxICON_WARNING);
return dialog.ShowModal() == wxID_YES;
}
// Shows a permission prompt on the GUI thread and waits for the answer. The caller may be the
// plugin-load worker, so off the main thread the dialog is marshaled with CallAfter.
bool ask_permission(const wxString& message)
{
if (wxTheApp == nullptr || GUI::wxGetApp().is_closing())
return false;
if (wxIsMainThread())
return show_permission_dialog(message, _L("Plugin permissions"));
auto result = std::make_shared<std::promise<bool>>();
auto future = result->get_future();
GUI::wxGetApp().CallAfter([result, message]() {
if (wxTheApp == nullptr || GUI::wxGetApp().is_closing()) {
result->set_value(false);
return;
}
result->set_value(show_permission_dialog(message, _L("Plugin permissions")));
});
return future.get();
}
} // namespace
bool PluginAuditManager::request_filesystem_read_permissions(const std::string& plugin_key, bool PluginAuditManager::request_filesystem_read_permissions(const std::string& plugin_key,
const std::vector<std::string>& paths) const std::vector<std::string>& paths)
{ {
@@ -759,80 +575,60 @@ bool PluginAuditManager::request_filesystem_read_permissions(const std::string&
if (missing.empty()) if (missing.empty())
return true; return true;
wxString requested_paths; if (wxTheApp == nullptr || GUI::wxGetApp().is_closing())
for (const std::string& path : missing)
requested_paths += wxString::FromUTF8(path.c_str()) + "\n";
if (!ask_permission(wxString::Format(_L("Plugin \"%s\" requests filesystem read access to:\n%s"),
wxString::FromUTF8(descriptor.name.c_str()), requested_paths)))
return false; return false;
seed_install_metadata(state, descriptor); auto show_dialog = [&descriptor, &missing]() {
wxString requested_paths;
for (const std::string& path : missing)
requested_paths += wxString::FromUTF8(path.c_str()) + "\n";
wxMessageDialog dialog(
nullptr,
wxString::Format(_L("Plugin \"%s\" requests filesystem read access to:\n%s"),
wxString::FromUTF8(descriptor.name.c_str()), requested_paths),
_L("Plugin permissions"),
wxYES_NO | wxICON_WARNING);
return dialog.ShowModal() == wxID_YES;
};
bool granted = false;
if (wxIsMainThread()) {
granted = show_dialog();
} else {
auto result = std::make_shared<std::promise<bool>>();
auto future = result->get_future();
GUI::wxGetApp().CallAfter([result, descriptor_name = descriptor.name, missing]() {
wxString requested_paths;
for (const std::string& path : missing)
requested_paths += wxString::FromUTF8(path.c_str()) + "\n";
wxMessageDialog dialog(
nullptr,
wxString::Format(_L("Plugin \"%s\" requests filesystem read access to:\n%s"),
wxString::FromUTF8(descriptor_name.c_str()), requested_paths),
_L("Plugin permissions"),
wxYES_NO | wxICON_WARNING);
result->set_value(dialog.ShowModal() == wxID_YES);
});
granted = future.get();
}
if (!granted)
return false;
if (state.plugin_name.empty()) {
state.installed_from = descriptor.is_cloud_plugin() ? "cloud" : "local";
state.installed_version = !descriptor.installed_version.empty() ? descriptor.installed_version : descriptor.version;
state.plugin_name = descriptor.name;
state.cloud_uuid = descriptor.cloud_uuid();
state.enabled = true;
}
state.permissions.fs_read.insert(state.permissions.fs_read.end(), missing.begin(), missing.end()); state.permissions.fs_read.insert(state.permissions.fs_read.end(), missing.begin(), missing.end());
return write_install_state(boost::filesystem::path(descriptor.plugin_root), state); return write_install_state(boost::filesystem::path(descriptor.plugin_root), state);
} }
bool PluginAuditManager::request_network_permissions(const std::string& plugin_key,
const std::vector<std::string>& urls)
{
if (plugin_key.empty() || urls.empty())
return true;
PluginDescriptor descriptor;
if (!PluginManager::instance().try_get_plugin_descriptor(plugin_key, descriptor) || descriptor.plugin_root.empty())
return false;
// Reduce every request to scheme://host before comparing or persisting it, so a URL with a
// path or query cannot create a second grant for the same host.
std::vector<std::string> requested;
for (const std::string& url : urls) {
const std::string normalized = normalize_network_url(url);
if (normalized.empty()) {
BOOST_LOG_TRIVIAL(warning) << "[AUDIT] Invalid network permission request for plugin " << plugin_key << ": " << url;
return false;
}
if (std::find(requested.begin(), requested.end(), normalized) == requested.end())
requested.push_back(normalized);
}
PluginInstallState state;
read_install_state(boost::filesystem::path(descriptor.plugin_root), state);
std::vector<std::string> missing;
for (const std::string& entry : requested) {
if (!network_permission_matches(state.permissions.network_http, entry) &&
!network_permission_matches(state.permissions.network_socket, entry))
missing.push_back(entry);
}
if (missing.empty())
return true;
wxString requested_hosts;
for (const std::string& entry : missing)
requested_hosts += wxString::FromUTF8(entry.c_str()) + "\n";
if (!ask_permission(wxString::Format(_L("Plugin \"%s\" requests network access to:\n%s"),
wxString::FromUTF8(descriptor.name.c_str()), requested_hosts)))
return false;
seed_install_metadata(state, descriptor);
// A declared host is reachable through both HTTP and raw socket events. Recording the grant in
// both existing lists keeps the sidecar self-describing and means either event layer matches
// it without relying on the other list.
for (const std::string& entry : missing) {
if (std::find(state.permissions.network_http.begin(), state.permissions.network_http.end(), entry) ==
state.permissions.network_http.end())
state.permissions.network_http.push_back(entry);
if (std::find(state.permissions.network_socket.begin(), state.permissions.network_socket.end(), entry) ==
state.permissions.network_socket.end())
state.permissions.network_socket.push_back(entry);
}
return write_install_state(boost::filesystem::path(descriptor.plugin_root), state);
}
void PluginAuditManager::report_violation(const AuditViolation& violation) void PluginAuditManager::report_violation(const AuditViolation& violation)
{ {
m_last_violation = violation; m_last_violation = violation;
@@ -875,39 +671,6 @@ PyObject* tuple_item(PyObject* args, Py_ssize_t index)
return PyTuple_GET_ITEM(args, index); return PyTuple_GET_ITEM(args, index);
} }
std::string python_unicode(PyObject* object)
{
if (!object || !PyUnicode_Check(object))
return {};
const char* text = PyUnicode_AsUTF8(object);
if (!text) {
PyErr_Clear();
return {};
}
return text;
}
// Converts a str/bytes leaf to text, clearing a failed conversion. Returns false when `object` is
// neither str nor bytes, so callers can apply their own fallback.
bool python_text(PyObject* object, std::string& out)
{
if (PyUnicode_Check(object)) {
out = python_unicode(object);
return true;
}
if (PyBytes_Check(object)) {
const char* value = PyBytes_AsString(object);
if (!value) {
PyErr_Clear();
out.clear();
return true;
}
out = value;
return true;
}
return false;
}
std::string python_path(PyObject* object) std::string python_path(PyObject* object)
{ {
if (!object) if (!object)
@@ -919,12 +682,26 @@ std::string python_path(PyObject* object)
return {}; return {};
} }
std::string result; const char* path = PyUnicode_Check(path_object) ? PyUnicode_AsUTF8(path_object) : PyBytes_AsString(path_object);
python_text(path_object, result); std::string result = path ? path : "";
Py_DECREF(path_object); Py_DECREF(path_object);
if (!path)
PyErr_Clear();
return result; return result;
} }
std::string python_unicode(PyObject* object)
{
if (!object || !PyUnicode_Check(object))
return {};
const char* text = PyUnicode_AsUTF8(object);
if (!text) {
PyErr_Clear();
return {};
}
return text;
}
std::string python_str(PyObject* object) std::string python_str(PyObject* object)
{ {
if (!object) if (!object)
@@ -1143,20 +920,6 @@ bool has_permission(const std::vector<std::string>& granted, const std::string&
return std::find(granted.begin(), granted.end(), target) != granted.end(); return std::find(granted.begin(), granted.end(), target) != granted.end();
} }
// A declared or previously approved network grant may live in either network list: an HTTP grant
// covers the sockets the same request opens, and a raw socket grant covers a later HTTP call to
// the same host.
bool has_network_permission(const PluginPermissions& permissions, const std::string& target)
{
return PluginAuditManager::network_permission_matches(permissions.network_http, target) ||
PluginAuditManager::network_permission_matches(permissions.network_socket, target);
}
bool has_any_network_permission(const PluginPermissions& permissions)
{
return !permissions.network_http.empty() || !permissions.network_socket.empty();
}
bool persist_permission(const std::string& plugin_key, bool persist_permission(const std::string& plugin_key,
PluginInstallState& state, PluginInstallState& state,
std::vector<std::string>& permission_list, std::vector<std::string>& permission_list,
@@ -1166,7 +929,15 @@ bool persist_permission(const std::string& plugin_key,
if (!PluginManager::instance().try_get_plugin_descriptor(plugin_key, descriptor) || descriptor.plugin_root.empty()) if (!PluginManager::instance().try_get_plugin_descriptor(plugin_key, descriptor) || descriptor.plugin_root.empty())
return false; return false;
seed_install_metadata(state, descriptor); // A sandbox plugin may not have a sidecar yet. Seed the small amount of install metadata
// needed by the writer so clicking Yes still creates the JSON file.
if (state.plugin_name.empty()) {
state.installed_from = descriptor.is_cloud_plugin() ? "cloud" : "local";
state.installed_version = !descriptor.installed_version.empty() ? descriptor.installed_version : descriptor.version;
state.plugin_name = descriptor.name;
state.cloud_uuid = descriptor.cloud_uuid();
state.enabled = true;
}
if (!has_permission(permission_list, target)) if (!has_permission(permission_list, target))
permission_list.push_back(target); permission_list.push_back(target);
@@ -1245,10 +1016,11 @@ bool prompt_for_targets(AuditEventCategory category, const std::string& plugin_n
for (const auto& target : unresolved) for (const auto& target : unresolved)
target_list += wxString::FromUTF8(target.c_str()) + "\n"; target_list += wxString::FromUTF8(target.c_str()) + "\n";
return show_permission_dialog( wxMessageDialog dialog(nullptr,
audit_message(category, wxString::FromUTF8(plugin_name.c_str()), wxString::FromUTF8(event_name.c_str()), audit_message(category, wxString::FromUTF8(plugin_name.c_str()),
target_list, unresolved.size()), wxString::FromUTF8(event_name.c_str()), target_list, unresolved.size()),
_L("Plugin permission request")); _L("Plugin permission request"), wxYES_NO | wxICON_WARNING);
return dialog.ShowModal() == wxID_YES;
} }
// Records a grant in the plugin's sidecar so it is not asked again. Reads the install state // Records a grant in the plugin's sidecar so it is not asked again. Reads the install state
@@ -1320,13 +1092,10 @@ int decide_audited_event(PluginAuditManager& mgr,
std::vector<std::string>* permission_list = permission_list_for(category, by_event, state.permissions); std::vector<std::string>* permission_list = permission_list_for(category, by_event, state.permissions);
std::vector<std::string> unresolved = keys; std::vector<std::string> unresolved = keys;
if (permission_list || is_network_category(category)) { if (permission_list) {
unresolved.clear(); unresolved.clear();
for (const auto& key : keys) for (const auto& key : keys)
if (!(permission_list && has_permission(*permission_list, key)) && if (!has_permission(*permission_list, key))
!(is_network_category(category) &&
(by_event ? has_any_network_permission(state.permissions)
: has_network_permission(state.permissions, key))))
unresolved.push_back(key); unresolved.push_back(key);
if (unresolved.empty()) if (unresolved.empty())
return 0; return 0;
@@ -1552,10 +1321,12 @@ void PluginAuditManager::install_hook()
for (const auto& name : default_denied_filenames()) for (const auto& name : default_denied_filenames())
add_denied_filename(name); add_denied_filename(name);
// Categorical denies on top of the exact-name list above: no path a plugin can reach may // Categorical denies on top of the exact-name list above: a path a plugin reaches must not
// contain a "secret", "cert"(ificate), or "conf"(ig) path component, regardless of which // have a "secret", "cert"(ificate), or "conf"(ig) component below data_dir() -- or anywhere
// allowed root it happens to sit inside. default_denied_path_keywords() is the single // in the path outside data_dir(), whose own location may legitimately contain one (Flatpak
// source of this list; the tests seed from it too. // keeps it under .../config/OrcaSlicer). This holds regardless of which allowed root the
// path 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()) for (const auto& keyword : default_denied_path_keywords())
add_denied_path_keyword(keyword); add_denied_path_keyword(keyword);
+9 -29
View File
@@ -102,12 +102,13 @@ public:
bool is_denied_filename(const boost::filesystem::path& candidate) const; bool is_denied_filename(const boost::filesystem::path& candidate) const;
// --- denied-path-keyword registry --- // --- denied-path-keyword registry ---
// Keywords that categorically deny a path if ANY component matches one case-insensitively -- // Keywords that categorically deny a path when a component matches one case-insensitively --
// e.g. a "secrets" subfolder, a "certificates" folder, a "conf"/"config" directory, or a // e.g. a "secrets" subfolder, a "certificates" folder, a "conf"/"config" directory, or a
// .conf/.ini file anywhere the plugin can otherwise reach, including inside an allowed root. // .conf/.ini file anywhere the plugin can otherwise reach, including inside an allowed root.
// This is broader than the exact-name is_denied_filename registry, but it is not a substring // The components above the app's data_dir() are not considered (see below). This is broader
// match: importable modules such as numpy/__config__.py, configparser.py, sysconfig.py, or // than the exact-name is_denied_filename registry, but it is not a substring match:
// user folders such as "Conference" and "Concert" are unrelated names and must stay promptable. // importable modules such as numpy/__config__.py, configparser.py, sysconfig.py, or user
// folders such as "Conference" and "Concert" are unrelated names and must stay promptable.
void add_denied_path_keyword(const std::string& keyword); void add_denied_path_keyword(const std::string& keyword);
// The list install_hook() seeds into the keyword registry. Exposed so tests seed the exact // The list install_hook() seeds into the keyword registry. Exposed so tests seed the exact
@@ -115,7 +116,10 @@ public:
static std::vector<std::string> default_denied_path_keywords(); static std::vector<std::string> default_denied_path_keywords();
// True when any component of candidate's (canonicalized) path matches a registered keyword, // True when any component of candidate's (canonicalized) path matches a registered keyword,
// case-insensitively. A registered "conf" keyword also blocks .conf/.ini file components. // case-insensitively. Inside data_dir(), only the components below data_dir() are checked:
// the app's own data folder may itself sit under a component named like a keyword (Flatpak
// keeps it under .../config/OrcaSlicer), which must not deny everything inside it. A
// registered "conf" keyword also blocks .conf/.ini file components.
bool is_denied_path_keyword(const boost::filesystem::path& candidate) const; bool is_denied_path_keyword(const boost::filesystem::path& candidate) const;
// is_denied_filename(candidate) || is_denied_path_keyword(candidate). Convenience for // is_denied_filename(candidate) || is_denied_path_keyword(candidate). Convenience for
@@ -137,29 +141,6 @@ public:
bool request_filesystem_read_permissions(const std::string& plugin_key, bool request_filesystem_read_permissions(const std::string& plugin_key,
const std::vector<std::string>& paths); const std::vector<std::string>& paths);
// Ask the user to grant network access to the given http(s) URLs. Each URL is reduced to its
// scheme and host before prompting and persisting, so one approval covers every path, query
// and port on that host. The grant is written to both network_http and network_socket: the
// same declared host can surface through HTTP events (urllib/http.client) and raw socket
// events. Like request_filesystem_read_permissions, the request may originate on a plugin
// load worker; denial aborts the plugin load without adding a permission.
bool request_network_permissions(const std::string& plugin_key,
const std::vector<std::string>& urls);
// Canonical scheme://host form used to store and match network permissions. Returns an empty
// string unless `url` is an absolute http(s) URL with a host. The path, query, fragment,
// userinfo and port are discarded; scheme and host are lowercased.
static std::string normalize_network_url(const std::string& url);
// True when a granted scheme://host entry covers `target`. `target` is a URL (urllib.Request),
// a bare host (http.client.connect, socket.getaddrinfo) or an address as exposed by the audit
// event (a hostname, a host:port pair, or a legacy persisted tuple such as "('host', 443)").
// URL targets must match both scheme and host. Canonical scheme://host grants cover all paths;
// older full-URL grants remain exact. A bare host carries no scheme, so it matches the host
// part of any grant; it never matches a different host.
static bool network_permission_matches(const std::vector<std::string>& granted,
const std::string& target);
void report_violation(const AuditViolation& violation); void report_violation(const AuditViolation& violation);
bool audit_denial_pending() const; bool audit_denial_pending() const;
void clear_audit_denial(); void clear_audit_denial();
@@ -210,7 +191,6 @@ private:
// mutable: is_denied_filename() is a const query that must lock. // mutable: is_denied_filename() is a const query that must lock.
mutable std::mutex m_mutex; mutable std::mutex m_mutex;
std::vector<AllowedRoot> m_global_allowed_roots; std::vector<AllowedRoot> m_global_allowed_roots;
std::vector<std::string> m_denied_filenames; std::vector<std::string> m_denied_filenames;
std::vector<std::string> m_denied_path_keywords; std::vector<std::string> m_denied_path_keywords;
+15 -27
View File
@@ -833,29 +833,6 @@ bool read_install_state(const boost::filesystem::path& plugin_dir, PluginInstall
} }
} }
namespace {
// The single descriptor -> sidecar metadata mapping. Prefer installed_version (fetched at install
// time and preserved across rewrites) over a possibly stale manifest version.
void apply_install_metadata(PluginInstallState& state, const PluginDescriptor& entry)
{
state.installed_from = entry.is_cloud_plugin() ? "cloud" : "local";
state.installed_version = !entry.installed_version.empty() ? entry.installed_version : entry.version;
state.plugin_name = entry.name;
state.cloud_uuid = entry.cloud_uuid();
}
} // namespace
void seed_install_metadata(PluginInstallState& state, const PluginDescriptor& entry)
{
if (!state.plugin_name.empty())
return;
apply_install_metadata(state, entry);
state.enabled = true;
}
bool write_install_state(const boost::filesystem::path& plugin_dir, const PluginInstallState& state) bool write_install_state(const boost::filesystem::path& plugin_dir, const PluginInstallState& state)
{ {
namespace fs = boost::filesystem; namespace fs = boost::filesystem;
@@ -899,9 +876,16 @@ bool write_install_state(const boost::filesystem::path& plugin_dir, const Plugin
// Loading a plugin updates its lifecycle/capability state, but must retain permissions granted // Loading a plugin updates its lifecycle/capability state, but must retain permissions granted
// during register_capabilities() or by a previous runtime audit prompt. // during register_capabilities() or by a previous runtime audit prompt.
read_install_state(plugin_dir, state); read_install_state(plugin_dir, state);
apply_install_metadata(state, entry); state.installed_from = entry.is_cloud_plugin() ? "cloud" : "local";
state.enabled = enabled; // Prefer the descriptor's recorded installed_version (the version fetched from the cloud
state.capabilities = capabilities; // at install time, preserved across sidecar re-writes) so a stale manifest/PEP723 header
// never overwrites the source-of-truth version. Fall back to the manifest version for
// first-time/local installs where installed_version is not yet populated.
state.installed_version = !entry.installed_version.empty() ? entry.installed_version : entry.version;
state.plugin_name = entry.name;
state.cloud_uuid = entry.cloud_uuid();
state.enabled = enabled;
state.capabilities = capabilities;
return write_install_state(plugin_dir, state); return write_install_state(plugin_dir, state);
} }
@@ -912,7 +896,11 @@ bool write_install_state(const boost::filesystem::path& plugin_dir, const Plugin
// permissions on a fresh install/reinstall. PluginManager writes the real per-capability flags // permissions on a fresh install/reinstall. PluginManager writes the real per-capability flags
// while preserving permissions once the package is loaded, via the overload above. // while preserving permissions once the package is loaded, via the overload above.
PluginInstallState state; PluginInstallState state;
seed_install_metadata(state, entry); state.installed_from = entry.is_cloud_plugin() ? "cloud" : "local";
state.installed_version = !entry.installed_version.empty() ? entry.installed_version : entry.version;
state.plugin_name = entry.name;
state.cloud_uuid = entry.cloud_uuid();
state.enabled = true;
return write_install_state(plugin_dir, state); return write_install_state(plugin_dir, state);
} }
-7
View File
@@ -186,19 +186,12 @@ std::vector<std::string> collect_resource_texture_files(const std::string& dir,
// Canonical writer: emits the .install_state.json schema for the given state. // Canonical writer: emits the .install_state.json schema for the given state.
bool write_install_state(const boost::filesystem::path& plugin_dir, const PluginInstallState& state); bool write_install_state(const boost::filesystem::path& plugin_dir, const PluginInstallState& state);
// Builds a PluginInstallState from the descriptor and delegates to the canonical writer. // Builds a PluginInstallState from the descriptor and delegates to the canonical writer.
bool write_install_state(const boost::filesystem::path& plugin_dir, const PluginDescriptor& entry, bool enabled, bool write_install_state(const boost::filesystem::path& plugin_dir, const PluginDescriptor& entry, bool enabled,
const std::vector<std::pair<std::string, bool>>& capabilities); const std::vector<std::pair<std::string, bool>>& capabilities);
// Convenience overload: write(dir, entry, /*enabled=*/true, /*capabilities=*/{}). // Convenience overload: write(dir, entry, /*enabled=*/true, /*capabilities=*/{}).
bool write_install_state(const boost::filesystem::path& plugin_dir, const PluginDescriptor& entry); bool write_install_state(const boost::filesystem::path& plugin_dir, const PluginDescriptor& entry);
// Fill the descriptor-derived install metadata into a sidecar state that does not describe an
// installed plugin yet, and mark it enabled. Leaves permissions and capabilities untouched.
// Single source for the metadata mapping used by the install writer and by the audit prompt path
// that creates a sidecar on demand.
void seed_install_metadata(PluginInstallState& state, const PluginDescriptor& entry);
// Reads install state back into the descriptor; plugin_key is always derived. Returns whether the // Reads install state back into the descriptor; plugin_key is always derived. Returns whether the
// sidecar was present and valid. // sidecar was present and valid.
bool read_install_state(const boost::filesystem::path& plugin_dir, PluginDescriptor& entry); bool read_install_state(const boost::filesystem::path& plugin_dir, PluginDescriptor& entry);
+6 -2
View File
@@ -1180,8 +1180,12 @@ void PluginManager::mark_plugin_install_state_disabled(const std::string& plugin
const boost::filesystem::path root(descriptor.plugin_root); const boost::filesystem::path root(descriptor.plugin_root);
PluginInstallState state; PluginInstallState state;
if (!read_install_state(root, state)) if (!read_install_state(root, state)) {
seed_install_metadata(state, descriptor); state.installed_from = descriptor.is_cloud_plugin() ? "cloud" : "local";
state.installed_version = !descriptor.installed_version.empty() ? descriptor.installed_version : descriptor.version;
state.plugin_name = descriptor.name;
state.cloud_uuid = descriptor.cloud_uuid();
}
state.enabled = false; state.enabled = false;
if (!write_install_state(root, state)) if (!write_install_state(root, state))
return; return;
+9 -66
View File
@@ -50,7 +50,6 @@ std::mutex g_registry_mutex;
std::unordered_map<std::string, std::vector<py::object>> g_pending_capabilities; std::unordered_map<std::string, std::vector<py::object>> g_pending_capabilities;
std::unordered_map<std::string, py::object> g_pending_package; std::unordered_map<std::string, py::object> g_pending_package;
std::unordered_map<std::string, std::vector<std::string>> g_pending_fs_read_permissions; std::unordered_map<std::string, std::vector<std::string>> g_pending_fs_read_permissions;
std::unordered_map<std::string, std::vector<std::string>> g_pending_network_permissions;
struct PluginInstanceHandle struct PluginInstanceHandle
{ {
// The C++ plugin interface points into a Python object. Keep both alive through one // The C++ plugin interface points into a Python object. Keep both alive through one
@@ -90,7 +89,6 @@ void discard_pending_capture_without_python(const std::string& plugin_key)
} }
g_pending_fs_read_permissions.erase(plugin_key); g_pending_fs_read_permissions.erase(plugin_key);
g_pending_network_permissions.erase(plugin_key);
} }
} // namespace } // namespace
@@ -116,7 +114,6 @@ void PythonPluginBridge::begin_plugin_capture(const std::string& plugin_key)
g_pending_capabilities.erase(plugin_key); g_pending_capabilities.erase(plugin_key);
g_pending_package.erase(plugin_key); g_pending_package.erase(plugin_key);
g_pending_fs_read_permissions.erase(plugin_key); g_pending_fs_read_permissions.erase(plugin_key);
g_pending_network_permissions.erase(plugin_key);
} }
// From now until finalize/cancel, @orca.plugin and register_capability() calls made by // From now until finalize/cancel, @orca.plugin and register_capability() calls made by
// Python code on this thread are attributed to this plugin. // Python code on this thread are attributed to this plugin.
@@ -148,7 +145,6 @@ std::vector<CapturedCapability> PythonPluginBridge::finalize_plugin_capture(cons
g_pending_capabilities.erase(capture_key); g_pending_capabilities.erase(capture_key);
g_pending_package.erase(capture_key); g_pending_package.erase(capture_key);
g_pending_fs_read_permissions.erase(capture_key); g_pending_fs_read_permissions.erase(capture_key);
g_pending_network_permissions.erase(capture_key);
}; };
try { try {
@@ -204,30 +200,6 @@ std::vector<CapturedCapability> PythonPluginBridge::finalize_plugin_capture(cons
return {}; return {};
} }
} }
std::vector<std::string> network_permissions;
{
std::lock_guard<std::mutex> lock(g_registry_mutex);
auto it = g_pending_network_permissions.find(capture_key);
if (it != g_pending_network_permissions.end()) {
network_permissions = std::move(it->second);
g_pending_network_permissions.erase(it);
}
}
if (!network_permissions.empty()) {
bool granted = false;
{
py::gil_scoped_release release;
granted = PluginAuditManager::instance().request_network_permissions(plugin_key, network_permissions);
}
if (!granted) {
error = _u8L("Plugin network permission request denied");
BOOST_LOG_TRIVIAL(warning) << error << " for key " << plugin_key;
discard_pending_for_key();
clear_active_key();
return {};
}
}
} catch (py::error_already_set& err) { } catch (py::error_already_set& err) {
log_python_exception_keep(err); log_python_exception_keep(err);
error = err.what(); error = err.what();
@@ -351,7 +323,6 @@ void PythonPluginBridge::cancel_plugin_capture(const std::string& plugin_key)
g_pending_capabilities.erase(plugin_key); g_pending_capabilities.erase(plugin_key);
g_pending_package.erase(plugin_key); g_pending_package.erase(plugin_key);
g_pending_fs_read_permissions.erase(plugin_key); g_pending_fs_read_permissions.erase(plugin_key);
g_pending_network_permissions.erase(plugin_key);
} }
if (g_active_plugin_key == plugin_key) if (g_active_plugin_key == plugin_key)
@@ -379,7 +350,6 @@ void PythonPluginBridge::clear_pending_captures()
} }
g_pending_package.clear(); g_pending_package.clear();
g_pending_fs_read_permissions.clear(); g_pending_fs_read_permissions.clear();
g_pending_network_permissions.clear();
g_active_plugin_key.clear(); g_active_plugin_key.clear();
return; return;
} }
@@ -389,7 +359,6 @@ void PythonPluginBridge::clear_pending_captures()
g_pending_capabilities.clear(); g_pending_capabilities.clear();
g_pending_package.clear(); g_pending_package.clear();
g_pending_fs_read_permissions.clear(); g_pending_fs_read_permissions.clear();
g_pending_network_permissions.clear();
g_active_plugin_key.clear(); g_active_plugin_key.clear();
} }
@@ -589,50 +558,24 @@ void bind_python_api(pybind11::module_& m)
m.def( m.def(
"request_permissions", "request_permissions",
[](const std::vector<std::string>& fs_read, const std::vector<std::string>& network) { [](const std::vector<std::string>& fs_read) {
if (g_active_plugin_key.empty()) if (g_active_plugin_key.empty())
throw py::value_error("request_permissions() called outside plugin discovery context"); throw py::value_error("request_permissions() called outside plugin discovery context");
// Validate and canonicalize everything before touching the pending registry, so a std::lock_guard<std::mutex> lock(g_registry_mutex);
// partially invalid call cannot leave a half-recorded request behind. auto& requested = g_pending_fs_read_permissions[g_active_plugin_key];
std::vector<std::string> normalized_network;
normalized_network.reserve(network.size());
for (const std::string& url : network) {
const std::string normalized = PluginAuditManager::normalize_network_url(url);
if (normalized.empty())
throw py::value_error(
"request_permissions(network=...) requires an http:// or https:// URL with a host");
if (std::find(normalized_network.begin(), normalized_network.end(), normalized) == normalized_network.end())
normalized_network.push_back(normalized);
}
for (const std::string& path : fs_read) { for (const std::string& path : fs_read) {
if (path.empty()) if (path.empty())
throw py::value_error("request_permissions(fs_read=...) does not accept empty paths"); throw py::value_error("request_permissions(fs_read=...) does not accept empty paths");
} if (std::find(requested.begin(), requested.end(), path) == requested.end())
requested.push_back(path);
std::lock_guard<std::mutex> lock(g_registry_mutex);
auto& requested_fs = g_pending_fs_read_permissions[g_active_plugin_key];
for (const std::string& path : fs_read) {
if (std::find(requested_fs.begin(), requested_fs.end(), path) == requested_fs.end())
requested_fs.push_back(path);
}
if (!normalized_network.empty()) {
auto& requested_network = g_pending_network_permissions[g_active_plugin_key];
for (const std::string& entry : normalized_network) {
if (std::find(requested_network.begin(), requested_network.end(), entry) == requested_network.end())
requested_network.push_back(entry);
}
} }
}, },
py::arg("fs_read") = std::vector<std::string>{}, py::arg("network") = std::vector<std::string>{}, py::arg("fs_read") = std::vector<std::string>{},
R"pbdoc(Request permissions while OrcaSlicer loads your module. R"pbdoc(Request filesystem read permissions while OrcaSlicer loads your module.
`fs_read` lists filesystem paths to read. `network` lists http(s) URLs; each is reduced to its The host presents the request to the user after register_capabilities() returns. Denying the
scheme and host, so one grant covers every path, query and port on that host. The host presents request aborts the plugin load.)pbdoc");
the requests to the user after register_capabilities() returns. Denying a request aborts the
plugin load.)pbdoc");
m.def("plugin", [](py::object cls) { m.def("plugin", [](py::object cls) {
if (g_active_plugin_key.empty()) if (g_active_plugin_key.empty())
+61 -87
View File
@@ -288,6 +288,67 @@ TEST_CASE("Plugin audit denies secret/certificate/config-like paths by keyword",
} }
} }
TEST_CASE("Plugin audit checks denied keywords below data_dir, not in the directories above it", "[PluginAudit]")
{
// Flatpak keeps the app's data folder under <home>/.var/app/<app-id>/config/OrcaSlicer, so
// data_dir() itself has a "config" component. That ancestor describes the sandbox's layout,
// not what the folder holds, and must not hard-deny every path inside it.
ScopedDataDir data_dir_guard("plugin-audit-data-dir-keyword");
const fs::path flatpak_data_dir = data_dir_guard.dir / "config" / "OrcaSlicer";
set_data_dir(flatpak_data_dir.string());
seed_denied_names();
seed_denied_keywords();
PluginAuditManager& mgr = PluginAuditManager::instance();
// Reproduce install_hook()'s grant: data_dir() is a global allowed root.
mgr.add_global_allowed_root(data_dir());
ScopedPluginAuditContext ctx("test_plugin", "");
SECTION("the root and the paths below it that carry no keyword are allowed")
{
CHECK_FALSE(mgr.is_denied_path_keyword(flatpak_data_dir / "orca_plugins" / "state.json"));
CHECK(mgr.check_open(data_dir(), "r").allowed);
CHECK(mgr.check_open((flatpak_data_dir / "orca_plugins" / "test_plugin" / "state.json").string(), "r").allowed);
CHECK(mgr.check_open((flatpak_data_dir / "orca_plugins" / "test_plugin" / "state.json").string(), "w").allowed);
CHECK(mgr.check_open((flatpak_data_dir / "system" / "profiles" / "vendor.json").string(), "r").allowed);
}
SECTION("a 'secrets' component below data_dir() is still denied")
{
AuditDecision decision = mgr.check_open((flatpak_data_dir / "secrets" / "token.json").string(), "r");
CHECK_FALSE(decision.allowed);
CHECK(decision.reason == "denied path keyword");
}
SECTION("a 'config' component below data_dir() is still denied")
{
AuditDecision decision = mgr.check_open((flatpak_data_dir / "config" / "settings.json").string(), "r");
CHECK_FALSE(decision.allowed);
CHECK(decision.reason == "denied path keyword");
}
SECTION("the app config below data_dir() is still denied by name")
{
AuditDecision decision = mgr.check_open((flatpak_data_dir / (SLIC3R_APP_KEY ".conf")).string(), "r");
CHECK_FALSE(decision.allowed);
CHECK(decision.reason == "denied filename");
}
SECTION("an unrelated resources root keeps the full-path keyword check")
{
ScopedResourcesDir resources_dir_guard("plugin-audit-data-dir-keyword-resources");
mgr.add_global_allowed_root(resources_dir(), /*allow_write=*/false);
CHECK(mgr.check_open((fs::path(resources_dir()) / "profiles" / "vendor.json").string(), "r").allowed);
AuditDecision decision = mgr.check_open((fs::path(resources_dir()) / "cert" / "slicer_base64.cer").string(), "r");
CHECK_FALSE(decision.allowed);
CHECK(decision.reason == "denied path keyword");
}
}
TEST_CASE("Plugin audit is_denied_path combines the filename and keyword registries", "[audit]") TEST_CASE("Plugin audit is_denied_path combines the filename and keyword registries", "[audit]")
{ {
seed_denied_names(); seed_denied_names();
@@ -351,93 +412,6 @@ TEST_CASE("Plugin audit a read-only allowed root blocks writes but not reads", "
} }
} }
TEST_CASE("Plugin audit network permissions match by scheme and host", "[audit]")
{
SECTION("a URL is reduced to its scheme and host")
{
CHECK(PluginAuditManager::normalize_network_url("https://API.Example.com:8443/v1/items?page=2#top") ==
"https://api.example.com");
CHECK(PluginAuditManager::normalize_network_url("http://example.com") == "http://example.com");
CHECK(PluginAuditManager::normalize_network_url("https://[2001:db8::1]:443/path") == "https://[2001:db8::1]");
}
SECTION("non-http schemes and targets without a scheme are not network URLs")
{
CHECK(PluginAuditManager::normalize_network_url("ftp://example.com").empty());
CHECK(PluginAuditManager::normalize_network_url("api.example.com").empty());
CHECK(PluginAuditManager::normalize_network_url("https://").empty());
}
SECTION("a URL target matches the same scheme and host regardless of path and port")
{
const std::vector<std::string> granted{"https://api.example.com"};
CHECK(PluginAuditManager::network_permission_matches(granted, "https://api.example.com/v1/items?page=2"));
CHECK(PluginAuditManager::network_permission_matches(granted, "https://api.example.com:8443/upload"));
}
SECTION("canonical host grants are broad but persisted full-URL grants stay exact")
{
const std::vector<std::string> declared_host{"https://api.example.com"};
CHECK(PluginAuditManager::network_permission_matches(declared_host, "https://api.example.com/v1"));
const std::vector<std::string> saved_url{"https://api.example.com/v1"};
CHECK(PluginAuditManager::network_permission_matches(saved_url, "https://api.example.com/v1"));
CHECK_FALSE(PluginAuditManager::network_permission_matches(saved_url, "https://api.example.com/v2"));
}
SECTION("a URL target does not match a different host or a different scheme")
{
const std::vector<std::string> granted{"https://api.example.com"};
CHECK_FALSE(PluginAuditManager::network_permission_matches(granted, "https://evil.example.com/v1"));
CHECK_FALSE(PluginAuditManager::network_permission_matches(granted, "http://api.example.com/v1"));
}
SECTION("a bare host target matches the host of a grant")
{
const std::vector<std::string> granted{"https://api.example.com"};
CHECK(PluginAuditManager::network_permission_matches(granted, "api.example.com"));
CHECK(PluginAuditManager::network_permission_matches(granted, "api.example.com:443"));
CHECK_FALSE(PluginAuditManager::network_permission_matches(granted, "other.example.com"));
}
SECTION("legacy persisted address tuples keep matching their host")
{
const std::vector<std::string> legacy_granted{"('api.example.com', 443)"};
CHECK(PluginAuditManager::network_permission_matches(legacy_granted, "api.example.com"));
const std::vector<std::string> granted{"https://api.example.com"};
CHECK(PluginAuditManager::network_permission_matches(granted, "('api.example.com', 443)"));
CHECK_FALSE(PluginAuditManager::network_permission_matches(granted, "('93.184.216.34', 443)"));
}
SECTION("an IPv6 literal matches across bracketed and bare forms")
{
const std::vector<std::string> granted{"https://[2001:db8::1]"};
CHECK(PluginAuditManager::network_permission_matches(granted, "2001:db8::1"));
CHECK(PluginAuditManager::network_permission_matches(granted, "[2001:db8::1]:443"));
CHECK_FALSE(PluginAuditManager::network_permission_matches(granted, "2001:db8::2"));
}
SECTION("a non-http URL only matches an exact grant")
{
const std::vector<std::string> granted{"ftp://example.com/file"};
CHECK(PluginAuditManager::network_permission_matches(granted, "ftp://example.com/file"));
CHECK_FALSE(PluginAuditManager::network_permission_matches(granted, "ftp://example.com/other"));
const std::vector<std::string> https{"https://api.example.com"};
CHECK_FALSE(PluginAuditManager::network_permission_matches(https, "ftp://api.example.com/file"));
}
SECTION("an empty grant list or an empty target never matches")
{
const std::vector<std::string> granted{};
CHECK_FALSE(PluginAuditManager::network_permission_matches(granted, "https://api.example.com"));
const std::vector<std::string> any{"https://api.example.com"};
CHECK_FALSE(PluginAuditManager::network_permission_matches(any, ""));
}
}
TEST_CASE("Plugin audit a scoped root can also be registered read-only", "[audit]") TEST_CASE("Plugin audit a scoped root can also be registered read-only", "[audit]")
{ {
ScopedDataDir data_dir_guard("plugin-audit-scoped-readonly"); ScopedDataDir data_dir_guard("plugin-audit-scoped-readonly");