mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-10-19 21:51:06 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c791532c6 |
No files matched your search
@@ -0,0 +1,39 @@
|
||||
# /// script
|
||||
# requires-python = ">=3.12"
|
||||
#
|
||||
# [tool.orcaslicer.plugin]
|
||||
# name = "Network Permission Probe"
|
||||
# description = "Tests host-based network permission prompts and matching"
|
||||
# author = "OrcaSlicer"
|
||||
# version = "0.1.0"
|
||||
# ///
|
||||
"""Local probe for the orca.request_permissions(network=...) API.
|
||||
|
||||
Install this file as a local plugin, approve https://example.com, then run the
|
||||
script capability more than once. Each run changes the query string; the saved
|
||||
scheme/host grant should prevent another prompt for that same host.
|
||||
"""
|
||||
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
import orca
|
||||
|
||||
|
||||
class NetworkPermissionProbe(orca.script.ScriptPluginCapabilityBase):
|
||||
def get_name(self):
|
||||
return "Network Permission Probe"
|
||||
|
||||
def execute(self):
|
||||
# User-triggered GET to a reserved example domain; varying the query tests
|
||||
# that permission matching is not tied to the complete URL.
|
||||
url = f"https://example.com/?permission_probe={time.time_ns()}"
|
||||
with urllib.request.urlopen(url, timeout=10) as response:
|
||||
return orca.ExecutionResult.success(f"Network permission probe: HTTP {response.status}")
|
||||
|
||||
|
||||
@orca.plugin
|
||||
class NetworkPermissionProbePackage(orca.base):
|
||||
def register_capabilities(self):
|
||||
orca.request_permissions(network=["https://example.com"])
|
||||
orca.register_capability(NetworkPermissionProbe)
|
||||
@@ -5,10 +5,14 @@
|
||||
#include "libslic3r/libslic3r.h" // GCODEVIEWER_APP_KEY, and SLIC3R_APP_KEY via libslic3r_version.h
|
||||
#include "libslic3r_version.h"
|
||||
|
||||
#include <boost/algorithm/string/case_conv.hpp>
|
||||
#include <boost/algorithm/string/predicate.hpp>
|
||||
#include <boost/algorithm/string/trim.hpp>
|
||||
#include <boost/asio/ip/address.hpp>
|
||||
#include <boost/filesystem/path.hpp>
|
||||
#include <boost/filesystem/operations.hpp>
|
||||
#include <boost/log/trivial.hpp>
|
||||
#include <boost/system/error_code.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
@@ -146,6 +150,205 @@ 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;
|
||||
}
|
||||
|
||||
// The only network events whose target can be a resolved address instead of the host the plugin
|
||||
// asked for. They may fall back to a network-approved call site; every other network event must
|
||||
// name a host or URL the hook can match directly.
|
||||
static bool is_resolved_address_event(const std::string& event_name)
|
||||
{
|
||||
return event_name == "socket.connect" || event_name == "socket.sendto";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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) {
|
||||
if (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;
|
||||
}
|
||||
|
||||
bool PluginAuditManager::is_ip_address_target(const std::string& target)
|
||||
{
|
||||
if (target.empty() || target.find("://") != std::string::npos)
|
||||
return false;
|
||||
|
||||
std::string host = target;
|
||||
if (host.size() >= 2 && host.front() == '[' && host.back() == ']')
|
||||
host = host.substr(1, host.size() - 2);
|
||||
|
||||
// Drop an IPv6 zone identifier before parsing; it is not part of the address literal.
|
||||
const size_t zone = host.find('%');
|
||||
if (zone != std::string::npos)
|
||||
host.erase(zone);
|
||||
|
||||
boost::system::error_code ec;
|
||||
boost::asio::ip::make_address(host, ec);
|
||||
return !ec;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Path safety
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -296,18 +499,16 @@ void PluginAuditManager::add_denied_filename(const std::string& filename)
|
||||
std::vector<std::string> PluginAuditManager::default_denied_filenames()
|
||||
{
|
||||
// AppConfig::config_path() picks .conf vs .ini on the USE_JSON_CONFIG ifdef and the app key
|
||||
// on its mode, and is a non-static member we cannot call without an instance. Denying all
|
||||
// on its mode, and is a non-static member we cannot call without an instance. Denying all
|
||||
// four names is cheaper and more robust than replicating that; the two unused names cost one
|
||||
// string comparison each. Keep the plugin install-state sidecar here too: it controls which
|
||||
// packages load on startup and must remain host-managed. Single-sourced so install_hook() and
|
||||
// the tests seed from the same list and cannot drift apart.
|
||||
// string comparison each. Single-sourced here so install_hook() and the tests seed from the
|
||||
// same list and cannot drift apart.
|
||||
return {
|
||||
SLIC3R_APP_KEY ".conf",
|
||||
GCODEVIEWER_APP_KEY ".conf",
|
||||
SLIC3R_APP_KEY ".ini",
|
||||
GCODEVIEWER_APP_KEY ".ini",
|
||||
secret_constants::USER_SECRET_FILENAME,
|
||||
".install_state.json",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -473,6 +674,39 @@ AuditDecision PluginAuditManager::check_open(const std::string& path_str, const
|
||||
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)
|
||||
{
|
||||
@@ -494,60 +728,80 @@ bool PluginAuditManager::request_filesystem_read_permissions(const std::string&
|
||||
if (missing.empty())
|
||||
return true;
|
||||
|
||||
if (wxTheApp == nullptr || GUI::wxGetApp().is_closing())
|
||||
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;
|
||||
|
||||
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;
|
||||
}
|
||||
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)
|
||||
{
|
||||
m_last_violation = violation;
|
||||
@@ -579,13 +833,38 @@ bool PluginAuditManager::last_violation(AuditViolation& violation) const
|
||||
|
||||
bool PluginAuditManager::has_approved_ancestor(const std::string& plugin_key,
|
||||
const std::vector<std::string>& call_site_ids) const
|
||||
{
|
||||
return has_call_site(m_approved_call_sites, plugin_key, call_site_ids);
|
||||
}
|
||||
|
||||
void PluginAuditManager::record_approved_call_sites(const std::string& plugin_key,
|
||||
const std::vector<std::string>& call_site_ids)
|
||||
{
|
||||
record_call_sites(m_approved_call_sites, plugin_key, call_site_ids);
|
||||
}
|
||||
|
||||
bool PluginAuditManager::has_approved_network_ancestor(const std::string& plugin_key,
|
||||
const std::vector<std::string>& call_site_ids) const
|
||||
{
|
||||
return has_call_site(m_approved_network_call_sites, plugin_key, call_site_ids);
|
||||
}
|
||||
|
||||
void PluginAuditManager::record_approved_network_call_sites(const std::string& plugin_key,
|
||||
const std::vector<std::string>& call_site_ids)
|
||||
{
|
||||
record_call_sites(m_approved_network_call_sites, plugin_key, call_site_ids);
|
||||
}
|
||||
|
||||
bool PluginAuditManager::has_call_site(const CallSiteRegistry& registry,
|
||||
const std::string& plugin_key,
|
||||
const std::vector<std::string>& call_site_ids) const
|
||||
{
|
||||
if (plugin_key.empty() || call_site_ids.empty())
|
||||
return false;
|
||||
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
auto it = m_approved_call_sites.find(plugin_key);
|
||||
if (it == m_approved_call_sites.end())
|
||||
auto it = registry.find(plugin_key);
|
||||
if (it == registry.end())
|
||||
return false;
|
||||
|
||||
for (const auto& id : call_site_ids)
|
||||
@@ -594,14 +873,15 @@ bool PluginAuditManager::has_approved_ancestor(const std::string& p
|
||||
return false;
|
||||
}
|
||||
|
||||
void PluginAuditManager::record_approved_call_sites(const std::string& plugin_key,
|
||||
const std::vector<std::string>& call_site_ids)
|
||||
void PluginAuditManager::record_call_sites(CallSiteRegistry& registry,
|
||||
const std::string& plugin_key,
|
||||
const std::vector<std::string>& call_site_ids)
|
||||
{
|
||||
if (plugin_key.empty() || call_site_ids.empty())
|
||||
return;
|
||||
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
auto& approved = m_approved_call_sites[plugin_key];
|
||||
auto& approved = registry[plugin_key];
|
||||
approved.insert(call_site_ids.begin(), call_site_ids.end());
|
||||
}
|
||||
|
||||
@@ -618,6 +898,39 @@ PyObject* tuple_item(PyObject* args, Py_ssize_t 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)
|
||||
{
|
||||
if (!object)
|
||||
@@ -629,26 +942,12 @@ std::string python_path(PyObject* object)
|
||||
return {};
|
||||
}
|
||||
|
||||
const char* path = PyUnicode_Check(path_object) ? PyUnicode_AsUTF8(path_object) : PyBytes_AsString(path_object);
|
||||
std::string result = path ? path : "";
|
||||
std::string result;
|
||||
python_text(path_object, result);
|
||||
Py_DECREF(path_object);
|
||||
if (!path)
|
||||
PyErr_Clear();
|
||||
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)
|
||||
{
|
||||
if (!object)
|
||||
@@ -665,6 +964,27 @@ std::string python_str(PyObject* object)
|
||||
return result;
|
||||
}
|
||||
|
||||
// Network audit events expose different shapes for the same thing: a URL, a bare host, or an
|
||||
// address tuple such as ("host", 443). Reduce the argument to the host it names, leaving the
|
||||
// scheme and port to be judged by the permission matcher. None / an empty tuple has no target.
|
||||
std::string python_network_host(PyObject* object)
|
||||
{
|
||||
if (!object || object == Py_None)
|
||||
return {};
|
||||
|
||||
if (PyTuple_Check(object)) {
|
||||
if (PyTuple_GET_SIZE(object) == 0)
|
||||
return {};
|
||||
return python_network_host(PyTuple_GET_ITEM(object, 0));
|
||||
}
|
||||
|
||||
std::string text;
|
||||
if (python_text(object, text))
|
||||
return text;
|
||||
|
||||
return python_str(object);
|
||||
}
|
||||
|
||||
std::vector<std::string> call_site_identities(const std::string& plugin_root)
|
||||
{
|
||||
std::vector<std::string> ids;
|
||||
@@ -768,8 +1088,10 @@ std::vector<std::string> audit_targets(const std::string& event_name, AuditEvent
|
||||
|
||||
const auto it = audit_target_arg_indices.find(event_name);
|
||||
if (it != audit_target_arg_indices.end()) {
|
||||
const bool network_event = is_network_category(category);
|
||||
for (const Py_ssize_t index : it->second) {
|
||||
std::string value = python_str(tuple_item(args, index));
|
||||
std::string value = network_event ? python_network_host(tuple_item(args, index))
|
||||
: python_str(tuple_item(args, index));
|
||||
if (!value.empty()) {
|
||||
targets.push_back(std::move(value));
|
||||
break;
|
||||
@@ -799,6 +1121,20 @@ bool has_permission(const std::vector<std::string>& granted, const std::string&
|
||||
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,
|
||||
PluginInstallState& state,
|
||||
std::vector<std::string>& permission_list,
|
||||
@@ -808,15 +1144,7 @@ bool persist_permission(const std::string& plugin_key,
|
||||
if (!PluginManager::instance().try_get_plugin_descriptor(plugin_key, descriptor) || descriptor.plugin_root.empty())
|
||||
return false;
|
||||
|
||||
// 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;
|
||||
}
|
||||
seed_install_metadata(state, descriptor);
|
||||
|
||||
if (!has_permission(permission_list, target))
|
||||
permission_list.push_back(target);
|
||||
@@ -895,11 +1223,10 @@ bool prompt_for_targets(AuditEventCategory category, const std::string& plugin_n
|
||||
for (const auto& target : unresolved)
|
||||
target_list += wxString::FromUTF8(target.c_str()) + "\n";
|
||||
|
||||
wxMessageDialog dialog(nullptr,
|
||||
audit_message(category, wxString::FromUTF8(plugin_name.c_str()),
|
||||
wxString::FromUTF8(event_name.c_str()), target_list, unresolved.size()),
|
||||
_L("Plugin permission request"), wxYES_NO | wxICON_WARNING);
|
||||
return dialog.ShowModal() == wxID_YES;
|
||||
return show_permission_dialog(
|
||||
audit_message(category, wxString::FromUTF8(plugin_name.c_str()), wxString::FromUTF8(event_name.c_str()),
|
||||
target_list, unresolved.size()),
|
||||
_L("Plugin permission request"));
|
||||
}
|
||||
|
||||
// Records a grant in the plugin's sidecar so it is not asked again. Reads the install state
|
||||
@@ -959,7 +1286,30 @@ int decide_audited_event(PluginAuditManager& mgr,
|
||||
const std::vector<std::string>& call_site_ids)
|
||||
{
|
||||
std::vector<std::string> unresolved = targets;
|
||||
if (permission_list) {
|
||||
if (is_network_category(category)) {
|
||||
if (targets.empty()) {
|
||||
// socket.__new__ and http.client.send carry no host. Any network grant already lets
|
||||
// the plugin open a connection, and every host-bearing event still matches its own
|
||||
// grant, so the targetless events are covered by an existing grant instead of
|
||||
// prompting every run. Deliberately do not record a call site here: a targetless
|
||||
// approval must not become an ancestor that later network events could use to skip
|
||||
// their own host check.
|
||||
if (has_any_network_permission(state.permissions))
|
||||
return 0;
|
||||
} else {
|
||||
unresolved.clear();
|
||||
for (const auto& target : targets)
|
||||
if (!has_network_permission(state.permissions, target))
|
||||
unresolved.push_back(target);
|
||||
if (unresolved.empty()) {
|
||||
// Remember the stdlib frames for this approved host. The network ancestor cache
|
||||
// is only consulted for a resolved-IP socket.connect/sendto, so this cannot
|
||||
// suppress a later hostname or URL event.
|
||||
mgr.record_approved_network_call_sites(plugin_key, call_site_ids);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
} else if (permission_list) {
|
||||
unresolved.clear();
|
||||
for (const auto& target : targets)
|
||||
if (!has_permission(*permission_list, target))
|
||||
@@ -975,6 +1325,12 @@ int decide_audited_event(PluginAuditManager& mgr,
|
||||
for (const auto& target : unresolved)
|
||||
persist_permission(plugin_key, state, *permission_list, target);
|
||||
|
||||
// A target-bearing network approval may cover the resolved address of the same connection.
|
||||
// A targetless approval does not: it carries no host, so it must not create an ancestor that
|
||||
// a later hostname/URL check could be skipped by (and the network cache is IP-only anyway).
|
||||
if (is_network_category(category) && !targets.empty())
|
||||
mgr.record_approved_network_call_sites(plugin_key, call_site_ids);
|
||||
|
||||
mgr.record_approved_call_sites(plugin_key, call_site_ids);
|
||||
return 0;
|
||||
}
|
||||
@@ -1026,12 +1382,28 @@ int PluginAuditManager::audit_hook(const char* event, PyObject* args, void* user
|
||||
PluginDescriptor plugin_descriptor;
|
||||
PluginManager::instance().try_get_plugin_descriptor(mgr->current_plugin(), plugin_descriptor);
|
||||
|
||||
// Some plugins call other audited events, e.g. urlib.request will call socket.connect. So this is so that if urlib.request
|
||||
// was already approved, socket.connect won't trigger another permission dialog.
|
||||
// Some plugins call other audited events, e.g. urllib.request will call socket.connect.
|
||||
// Collect the stdlib frames now so an approved network host can cover the resolved address
|
||||
// of the same connection without covering another host.
|
||||
const std::vector<std::string> call_site_ids =
|
||||
PluginAuditDetail::call_site_identities(plugin_descriptor.plugin_root);
|
||||
if (mgr->has_approved_ancestor(mgr->current_plugin(), call_site_ids))
|
||||
|
||||
// A general call-site approval may cover nested events for the same logical action. Network
|
||||
// events use a separate rule: a hostname/URL target must match its own grant even when a
|
||||
// parent event was approved, and targetless events are handled by the any-grant rule. Only a
|
||||
// resolved-address connect/sendto (target is an IP literal) may use an approval recorded for
|
||||
// an earlier network host event, because its own target carries no hostname to match.
|
||||
if (is_network_category(event_type)) {
|
||||
const bool all_resolved_addresses =
|
||||
!targets.empty() && std::all_of(targets.begin(), targets.end(), [](const std::string& target) {
|
||||
return PluginAuditManager::is_ip_address_target(target);
|
||||
});
|
||||
if (is_resolved_address_event(event_name) && all_resolved_addresses &&
|
||||
mgr->has_approved_network_ancestor(mgr->current_plugin(), call_site_ids))
|
||||
return 0;
|
||||
} else if (mgr->has_approved_ancestor(mgr->current_plugin(), call_site_ids)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// A filesystem target that resolves entirely inside a pre-determined allowed root -- the
|
||||
// plugin system's own data_dir() tree (which holds each plugin's storage folder and the
|
||||
@@ -1078,9 +1450,8 @@ void PluginAuditManager::install_hook()
|
||||
// "cert" denied-path keyword seeded below.
|
||||
add_global_allowed_root(resources_dir(), /*allow_write=*/false);
|
||||
|
||||
// The user's app config and cloud credentials live directly inside data_dir(), and plugin
|
||||
// install state lives beneath it, so the root just granted would otherwise expose them to
|
||||
// plugins. Deny them by name.
|
||||
// The user's app config and cloud credentials live directly inside data_dir(), so the
|
||||
// root just granted would otherwise expose them to any plugin. Deny them by name.
|
||||
//
|
||||
// Seeded here rather than by each secret's owner because install_hook() runs during lazy
|
||||
// interpreter init and therefore provably precedes any plugin bytecode, whereas
|
||||
|
||||
@@ -138,6 +138,33 @@ public:
|
||||
bool request_filesystem_read_permissions(const std::string& plugin_key,
|
||||
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. 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);
|
||||
|
||||
// True when `target` is a bare IPv4/IPv6 literal (optionally bracketed). A network event
|
||||
// carrying an IP literal has no hostname the audit hook can match, so it is the only kind of
|
||||
// network event allowed to use an approved network call-site ancestor.
|
||||
static bool is_ip_address_target(const std::string& target);
|
||||
|
||||
void report_violation(const AuditViolation& violation);
|
||||
bool audit_denial_pending() const;
|
||||
void clear_audit_denial();
|
||||
@@ -151,9 +178,20 @@ public:
|
||||
// is recorded here by (filename, function, first line) identity. A later event whose own
|
||||
// ancestor chain still contains one of those frames is the same logical action seen from
|
||||
// a deeper layer, so it is auto-approved instead of prompting again.
|
||||
//
|
||||
// Network events do not use this general cache: see the network call-site cache below.
|
||||
bool has_approved_ancestor(const std::string& plugin_key, const std::vector<std::string>& call_site_ids) const;
|
||||
void record_approved_call_sites(const std::string& plugin_key, const std::vector<std::string>& call_site_ids);
|
||||
|
||||
// --- network call-site cache ---
|
||||
// Separate from the general cascade above so a network approval can only suppress a later
|
||||
// network event with no hostname to check: a resolved address (socket.connect/sendto with an
|
||||
// IP literal). Hostname and URL events are never auto-approved by an ancestor; they must
|
||||
// match their own grant. Targetless events are handled by the "any network grant" rule and
|
||||
// never record an entry here.
|
||||
bool has_approved_network_ancestor(const std::string& plugin_key, const std::vector<std::string>& call_site_ids) const;
|
||||
void record_approved_network_call_sites(const std::string& plugin_key, const std::vector<std::string>& call_site_ids);
|
||||
|
||||
bool verbose_events = true;
|
||||
|
||||
private:
|
||||
@@ -170,12 +208,23 @@ private:
|
||||
static thread_local bool m_has_last_violation;
|
||||
static thread_local AuditViolation m_last_violation;
|
||||
|
||||
// mutable: is_denied_filename() and has_approved_ancestor() are const queries that must lock.
|
||||
// mutable: the deny and ancestor queries are const but must lock.
|
||||
mutable std::mutex m_mutex;
|
||||
|
||||
using CallSiteRegistry = std::unordered_map<std::string, std::unordered_set<std::string>>;
|
||||
|
||||
// Shared lookup/insert mechanics for the two registries below. The caller selects the
|
||||
// registry, so the general and network approvals remain independent.
|
||||
bool has_call_site(const CallSiteRegistry& registry, const std::string& plugin_key,
|
||||
const std::vector<std::string>& call_site_ids) const;
|
||||
void record_call_sites(CallSiteRegistry& registry, const std::string& plugin_key,
|
||||
const std::vector<std::string>& call_site_ids);
|
||||
|
||||
std::vector<AllowedRoot> m_global_allowed_roots;
|
||||
std::vector<std::string> m_denied_filenames;
|
||||
std::vector<std::string> m_denied_path_keywords;
|
||||
std::unordered_map<std::string, std::unordered_set<std::string>> m_approved_call_sites; // plugin_key -> call-site ids
|
||||
CallSiteRegistry m_approved_call_sites;
|
||||
CallSiteRegistry m_approved_network_call_sites;
|
||||
};
|
||||
|
||||
// RAII guard that sets the current plugin key and capability name, restoring the previous
|
||||
|
||||
@@ -832,6 +832,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)
|
||||
{
|
||||
namespace fs = boost::filesystem;
|
||||
@@ -874,16 +897,9 @@ 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
|
||||
// during register_capabilities() or by a previous runtime audit prompt.
|
||||
read_install_state(plugin_dir, state);
|
||||
state.installed_from = entry.is_cloud_plugin() ? "cloud" : "local";
|
||||
// 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.capabilities = capabilities;
|
||||
apply_install_metadata(state, entry);
|
||||
state.enabled = enabled;
|
||||
state.capabilities = capabilities;
|
||||
return write_install_state(plugin_dir, state);
|
||||
}
|
||||
|
||||
@@ -894,11 +910,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
|
||||
// while preserving permissions once the package is loaded, via the overload above.
|
||||
PluginInstallState state;
|
||||
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;
|
||||
seed_install_metadata(state, entry);
|
||||
return write_install_state(plugin_dir, state);
|
||||
}
|
||||
|
||||
|
||||
@@ -184,12 +184,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.
|
||||
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.
|
||||
bool write_install_state(const boost::filesystem::path& plugin_dir, const PluginDescriptor& entry, bool enabled,
|
||||
const std::vector<std::pair<std::string, bool>>& capabilities);
|
||||
// Convenience overload: write(dir, entry, /*enabled=*/true, /*capabilities=*/{}).
|
||||
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
|
||||
// sidecar was present and valid.
|
||||
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);
|
||||
PluginInstallState state;
|
||||
if (!read_install_state(root, state)) {
|
||||
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();
|
||||
}
|
||||
if (!read_install_state(root, state))
|
||||
seed_install_metadata(state, descriptor);
|
||||
state.enabled = false;
|
||||
if (!write_install_state(root, state))
|
||||
return;
|
||||
|
||||
@@ -50,6 +50,7 @@ std::mutex g_registry_mutex;
|
||||
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, std::vector<std::string>> g_pending_fs_read_permissions;
|
||||
std::unordered_map<std::string, std::vector<std::string>> g_pending_network_permissions;
|
||||
struct PluginInstanceHandle
|
||||
{
|
||||
// The C++ plugin interface points into a Python object. Keep both alive through one
|
||||
@@ -89,6 +90,7 @@ void discard_pending_capture_without_python(const std::string& plugin_key)
|
||||
}
|
||||
|
||||
g_pending_fs_read_permissions.erase(plugin_key);
|
||||
g_pending_network_permissions.erase(plugin_key);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
@@ -114,6 +116,7 @@ void PythonPluginBridge::begin_plugin_capture(const std::string& plugin_key)
|
||||
g_pending_capabilities.erase(plugin_key);
|
||||
g_pending_package.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
|
||||
// Python code on this thread are attributed to this plugin.
|
||||
@@ -145,6 +148,7 @@ std::vector<CapturedCapability> PythonPluginBridge::finalize_plugin_capture(cons
|
||||
g_pending_capabilities.erase(capture_key);
|
||||
g_pending_package.erase(capture_key);
|
||||
g_pending_fs_read_permissions.erase(capture_key);
|
||||
g_pending_network_permissions.erase(capture_key);
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -200,6 +204,30 @@ std::vector<CapturedCapability> PythonPluginBridge::finalize_plugin_capture(cons
|
||||
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) {
|
||||
log_python_exception_keep(err);
|
||||
error = err.what();
|
||||
@@ -323,6 +351,7 @@ void PythonPluginBridge::cancel_plugin_capture(const std::string& plugin_key)
|
||||
g_pending_capabilities.erase(plugin_key);
|
||||
g_pending_package.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)
|
||||
@@ -350,6 +379,7 @@ void PythonPluginBridge::clear_pending_captures()
|
||||
}
|
||||
g_pending_package.clear();
|
||||
g_pending_fs_read_permissions.clear();
|
||||
g_pending_network_permissions.clear();
|
||||
g_active_plugin_key.clear();
|
||||
return;
|
||||
}
|
||||
@@ -359,6 +389,7 @@ void PythonPluginBridge::clear_pending_captures()
|
||||
g_pending_capabilities.clear();
|
||||
g_pending_package.clear();
|
||||
g_pending_fs_read_permissions.clear();
|
||||
g_pending_network_permissions.clear();
|
||||
g_active_plugin_key.clear();
|
||||
}
|
||||
|
||||
@@ -558,24 +589,50 @@ void bind_python_api(pybind11::module_& m)
|
||||
|
||||
m.def(
|
||||
"request_permissions",
|
||||
[](const std::vector<std::string>& fs_read) {
|
||||
[](const std::vector<std::string>& fs_read, const std::vector<std::string>& network) {
|
||||
if (g_active_plugin_key.empty())
|
||||
throw py::value_error("request_permissions() called outside plugin discovery context");
|
||||
|
||||
std::lock_guard<std::mutex> lock(g_registry_mutex);
|
||||
auto& requested = g_pending_fs_read_permissions[g_active_plugin_key];
|
||||
// Validate and canonicalize everything before touching the pending registry, so a
|
||||
// partially invalid call cannot leave a half-recorded request behind.
|
||||
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) {
|
||||
if (path.empty())
|
||||
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>{},
|
||||
R"pbdoc(Request filesystem read permissions while OrcaSlicer loads your module.
|
||||
py::arg("fs_read") = std::vector<std::string>{}, py::arg("network") = std::vector<std::string>{},
|
||||
R"pbdoc(Request permissions while OrcaSlicer loads your module.
|
||||
|
||||
The host presents the request to the user after register_capabilities() returns. Denying the
|
||||
request aborts the plugin load.)pbdoc");
|
||||
`fs_read` lists filesystem paths to read. `network` lists http(s) URLs; each is reduced to its
|
||||
scheme and host, so one grant covers every path, query and port on that host. The host presents
|
||||
the requests to the user after register_capabilities() returns. Denying a request aborts the
|
||||
plugin load.)pbdoc");
|
||||
|
||||
m.def("plugin", [](py::object cls) {
|
||||
if (g_active_plugin_key.empty())
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
#include <boost/filesystem.hpp>
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
using namespace Slic3r;
|
||||
namespace fs = boost::filesystem;
|
||||
@@ -43,7 +44,7 @@ void seed_denied_keywords()
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("Plugin audit denies host-managed filenames anywhere", "[audit]")
|
||||
TEST_CASE("Plugin audit denies app config and token filenames anywhere", "[audit]")
|
||||
{
|
||||
seed_denied_names();
|
||||
const PluginAuditManager& mgr = PluginAuditManager::instance();
|
||||
@@ -55,7 +56,6 @@ TEST_CASE("Plugin audit denies host-managed filenames anywhere", "[audit]")
|
||||
CHECK(mgr.is_denied_filename(fs::path(SLIC3R_APP_KEY ".ini")));
|
||||
CHECK(mgr.is_denied_filename(fs::path(GCODEVIEWER_APP_KEY ".ini")));
|
||||
CHECK(mgr.is_denied_filename(fs::path(secret_constants::USER_SECRET_FILENAME)));
|
||||
CHECK(mgr.is_denied_filename(fs::path(".install_state.json")));
|
||||
}
|
||||
|
||||
SECTION("companions holding the same secrets are denied by the prefix rule")
|
||||
@@ -111,7 +111,6 @@ TEST_CASE("Plugin audit deny beats allowed roots", "[audit]")
|
||||
|
||||
const fs::path conf = fs::path(data_dir()) / (SLIC3R_APP_KEY ".conf");
|
||||
const fs::path token = fs::path(data_dir()) / secret_constants::USER_SECRET_FILENAME;
|
||||
const fs::path install_state = fs::path(data_dir()) / "plugins" / "example" / ".install_state.json";
|
||||
|
||||
SECTION("a non-denied file inside the allowed root is writable (root really grants writes)")
|
||||
{
|
||||
@@ -152,17 +151,6 @@ TEST_CASE("Plugin audit deny beats allowed roots", "[audit]")
|
||||
CHECK_FALSE(decision.allowed);
|
||||
}
|
||||
|
||||
SECTION("plugin install state is blocked for reads and writes despite the allowed root")
|
||||
{
|
||||
AuditDecision read_decision = mgr.check_open(install_state.string(), "r");
|
||||
CHECK_FALSE(read_decision.allowed);
|
||||
CHECK(read_decision.reason == "denied filename");
|
||||
|
||||
AuditDecision write_decision = mgr.check_open(install_state.string(), "w");
|
||||
CHECK_FALSE(write_decision.allowed);
|
||||
CHECK(write_decision.reason == "denied filename");
|
||||
}
|
||||
|
||||
SECTION("a traversal path resolving to the config is blocked")
|
||||
{
|
||||
const fs::path traversal = fs::path(data_dir()) / "plugins" / ".." / (SLIC3R_APP_KEY ".conf");
|
||||
@@ -338,6 +326,117 @@ 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("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 limits network ancestor approvals to resolved addresses", "[audit]")
|
||||
{
|
||||
SECTION("IP literals are distinguished from hostnames and URLs")
|
||||
{
|
||||
CHECK(PluginAuditManager::is_ip_address_target("127.0.0.1"));
|
||||
CHECK(PluginAuditManager::is_ip_address_target("93.184.216.34"));
|
||||
CHECK(PluginAuditManager::is_ip_address_target("::1"));
|
||||
CHECK(PluginAuditManager::is_ip_address_target("2001:db8::1"));
|
||||
CHECK(PluginAuditManager::is_ip_address_target("[2001:db8::1]"));
|
||||
|
||||
CHECK_FALSE(PluginAuditManager::is_ip_address_target("api.example.com"));
|
||||
CHECK_FALSE(PluginAuditManager::is_ip_address_target("localhost"));
|
||||
CHECK_FALSE(PluginAuditManager::is_ip_address_target("https://api.example.com"));
|
||||
CHECK_FALSE(PluginAuditManager::is_ip_address_target(""));
|
||||
}
|
||||
|
||||
SECTION("a network approval is kept apart from the general call-site cascade")
|
||||
{
|
||||
PluginAuditManager& mgr = PluginAuditManager::instance();
|
||||
const std::string plugin_key = "plugin-audit-network-ancestor";
|
||||
const std::string other_key = "plugin-audit-network-ancestor-other";
|
||||
|
||||
mgr.record_approved_network_call_sites(plugin_key, {"frame:network"});
|
||||
mgr.record_approved_call_sites(plugin_key, {"frame:general"});
|
||||
|
||||
CHECK(mgr.has_approved_network_ancestor(plugin_key, {"frame:network"}));
|
||||
CHECK_FALSE(mgr.has_approved_network_ancestor(plugin_key, {"frame:general"}));
|
||||
CHECK_FALSE(mgr.has_approved_network_ancestor(other_key, {"frame:network"}));
|
||||
|
||||
// The network approval must not leak into the general cascade used by other categories.
|
||||
CHECK_FALSE(mgr.has_approved_ancestor(plugin_key, {"frame:network"}));
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Plugin audit a scoped root can also be registered read-only", "[audit]")
|
||||
{
|
||||
ScopedDataDir data_dir_guard("plugin-audit-scoped-readonly");
|
||||
|
||||
Reference in new issue
Block a user