mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-10-20 22:18:30 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4afd257371 | ||
|
|
4a0d5e5edd | ||
|
|
6c791532c6 |
No files matched your search
@@ -5,10 +5,13 @@
|
|||||||
#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>
|
||||||
@@ -161,6 +164,180 @@ 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)
|
||||||
{
|
{
|
||||||
@@ -528,6 +705,134 @@ 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,
|
||||||
|
const std::vector<std::string>& paths)
|
||||||
|
{
|
||||||
|
if (plugin_key.empty() || paths.empty())
|
||||||
|
return true;
|
||||||
|
|
||||||
|
PluginDescriptor descriptor;
|
||||||
|
if (!PluginManager::instance().try_get_plugin_descriptor(plugin_key, descriptor) || descriptor.plugin_root.empty())
|
||||||
|
return false;
|
||||||
|
|
||||||
|
PluginInstallState state;
|
||||||
|
read_install_state(boost::filesystem::path(descriptor.plugin_root), state);
|
||||||
|
|
||||||
|
std::vector<std::string> missing;
|
||||||
|
for (const std::string& path : paths) {
|
||||||
|
if (std::find(state.permissions.fs_read.begin(), state.permissions.fs_read.end(), path) == state.permissions.fs_read.end())
|
||||||
|
missing.push_back(path);
|
||||||
|
}
|
||||||
|
if (missing.empty())
|
||||||
|
return true;
|
||||||
|
|
||||||
|
wxString requested_paths;
|
||||||
|
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;
|
||||||
|
|
||||||
|
seed_install_metadata(state, descriptor);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
@@ -570,6 +875,39 @@ 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)
|
||||||
@@ -581,26 +919,12 @@ std::string python_path(PyObject* object)
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
const char* path = PyUnicode_Check(path_object) ? PyUnicode_AsUTF8(path_object) : PyBytes_AsString(path_object);
|
std::string result;
|
||||||
std::string result = path ? path : "";
|
python_text(path_object, result);
|
||||||
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)
|
||||||
@@ -763,7 +1087,7 @@ std::vector<std::string> audit_targets(const std::string& event_name, AuditEvent
|
|||||||
// Thread creation exposes no user-supplied target. Use a fixed sentinel so the grant
|
// Thread creation exposes no user-supplied target. Use a fixed sentinel so the grant
|
||||||
// persists per plugin: the permission list matches targets by exact string, and the
|
// persists per plugin: the permission list matches targets by exact string, and the
|
||||||
// started function's repr embeds an address that changes every run.
|
// started function's repr embeds an address that changes every run.
|
||||||
targets.emplace_back(kThreadPermissionTarget);
|
targets.emplace_back("thread");
|
||||||
return targets;
|
return targets;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
@@ -819,18 +1143,18 @@ 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 sandbox plugin may not have a sidecar yet. Seed the small amount of install metadata the
|
// A declared or previously approved network grant may live in either network list: an HTTP grant
|
||||||
// writer needs so clicking Yes still creates the JSON file.
|
// covers the sockets the same request opens, and a raw socket grant covers a later HTTP call to
|
||||||
void seed_install_metadata(const PluginDescriptor& descriptor, PluginInstallState& state)
|
// the same host.
|
||||||
|
bool has_network_permission(const PluginPermissions& permissions, const std::string& target)
|
||||||
{
|
{
|
||||||
if (!state.plugin_name.empty())
|
return PluginAuditManager::network_permission_matches(permissions.network_http, target) ||
|
||||||
return;
|
PluginAuditManager::network_permission_matches(permissions.network_socket, target);
|
||||||
|
}
|
||||||
|
|
||||||
state.installed_from = descriptor.is_cloud_plugin() ? "cloud" : "local";
|
bool has_any_network_permission(const PluginPermissions& permissions)
|
||||||
state.installed_version = !descriptor.installed_version.empty() ? descriptor.installed_version : descriptor.version;
|
{
|
||||||
state.plugin_name = descriptor.name;
|
return !permissions.network_http.empty() || !permissions.network_socket.empty();
|
||||||
state.cloud_uuid = descriptor.cloud_uuid();
|
|
||||||
state.enabled = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bool persist_permission(const std::string& plugin_key,
|
bool persist_permission(const std::string& plugin_key,
|
||||||
@@ -842,7 +1166,7 @@ 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(descriptor, state);
|
seed_install_metadata(state, descriptor);
|
||||||
|
|
||||||
if (!has_permission(permission_list, target))
|
if (!has_permission(permission_list, target))
|
||||||
permission_list.push_back(target);
|
permission_list.push_back(target);
|
||||||
@@ -921,18 +1245,10 @@ 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";
|
||||||
|
|
||||||
wxMessageDialog dialog(nullptr,
|
return show_permission_dialog(
|
||||||
audit_message(category, wxString::FromUTF8(plugin_name.c_str()),
|
audit_message(category, wxString::FromUTF8(plugin_name.c_str()), wxString::FromUTF8(event_name.c_str()),
|
||||||
wxString::FromUTF8(event_name.c_str()), target_list, unresolved.size()),
|
target_list, unresolved.size()),
|
||||||
_L("Plugin permission request"), wxYES_NO | wxICON_WARNING);
|
_L("Plugin permission request"));
|
||||||
return dialog.ShowModal() == wxID_YES;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shows a prompt whose body may cover several permission buckets. Must run on the GUI thread.
|
|
||||||
bool prompt_for_permission_message(const wxString& message)
|
|
||||||
{
|
|
||||||
wxMessageDialog dialog(nullptr, message, _L("Plugin permissions"), 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
|
||||||
@@ -961,7 +1277,7 @@ static PluginAuditManager::PermissionPrompt s_permission_prompt_for_testing;
|
|||||||
// SlicingPipeline hook runs on the slicing worker thread (see PluginHooks.cpp), and
|
// SlicingPipeline hook runs on the slicing worker thread (see PluginHooks.cpp), and
|
||||||
// BackgroundSlicingProcess::stop()/stop_internal() park the UI thread until that worker stops.
|
// BackgroundSlicingProcess::stop()/stop_internal() park the UI thread until that worker stops.
|
||||||
// Blocking the worker on a marshaled modal -- which is safe for the plugin-load worker that
|
// Blocking the worker on a marshaled modal -- which is safe for the plugin-load worker that
|
||||||
// request_permissions runs on -- would therefore deadlock the application (the
|
// request_filesystem_read_permissions runs on -- would therefore deadlock the application (the
|
||||||
// invariant PluginHostUi.cpp documents for slicing-hook UI calls). Off the main thread the prompt
|
// invariant PluginHostUi.cpp documents for slicing-hook UI calls). Off the main thread the prompt
|
||||||
// is therefore posted asynchronously and the current event denied (fail closed, like an unanswered
|
// is therefore posted asynchronously and the current event denied (fail closed, like an unanswered
|
||||||
// prompt); the grant is persisted once the user accepts, so a later attempt succeeds without
|
// prompt); the grant is persisted once the user accepts, so a later attempt succeeds without
|
||||||
@@ -1004,10 +1320,13 @@ 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) {
|
if (permission_list || is_network_category(category)) {
|
||||||
unresolved.clear();
|
unresolved.clear();
|
||||||
for (const auto& key : keys)
|
for (const auto& key : keys)
|
||||||
if (!has_permission(*permission_list, key))
|
if (!(permission_list && 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;
|
||||||
@@ -1085,105 +1404,6 @@ int audit_plugin_event(PluginAuditManager& mgr, const std::string& event_name, A
|
|||||||
|
|
||||||
} // namespace PluginAuditDetail
|
} // namespace PluginAuditDetail
|
||||||
|
|
||||||
bool PluginAuditManager::request_permissions(const std::string& plugin_key, const PluginPermissions& requested)
|
|
||||||
{
|
|
||||||
if (plugin_key.empty())
|
|
||||||
return true;
|
|
||||||
|
|
||||||
PluginDescriptor descriptor;
|
|
||||||
if (!PluginManager::instance().try_get_plugin_descriptor(plugin_key, descriptor) || descriptor.plugin_root.empty())
|
|
||||||
return false;
|
|
||||||
|
|
||||||
PluginInstallState state;
|
|
||||||
read_install_state(boost::filesystem::path(descriptor.plugin_root), state);
|
|
||||||
|
|
||||||
// Per bucket, keep only the targets that no earlier grant already covers. Dropping these
|
|
||||||
// before prompting keeps a repeated declaration from asking again for what is on disk.
|
|
||||||
struct MissingBucket {
|
|
||||||
AuditEventCategory category;
|
|
||||||
std::vector<std::string> targets;
|
|
||||||
};
|
|
||||||
std::vector<MissingBucket> missing_buckets;
|
|
||||||
|
|
||||||
const auto collect_missing = [&missing_buckets](AuditEventCategory category,
|
|
||||||
const std::vector<std::string>& requested_targets,
|
|
||||||
const std::vector<std::string>& granted_targets) {
|
|
||||||
MissingBucket bucket{category, {}};
|
|
||||||
for (const std::string& target : requested_targets) {
|
|
||||||
if (target.empty())
|
|
||||||
continue;
|
|
||||||
if (std::find(granted_targets.begin(), granted_targets.end(), target) == granted_targets.end() &&
|
|
||||||
std::find(bucket.targets.begin(), bucket.targets.end(), target) == bucket.targets.end())
|
|
||||||
bucket.targets.push_back(target);
|
|
||||||
}
|
|
||||||
if (!bucket.targets.empty())
|
|
||||||
missing_buckets.push_back(std::move(bucket));
|
|
||||||
};
|
|
||||||
|
|
||||||
collect_missing(AuditEventCategory::FsRead, requested.fs_read, state.permissions.fs_read);
|
|
||||||
collect_missing(AuditEventCategory::FsReadWrite, requested.fs_readwrite, state.permissions.fs_readwrite);
|
|
||||||
collect_missing(AuditEventCategory::Http, requested.network_http, state.permissions.network_http);
|
|
||||||
collect_missing(AuditEventCategory::Socket, requested.network_socket, state.permissions.network_socket);
|
|
||||||
collect_missing(AuditEventCategory::ProcessCreate, requested.process, state.permissions.process);
|
|
||||||
collect_missing(AuditEventCategory::Threading, requested.threading, state.permissions.threading);
|
|
||||||
|
|
||||||
if (missing_buckets.empty())
|
|
||||||
return true;
|
|
||||||
|
|
||||||
if (wxTheApp == nullptr || GUI::wxGetApp().is_closing())
|
|
||||||
return false;
|
|
||||||
|
|
||||||
const auto build_message = [](const std::string& plugin_name, const std::vector<MissingBucket>& buckets) {
|
|
||||||
wxString message;
|
|
||||||
for (const MissingBucket& bucket : buckets) {
|
|
||||||
wxString targets;
|
|
||||||
for (const std::string& target : bucket.targets)
|
|
||||||
targets += wxString::FromUTF8(target.c_str()) + "\n";
|
|
||||||
|
|
||||||
if (!message.IsEmpty())
|
|
||||||
message += "\n\n";
|
|
||||||
message += PluginAuditDetail::audit_message(bucket.category, wxString::FromUTF8(plugin_name.c_str()),
|
|
||||||
wxEmptyString, targets, bucket.targets.size());
|
|
||||||
}
|
|
||||||
return message;
|
|
||||||
};
|
|
||||||
|
|
||||||
bool granted = false;
|
|
||||||
if (wxIsMainThread()) {
|
|
||||||
granted = PluginAuditDetail::prompt_for_permission_message(build_message(descriptor.name, missing_buckets));
|
|
||||||
} else {
|
|
||||||
auto result = std::make_shared<std::promise<bool>>();
|
|
||||||
auto future = result->get_future();
|
|
||||||
GUI::wxGetApp().CallAfter([result, plugin_name = descriptor.name, missing_buckets, build_message]() {
|
|
||||||
result->set_value(PluginAuditDetail::prompt_for_permission_message(build_message(plugin_name, missing_buckets)));
|
|
||||||
});
|
|
||||||
granted = future.get();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!granted)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
// The dialog can stay open while another grant is persisted; re-read before saving so this
|
|
||||||
// request adds to the current state instead of replacing it.
|
|
||||||
PluginInstallState latest_state;
|
|
||||||
if (read_install_state(boost::filesystem::path(descriptor.plugin_root), latest_state))
|
|
||||||
state = std::move(latest_state);
|
|
||||||
|
|
||||||
PluginAuditDetail::seed_install_metadata(descriptor, state);
|
|
||||||
|
|
||||||
for (const MissingBucket& bucket : missing_buckets) {
|
|
||||||
std::vector<std::string>* permission_list =
|
|
||||||
PluginAuditDetail::permission_list_for(bucket.category, /*by_event=*/false, state.permissions);
|
|
||||||
if (!permission_list)
|
|
||||||
continue;
|
|
||||||
for (const std::string& target : bucket.targets)
|
|
||||||
if (std::find(permission_list->begin(), permission_list->end(), target) == permission_list->end())
|
|
||||||
permission_list->push_back(target);
|
|
||||||
}
|
|
||||||
|
|
||||||
return write_install_state(boost::filesystem::path(descriptor.plugin_root), state);
|
|
||||||
}
|
|
||||||
|
|
||||||
void PluginAuditManager::record_root_frame()
|
void PluginAuditManager::record_root_frame()
|
||||||
{
|
{
|
||||||
PyObject* frame = reinterpret_cast<PyObject*>(PyEval_GetFrame()); // borrowed reference
|
PyObject* frame = reinterpret_cast<PyObject*>(PyEval_GetFrame()); // borrowed reference
|
||||||
|
|||||||
@@ -13,12 +13,6 @@
|
|||||||
|
|
||||||
namespace Slic3r {
|
namespace Slic3r {
|
||||||
|
|
||||||
struct PluginPermissions;
|
|
||||||
|
|
||||||
// Thread creation exposes no user-supplied target, so both the runtime audit event and a
|
|
||||||
// request_permissions(threading=True) declaration use this fixed target.
|
|
||||||
inline constexpr const char* kThreadPermissionTarget = "thread";
|
|
||||||
|
|
||||||
struct AuditDecision {
|
struct AuditDecision {
|
||||||
bool allowed = true;
|
bool allowed = true;
|
||||||
std::string reason;
|
std::string reason;
|
||||||
@@ -136,12 +130,35 @@ public:
|
|||||||
AuditDecision check_path_access(const boost::filesystem::path& candidate, bool is_write);
|
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);
|
||||||
|
|
||||||
// Ask the user to grant the requested permissions. The request may originate on a plugin
|
// Ask the user to grant the requested filesystem-read paths. The request may originate on a
|
||||||
// load worker, so the implementation marshals the modal dialog to the wx main thread.
|
// plugin load worker, so the implementation marshals the modal dialog to the wx main thread.
|
||||||
// Targets already persisted for the plugin are not shown or asked for again. Returns true
|
// Returns true only when every missing path was granted and persisted; denial aborts the plugin
|
||||||
// only when every missing target was granted and persisted; denial aborts the plugin load
|
// load without adding a permission.
|
||||||
// without adding a permission.
|
bool request_filesystem_read_permissions(const std::string& plugin_key,
|
||||||
bool request_permissions(const std::string& plugin_key, const PluginPermissions& requested);
|
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;
|
||||||
@@ -193,6 +210,7 @@ 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;
|
||||||
|
|||||||
@@ -833,6 +833,29 @@ 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;
|
||||||
@@ -876,14 +899,7 @@ 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);
|
||||||
state.installed_from = entry.is_cloud_plugin() ? "cloud" : "local";
|
apply_install_metadata(state, entry);
|
||||||
// Prefer the descriptor's recorded installed_version (the version fetched from the cloud
|
|
||||||
// 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.enabled = enabled;
|
||||||
state.capabilities = capabilities;
|
state.capabilities = capabilities;
|
||||||
return write_install_state(plugin_dir, state);
|
return write_install_state(plugin_dir, state);
|
||||||
@@ -896,11 +912,7 @@ 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;
|
||||||
state.installed_from = entry.is_cloud_plugin() ? "cloud" : "local";
|
seed_install_metadata(state, entry);
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -186,12 +186,19 @@ 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);
|
||||||
|
|||||||
@@ -1180,12 +1180,8 @@ 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))
|
||||||
state.installed_from = descriptor.is_cloud_plugin() ? "cloud" : "local";
|
seed_install_metadata(state, descriptor);
|
||||||
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;
|
||||||
|
|||||||
@@ -49,7 +49,8 @@ thread_local std::string g_active_plugin_key;
|
|||||||
std::mutex g_registry_mutex;
|
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, PluginPermissions> g_pending_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
|
||||||
@@ -88,7 +89,8 @@ void discard_pending_capture_without_python(const std::string& plugin_key)
|
|||||||
g_pending_package.erase(package);
|
g_pending_package.erase(package);
|
||||||
}
|
}
|
||||||
|
|
||||||
g_pending_permissions.erase(plugin_key);
|
g_pending_fs_read_permissions.erase(plugin_key);
|
||||||
|
g_pending_network_permissions.erase(plugin_key);
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
@@ -113,7 +115,8 @@ void PythonPluginBridge::begin_plugin_capture(const std::string& plugin_key)
|
|||||||
// for this same entry path.
|
// for this same entry path.
|
||||||
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_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.
|
||||||
@@ -144,7 +147,8 @@ std::vector<CapturedCapability> PythonPluginBridge::finalize_plugin_capture(cons
|
|||||||
std::lock_guard<std::mutex> lock(g_registry_mutex);
|
std::lock_guard<std::mutex> lock(g_registry_mutex);
|
||||||
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_permissions.erase(capture_key);
|
g_pending_fs_read_permissions.erase(capture_key);
|
||||||
|
g_pending_network_permissions.erase(capture_key);
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -177,27 +181,47 @@ std::vector<CapturedCapability> PythonPluginBridge::finalize_plugin_capture(cons
|
|||||||
// Permission declarations are collected while register_capabilities() runs so the
|
// Permission declarations are collected while register_capabilities() runs so the
|
||||||
// plugin can describe its needs without touching wx from the Python load worker. Ask
|
// plugin can describe its needs without touching wx from the Python load worker. Ask
|
||||||
// only after registration returns, before capability instances are materialized.
|
// only after registration returns, before capability instances are materialized.
|
||||||
PluginPermissions requested_permissions;
|
std::vector<std::string> fs_read_permissions;
|
||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lock(g_registry_mutex);
|
std::lock_guard<std::mutex> lock(g_registry_mutex);
|
||||||
auto it = g_pending_permissions.find(capture_key);
|
auto it = g_pending_fs_read_permissions.find(capture_key);
|
||||||
if (it != g_pending_permissions.end()) {
|
if (it != g_pending_fs_read_permissions.end()) {
|
||||||
requested_permissions = std::move(it->second);
|
fs_read_permissions = std::move(it->second);
|
||||||
g_pending_permissions.erase(it);
|
g_pending_fs_read_permissions.erase(it);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const bool has_permission_request =
|
if (!fs_read_permissions.empty()) {
|
||||||
!requested_permissions.fs_read.empty() || !requested_permissions.fs_readwrite.empty() ||
|
|
||||||
!requested_permissions.network_http.empty() || !requested_permissions.network_socket.empty() ||
|
|
||||||
!requested_permissions.process.empty() || !requested_permissions.threading.empty();
|
|
||||||
if (has_permission_request) {
|
|
||||||
bool granted = false;
|
bool granted = false;
|
||||||
{
|
{
|
||||||
py::gil_scoped_release release;
|
py::gil_scoped_release release;
|
||||||
granted = PluginAuditManager::instance().request_permissions(plugin_key, requested_permissions);
|
granted = PluginAuditManager::instance().request_filesystem_read_permissions(plugin_key, fs_read_permissions);
|
||||||
}
|
}
|
||||||
if (!granted) {
|
if (!granted) {
|
||||||
error = _u8L("Plugin permission request denied");
|
error = _u8L("Plugin filesystem read permission request denied");
|
||||||
|
BOOST_LOG_TRIVIAL(warning) << error << " for key " << plugin_key;
|
||||||
|
discard_pending_for_key();
|
||||||
|
clear_active_key();
|
||||||
|
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;
|
BOOST_LOG_TRIVIAL(warning) << error << " for key " << plugin_key;
|
||||||
discard_pending_for_key();
|
discard_pending_for_key();
|
||||||
clear_active_key();
|
clear_active_key();
|
||||||
@@ -326,7 +350,8 @@ void PythonPluginBridge::cancel_plugin_capture(const std::string& plugin_key)
|
|||||||
// may already have registered under this key.
|
// may already have registered under this 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_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)
|
||||||
@@ -353,7 +378,8 @@ void PythonPluginBridge::clear_pending_captures()
|
|||||||
(void) pkg.release();
|
(void) pkg.release();
|
||||||
}
|
}
|
||||||
g_pending_package.clear();
|
g_pending_package.clear();
|
||||||
g_pending_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;
|
||||||
}
|
}
|
||||||
@@ -362,7 +388,8 @@ void PythonPluginBridge::clear_pending_captures()
|
|||||||
BOOST_LOG_TRIVIAL(info) << "Clearing " << g_pending_capabilities.size() << " pending Python plugin capture(s)";
|
BOOST_LOG_TRIVIAL(info) << "Clearing " << g_pending_capabilities.size() << " pending Python plugin capture(s)";
|
||||||
g_pending_capabilities.clear();
|
g_pending_capabilities.clear();
|
||||||
g_pending_package.clear();
|
g_pending_package.clear();
|
||||||
g_pending_permissions.clear();
|
g_pending_fs_read_permissions.clear();
|
||||||
|
g_pending_network_permissions.clear();
|
||||||
g_active_plugin_key.clear();
|
g_active_plugin_key.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -562,54 +589,50 @@ 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>& fs_read, const std::vector<std::string>& network) {
|
||||||
const std::vector<std::string>& fs_readwrite,
|
|
||||||
const std::vector<std::string>& network_http,
|
|
||||||
const std::vector<std::string>& network_socket,
|
|
||||||
const std::vector<std::string>& process,
|
|
||||||
bool threading) {
|
|
||||||
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");
|
||||||
|
|
||||||
std::lock_guard<std::mutex> lock(g_registry_mutex);
|
// Validate and canonicalize everything before touching the pending registry, so a
|
||||||
PluginPermissions& requested = g_pending_permissions[g_active_plugin_key];
|
// partially invalid call cannot leave a half-recorded request behind.
|
||||||
|
std::vector<std::string> normalized_network;
|
||||||
const auto add_targets = [](std::vector<std::string>& bucket, const char* name,
|
normalized_network.reserve(network.size());
|
||||||
const std::vector<std::string>& targets) {
|
for (const std::string& url : network) {
|
||||||
for (const std::string& target : targets) {
|
const std::string normalized = PluginAuditManager::normalize_network_url(url);
|
||||||
if (target.empty())
|
if (normalized.empty())
|
||||||
throw py::value_error(std::string("request_permissions(") + name +
|
throw py::value_error(
|
||||||
"=...) does not accept empty values");
|
"request_permissions(network=...) requires an http:// or https:// URL with a host");
|
||||||
if (std::find(bucket.begin(), bucket.end(), target) == bucket.end())
|
if (std::find(normalized_network.begin(), normalized_network.end(), normalized) == normalized_network.end())
|
||||||
bucket.push_back(target);
|
normalized_network.push_back(normalized);
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
add_targets(requested.fs_read, "fs_read", fs_read);
|
for (const std::string& path : fs_read) {
|
||||||
add_targets(requested.fs_readwrite, "fs_readwrite", fs_readwrite);
|
if (path.empty())
|
||||||
add_targets(requested.network_http, "network_http", network_http);
|
throw py::value_error("request_permissions(fs_read=...) does not accept empty paths");
|
||||||
add_targets(requested.network_socket, "network_socket", network_socket);
|
}
|
||||||
add_targets(requested.process, "process", process);
|
|
||||||
|
|
||||||
if (threading && std::find(requested.threading.begin(), requested.threading.end(),
|
std::lock_guard<std::mutex> lock(g_registry_mutex);
|
||||||
kThreadPermissionTarget) == requested.threading.end())
|
auto& requested_fs = g_pending_fs_read_permissions[g_active_plugin_key];
|
||||||
requested.threading.emplace_back(kThreadPermissionTarget);
|
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("fs_read") = std::vector<std::string>{}, py::arg("network") = std::vector<std::string>{},
|
||||||
py::arg("fs_readwrite") = std::vector<std::string>{},
|
|
||||||
py::arg("network_http") = std::vector<std::string>{},
|
|
||||||
py::arg("network_socket") = std::vector<std::string>{},
|
|
||||||
py::arg("process") = std::vector<std::string>{},
|
|
||||||
py::arg("threading") = false,
|
|
||||||
R"pbdoc(Request permissions while OrcaSlicer loads your module.
|
R"pbdoc(Request permissions while OrcaSlicer loads your module.
|
||||||
|
|
||||||
Each bucket takes the target strings the plugin expects the corresponding audit event to
|
`fs_read` lists filesystem paths to read. `network` lists http(s) URLs; each is reduced to its
|
||||||
report: filesystem paths for fs_read and fs_readwrite, URLs for network_http, socket addresses
|
scheme and host, so one grant covers every path, query and port on that host. The host presents
|
||||||
for network_socket, and commands for process. threading=True requests permission to start
|
the requests to the user after register_capabilities() returns. Denying a request aborts the
|
||||||
threads. Targets already granted to the plugin are not asked for again.
|
plugin load.)pbdoc");
|
||||||
|
|
||||||
The host presents one request to the user after register_capabilities() returns and before any
|
|
||||||
capability instance is created. Denying the 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())
|
||||||
|
|||||||
@@ -351,6 +351,93 @@ 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");
|
||||||
|
|||||||
Reference in new issue
Block a user