Compare commits

...
Author SHA1 Message Date
Ian Chua 6c791532c6 fix: update api to allow requesting network permissions 2026-10-20 01:15:14 +08:00
Lam Wei Lun 4c2ae6d202 Keep Auto Drop off for projects saved by 2.4.x (#16633)
2.4.0 to 2.4.2 wrote auto_drop on each build item, and 2.4.x objects
carry no auto_drop attribute in model_settings.config. The importer only
read the object attribute and treated a missing one as on, so floating
objects were dropped to the bed on load and the flag was rewritten as on
on the next save.

Record object ids with an item auto_drop="0" and use that as the fallback
when the object has no attribute. The attribute's presence is now tracked
so the fallback applies only when it is absent. Any item set to off turns
auto drop off for the object, since the flag is shared by all instances.
2026-10-19 16:08:59 +08:00
HanifKoh ac022b56ad Wait for the Close Frame in the Moonraker Status Stream Test (#16635)
The fake printer counts the agent's close frame in its read handler, but
Beast answers the close before that read completes, so stop() can return
and the test can read the counter before it is incremented. On a loaded
runner the check saw 0.

Wait for the counter like the other fake-printer counters do, then keep
the exact-count check.
2026-10-19 16:06:41 +08:00
11 changed files with 809 additions and 122 deletions

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)
+13 -3
View File
@@ -988,7 +988,7 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
MetadataList metadata; MetadataList metadata;
VolumeMetadataList volumes; VolumeMetadataList volumes;
bool auto_drop { true }; std::optional<bool> auto_drop;
}; };
struct CutObjectInfo struct CutObjectInfo
@@ -1208,6 +1208,7 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
//IdToGeometryMap m_orig_geometries; // backup & restore //IdToGeometryMap m_orig_geometries; // backup & restore
CurrentConfig m_curr_config; CurrentConfig m_curr_config;
IdToMetadataMap m_objects_metadata; IdToMetadataMap m_objects_metadata;
std::set<int> m_items_auto_drop_off; // object ids with a build item auto_drop="0" (2.4.x layout)
IdToCutObjectInfoMap m_cut_object_infos; IdToCutObjectInfoMap m_cut_object_infos;
IdToLayerHeightsProfileMap m_layer_heights_profiles; IdToLayerHeightsProfileMap m_layer_heights_profiles;
IdToLayerConfigRangesMap m_layer_config_ranges; IdToLayerConfigRangesMap m_layer_config_ranges;
@@ -1463,6 +1464,7 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
m_objects.clear(); m_objects.clear();
m_instances.clear(); m_instances.clear();
m_objects_metadata.clear(); m_objects_metadata.clear();
m_items_auto_drop_off.clear();
m_curr_metadata_name.clear(); m_curr_metadata_name.clear();
m_curr_characters.clear(); m_curr_characters.clear();
@@ -1502,6 +1504,7 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
m_curr_config.object_id = -1; m_curr_config.object_id = -1;
m_curr_config.volume_id = -1; m_curr_config.volume_id = -1;
m_objects_metadata.clear(); m_objects_metadata.clear();
m_items_auto_drop_off.clear();
m_layer_heights_profiles.clear(); m_layer_heights_profiles.clear();
m_layer_config_ranges.clear(); m_layer_config_ranges.clear();
m_brim_ear_points.clear(); m_brim_ear_points.clear();
@@ -2257,11 +2260,14 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
ObjectMetadata::VolumeMetadataList volumes; ObjectMetadata::VolumeMetadataList volumes;
ObjectMetadata::VolumeMetadataList* volumes_ptr = nullptr; ObjectMetadata::VolumeMetadataList* volumes_ptr = nullptr;
// 2.4.x stored auto_drop on build items, shared by all instances of an object; any "off" wins
const bool legacy_auto_drop = !m_items_auto_drop_off.count(object.first.second);
IdToMetadataMap::iterator obj_metadata = m_objects_metadata.find(object.first.second); IdToMetadataMap::iterator obj_metadata = m_objects_metadata.find(object.first.second);
if (obj_metadata != m_objects_metadata.end()) { if (obj_metadata != m_objects_metadata.end()) {
// config data has been found, this model was saved using slic3r pe // config data has been found, this model was saved using slic3r pe
model_object->auto_drop = obj_metadata->second.auto_drop; model_object->auto_drop = obj_metadata->second.auto_drop.value_or(legacy_auto_drop);
// apply object's name and config data // apply object's name and config data
for (const Metadata& metadata : obj_metadata->second.metadata) { for (const Metadata& metadata : obj_metadata->second.metadata) {
@@ -2279,6 +2285,7 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
} }
else { else {
// config data not found, this model was not saved using slic3r pe // config data not found, this model was not saved using slic3r pe
model_object->auto_drop = legacy_auto_drop;
// add the entire geometry as the single volume to generate // add the entire geometry as the single volume to generate
//volumes.emplace_back(0, (int)obj_geometry->second.triangles.size() - 1); //volumes.emplace_back(0, (int)obj_geometry->second.triangles.size() - 1);
@@ -4121,6 +4128,8 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
std::string path = bbs_get_attribute_value_string(attributes, num_attributes, PPATH_ATTR); std::string path = bbs_get_attribute_value_string(attributes, num_attributes, PPATH_ATTR);
Transform3d transform = bbs_get_transform_from_3mf_specs_string(bbs_get_attribute_value_string(attributes, num_attributes, TRANSFORM_ATTR)); Transform3d transform = bbs_get_transform_from_3mf_specs_string(bbs_get_attribute_value_string(attributes, num_attributes, TRANSFORM_ATTR));
int printable = bbs_get_attribute_value_bool(attributes, num_attributes, PRINTABLE_ATTR); int printable = bbs_get_attribute_value_bool(attributes, num_attributes, PRINTABLE_ATTR);
if (const char* auto_drop = bbs_get_attribute_value_charptr(attributes, num_attributes, AUTO_DROP_ATTR); auto_drop != nullptr && !::atoi(auto_drop))
m_items_auto_drop_off.insert(object_id);
return !m_load_model || _create_object_instance(path, object_id, transform, printable, 1); return !m_load_model || _create_object_instance(path, object_id, transform, printable, 1);
} }
@@ -4489,7 +4498,8 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
// int instances_count_id = bbs_get_attribute_value_int(attributes, num_attributes, INSTANCESCOUNT_ATTR); // int instances_count_id = bbs_get_attribute_value_int(attributes, num_attributes, INSTANCESCOUNT_ATTR);
ObjectMetadata metadata; ObjectMetadata metadata;
metadata.auto_drop = bbs_get_attribute_value_bool(attributes, num_attributes, AUTO_DROP_ATTR); if (bbs_get_attribute_value_charptr(attributes, num_attributes, AUTO_DROP_ATTR) != nullptr)
metadata.auto_drop = bbs_get_attribute_value_bool(attributes, num_attributes, AUTO_DROP_ATTR);
m_objects_metadata.insert({ object_id, std::move(metadata) }); m_objects_metadata.insert({ object_id, std::move(metadata) });
m_curr_config.object_id = object_id; m_curr_config.object_id = object_id;
return true; return true;
+461 -87
View File
@@ -5,10 +5,14 @@
#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/asio/ip/address.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 <cctype> #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 // Path safety
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -471,6 +674,39 @@ AuditDecision PluginAuditManager::check_open(const std::string& path_str, const
return check_path_access(boost::filesystem::path(path_str), is_write); return check_path_access(boost::filesystem::path(path_str), is_write);
} }
namespace {
// Builds and shows the modal confirmation. Must run on the GUI thread.
bool show_permission_dialog(const wxString& message, const wxString& title)
{
wxMessageDialog dialog(nullptr, message, title, wxYES_NO | wxICON_WARNING);
return dialog.ShowModal() == wxID_YES;
}
// Shows a permission prompt on the GUI thread and waits for the answer. The caller may be the
// plugin-load worker, so off the main thread the dialog is marshaled with CallAfter.
bool ask_permission(const wxString& message)
{
if (wxTheApp == nullptr || GUI::wxGetApp().is_closing())
return false;
if (wxIsMainThread())
return show_permission_dialog(message, _L("Plugin permissions"));
auto result = std::make_shared<std::promise<bool>>();
auto future = result->get_future();
GUI::wxGetApp().CallAfter([result, message]() {
if (wxTheApp == nullptr || GUI::wxGetApp().is_closing()) {
result->set_value(false);
return;
}
result->set_value(show_permission_dialog(message, _L("Plugin permissions")));
});
return future.get();
}
} // namespace
bool PluginAuditManager::request_filesystem_read_permissions(const std::string& plugin_key, bool PluginAuditManager::request_filesystem_read_permissions(const std::string& plugin_key,
const std::vector<std::string>& paths) const std::vector<std::string>& paths)
{ {
@@ -492,60 +728,80 @@ bool PluginAuditManager::request_filesystem_read_permissions(const std::string&
if (missing.empty()) if (missing.empty())
return true; 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; return false;
auto show_dialog = [&descriptor, &missing]() { seed_install_metadata(state, descriptor);
wxString requested_paths;
for (const std::string& path : missing)
requested_paths += wxString::FromUTF8(path.c_str()) + "\n";
wxMessageDialog dialog(
nullptr,
wxString::Format(_L("Plugin \"%s\" requests filesystem read access to:\n%s"),
wxString::FromUTF8(descriptor.name.c_str()), requested_paths),
_L("Plugin permissions"),
wxYES_NO | wxICON_WARNING);
return dialog.ShowModal() == wxID_YES;
};
bool granted = false;
if (wxIsMainThread()) {
granted = show_dialog();
} else {
auto result = std::make_shared<std::promise<bool>>();
auto future = result->get_future();
GUI::wxGetApp().CallAfter([result, descriptor_name = descriptor.name, missing]() {
wxString requested_paths;
for (const std::string& path : missing)
requested_paths += wxString::FromUTF8(path.c_str()) + "\n";
wxMessageDialog dialog(
nullptr,
wxString::Format(_L("Plugin \"%s\" requests filesystem read access to:\n%s"),
wxString::FromUTF8(descriptor_name.c_str()), requested_paths),
_L("Plugin permissions"),
wxYES_NO | wxICON_WARNING);
result->set_value(dialog.ShowModal() == wxID_YES);
});
granted = future.get();
}
if (!granted)
return false;
if (state.plugin_name.empty()) {
state.installed_from = descriptor.is_cloud_plugin() ? "cloud" : "local";
state.installed_version = !descriptor.installed_version.empty() ? descriptor.installed_version : descriptor.version;
state.plugin_name = descriptor.name;
state.cloud_uuid = descriptor.cloud_uuid();
state.enabled = true;
}
state.permissions.fs_read.insert(state.permissions.fs_read.end(), missing.begin(), missing.end()); state.permissions.fs_read.insert(state.permissions.fs_read.end(), missing.begin(), missing.end());
return write_install_state(boost::filesystem::path(descriptor.plugin_root), state); return write_install_state(boost::filesystem::path(descriptor.plugin_root), state);
} }
bool PluginAuditManager::request_network_permissions(const std::string& plugin_key,
const std::vector<std::string>& urls)
{
if (plugin_key.empty() || urls.empty())
return true;
PluginDescriptor descriptor;
if (!PluginManager::instance().try_get_plugin_descriptor(plugin_key, descriptor) || descriptor.plugin_root.empty())
return false;
// Reduce every request to scheme://host before comparing or persisting it, so a URL with a
// path or query cannot create a second grant for the same host.
std::vector<std::string> requested;
for (const std::string& url : urls) {
const std::string normalized = normalize_network_url(url);
if (normalized.empty()) {
BOOST_LOG_TRIVIAL(warning) << "[AUDIT] Invalid network permission request for plugin " << plugin_key << ": " << url;
return false;
}
if (std::find(requested.begin(), requested.end(), normalized) == requested.end())
requested.push_back(normalized);
}
PluginInstallState state;
read_install_state(boost::filesystem::path(descriptor.plugin_root), state);
std::vector<std::string> missing;
for (const std::string& entry : requested) {
if (!network_permission_matches(state.permissions.network_http, entry) &&
!network_permission_matches(state.permissions.network_socket, entry))
missing.push_back(entry);
}
if (missing.empty())
return true;
wxString requested_hosts;
for (const std::string& entry : missing)
requested_hosts += wxString::FromUTF8(entry.c_str()) + "\n";
if (!ask_permission(wxString::Format(_L("Plugin \"%s\" requests network access to:\n%s"),
wxString::FromUTF8(descriptor.name.c_str()), requested_hosts)))
return false;
seed_install_metadata(state, descriptor);
// A declared host is reachable through both HTTP and raw socket events. Recording the grant in
// both existing lists keeps the sidecar self-describing and means either event layer matches
// it without relying on the other list.
for (const std::string& entry : missing) {
if (std::find(state.permissions.network_http.begin(), state.permissions.network_http.end(), entry) ==
state.permissions.network_http.end())
state.permissions.network_http.push_back(entry);
if (std::find(state.permissions.network_socket.begin(), state.permissions.network_socket.end(), entry) ==
state.permissions.network_socket.end())
state.permissions.network_socket.push_back(entry);
}
return write_install_state(boost::filesystem::path(descriptor.plugin_root), state);
}
void PluginAuditManager::report_violation(const AuditViolation& violation) void PluginAuditManager::report_violation(const AuditViolation& violation)
{ {
m_last_violation = violation; m_last_violation = violation;
@@ -577,13 +833,38 @@ bool PluginAuditManager::last_violation(AuditViolation& violation) const
bool PluginAuditManager::has_approved_ancestor(const std::string& plugin_key, bool PluginAuditManager::has_approved_ancestor(const std::string& plugin_key,
const std::vector<std::string>& call_site_ids) const 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()) if (plugin_key.empty() || call_site_ids.empty())
return false; return false;
std::lock_guard<std::mutex> lock(m_mutex); std::lock_guard<std::mutex> lock(m_mutex);
auto it = m_approved_call_sites.find(plugin_key); auto it = registry.find(plugin_key);
if (it == m_approved_call_sites.end()) if (it == registry.end())
return false; return false;
for (const auto& id : call_site_ids) for (const auto& id : call_site_ids)
@@ -592,14 +873,15 @@ bool PluginAuditManager::has_approved_ancestor(const std::string& p
return false; return false;
} }
void PluginAuditManager::record_approved_call_sites(const std::string& plugin_key, void PluginAuditManager::record_call_sites(CallSiteRegistry& registry,
const std::vector<std::string>& call_site_ids) const std::string& plugin_key,
const std::vector<std::string>& call_site_ids)
{ {
if (plugin_key.empty() || call_site_ids.empty()) if (plugin_key.empty() || call_site_ids.empty())
return; return;
std::lock_guard<std::mutex> lock(m_mutex); 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()); approved.insert(call_site_ids.begin(), call_site_ids.end());
} }
@@ -616,6 +898,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)
@@ -627,26 +942,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)
@@ -663,6 +964,27 @@ std::string python_str(PyObject* object)
return result; 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> call_site_identities(const std::string& plugin_root)
{ {
std::vector<std::string> ids; std::vector<std::string> ids;
@@ -766,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); const auto it = audit_target_arg_indices.find(event_name);
if (it != audit_target_arg_indices.end()) { if (it != audit_target_arg_indices.end()) {
const bool network_event = is_network_category(category);
for (const Py_ssize_t index : it->second) { 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()) { if (!value.empty()) {
targets.push_back(std::move(value)); targets.push_back(std::move(value));
break; break;
@@ -797,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(); return std::find(granted.begin(), granted.end(), target) != granted.end();
} }
// A declared or previously approved network grant may live in either network list: an HTTP grant
// covers the sockets the same request opens, and a raw socket grant covers a later HTTP call to
// the same host.
bool has_network_permission(const PluginPermissions& permissions, const std::string& target)
{
return PluginAuditManager::network_permission_matches(permissions.network_http, target) ||
PluginAuditManager::network_permission_matches(permissions.network_socket, target);
}
bool has_any_network_permission(const PluginPermissions& permissions)
{
return !permissions.network_http.empty() || !permissions.network_socket.empty();
}
bool persist_permission(const std::string& plugin_key, bool persist_permission(const std::string& plugin_key,
PluginInstallState& state, PluginInstallState& state,
std::vector<std::string>& permission_list, std::vector<std::string>& permission_list,
@@ -806,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()) if (!PluginManager::instance().try_get_plugin_descriptor(plugin_key, descriptor) || descriptor.plugin_root.empty())
return false; return false;
// A sandbox plugin may not have a sidecar yet. Seed the small amount of install metadata seed_install_metadata(state, descriptor);
// needed by the writer so clicking Yes still creates the JSON file.
if (state.plugin_name.empty()) {
state.installed_from = descriptor.is_cloud_plugin() ? "cloud" : "local";
state.installed_version = !descriptor.installed_version.empty() ? descriptor.installed_version : descriptor.version;
state.plugin_name = descriptor.name;
state.cloud_uuid = descriptor.cloud_uuid();
state.enabled = true;
}
if (!has_permission(permission_list, target)) if (!has_permission(permission_list, target))
permission_list.push_back(target); permission_list.push_back(target);
@@ -893,11 +1223,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;
} }
// 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
@@ -957,7 +1286,30 @@ int decide_audited_event(PluginAuditManager& mgr,
const std::vector<std::string>& call_site_ids) const std::vector<std::string>& call_site_ids)
{ {
std::vector<std::string> unresolved = targets; 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(); unresolved.clear();
for (const auto& target : targets) for (const auto& target : targets)
if (!has_permission(*permission_list, target)) if (!has_permission(*permission_list, target))
@@ -973,6 +1325,12 @@ int decide_audited_event(PluginAuditManager& mgr,
for (const auto& target : unresolved) for (const auto& target : unresolved)
persist_permission(plugin_key, state, *permission_list, target); 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); mgr.record_approved_call_sites(plugin_key, call_site_ids);
return 0; return 0;
} }
@@ -1024,12 +1382,28 @@ int PluginAuditManager::audit_hook(const char* event, PyObject* args, void* user
PluginDescriptor plugin_descriptor; PluginDescriptor plugin_descriptor;
PluginManager::instance().try_get_plugin_descriptor(mgr->current_plugin(), 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 // Some plugins call other audited events, e.g. urllib.request will call socket.connect.
// was already approved, socket.connect won't trigger another permission dialog. // 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 = const std::vector<std::string> call_site_ids =
PluginAuditDetail::call_site_identities(plugin_descriptor.plugin_root); 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; return 0;
}
// A filesystem target that resolves entirely inside a pre-determined allowed root -- the // 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 // plugin system's own data_dir() tree (which holds each plugin's storage folder and the
+51 -2
View File
@@ -138,6 +138,33 @@ public:
bool request_filesystem_read_permissions(const std::string& plugin_key, bool request_filesystem_read_permissions(const std::string& plugin_key,
const std::vector<std::string>& paths); const std::vector<std::string>& paths);
// Ask the user to grant network access to the given http(s) URLs. Each URL is reduced to its
// scheme and host before prompting and persisting, so one approval covers every path, query
// and port on that host. The grant is written to both network_http and network_socket: the
// same declared host can surface through HTTP events (urllib/http.client) and raw socket
// events. Like request_filesystem_read_permissions, the request may originate on a plugin
// load worker; denial aborts the plugin load without adding a permission.
bool request_network_permissions(const std::string& plugin_key,
const std::vector<std::string>& urls);
// Canonical scheme://host form used to store and match network permissions. Returns an empty
// string unless `url` is an absolute http(s) URL with a host. The path, query, fragment,
// userinfo and port are discarded; scheme and host are lowercased.
static std::string normalize_network_url(const std::string& url);
// True when a granted scheme://host entry covers `target`. `target` is a URL (urllib.Request),
// a bare host (http.client.connect, socket.getaddrinfo) or an address as exposed by the audit
// event (a hostname, a host:port pair, or a legacy persisted tuple such as "('host', 443)").
// URL targets must match both scheme and host. 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); void report_violation(const AuditViolation& violation);
bool audit_denial_pending() const; bool audit_denial_pending() const;
void clear_audit_denial(); void clear_audit_denial();
@@ -151,9 +178,20 @@ public:
// is recorded here by (filename, function, first line) identity. A later event whose own // 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 // 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. // 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; 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); 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; bool verbose_events = true;
private: private:
@@ -170,12 +208,23 @@ private:
static thread_local bool m_has_last_violation; static thread_local bool m_has_last_violation;
static thread_local AuditViolation m_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; 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<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;
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 // RAII guard that sets the current plugin key and capability name, restoring the previous
+27 -15
View File
@@ -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) bool write_install_state(const boost::filesystem::path& plugin_dir, const PluginInstallState& state)
{ {
namespace fs = boost::filesystem; 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 // 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 state.enabled = enabled;
// at install time, preserved across sidecar re-writes) so a stale manifest/PEP723 header state.capabilities = capabilities;
// never overwrites the source-of-truth version. Fall back to the manifest version for
// first-time/local installs where installed_version is not yet populated.
state.installed_version = !entry.installed_version.empty() ? entry.installed_version : entry.version;
state.plugin_name = entry.name;
state.cloud_uuid = entry.cloud_uuid();
state.enabled = enabled;
state.capabilities = capabilities;
return write_install_state(plugin_dir, state); return write_install_state(plugin_dir, state);
} }
@@ -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 // 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);
} }
+7
View File
@@ -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. // 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);
+2 -6
View File
@@ -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;
+66 -9
View File
@@ -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, std::vector<py::object>> g_pending_capabilities;
std::unordered_map<std::string, py::object> g_pending_package; std::unordered_map<std::string, py::object> g_pending_package;
std::unordered_map<std::string, std::vector<std::string>> g_pending_fs_read_permissions; std::unordered_map<std::string, std::vector<std::string>> g_pending_fs_read_permissions;
std::unordered_map<std::string, std::vector<std::string>> g_pending_network_permissions;
struct PluginInstanceHandle struct PluginInstanceHandle
{ {
// The C++ plugin interface points into a Python object. Keep both alive through one // The C++ plugin interface points into a Python object. Keep both alive through one
@@ -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_fs_read_permissions.erase(plugin_key);
g_pending_network_permissions.erase(plugin_key);
} }
} // namespace } // namespace
@@ -114,6 +116,7 @@ void PythonPluginBridge::begin_plugin_capture(const std::string& plugin_key)
g_pending_capabilities.erase(plugin_key); g_pending_capabilities.erase(plugin_key);
g_pending_package.erase(plugin_key); g_pending_package.erase(plugin_key);
g_pending_fs_read_permissions.erase(plugin_key); g_pending_fs_read_permissions.erase(plugin_key);
g_pending_network_permissions.erase(plugin_key);
} }
// From now until finalize/cancel, @orca.plugin and register_capability() calls made by // From now until finalize/cancel, @orca.plugin and register_capability() calls made by
// Python code on this thread are attributed to this plugin. // Python code on this thread are attributed to this plugin.
@@ -145,6 +148,7 @@ std::vector<CapturedCapability> PythonPluginBridge::finalize_plugin_capture(cons
g_pending_capabilities.erase(capture_key); g_pending_capabilities.erase(capture_key);
g_pending_package.erase(capture_key); g_pending_package.erase(capture_key);
g_pending_fs_read_permissions.erase(capture_key); g_pending_fs_read_permissions.erase(capture_key);
g_pending_network_permissions.erase(capture_key);
}; };
try { try {
@@ -200,6 +204,30 @@ std::vector<CapturedCapability> PythonPluginBridge::finalize_plugin_capture(cons
return {}; return {};
} }
} }
std::vector<std::string> network_permissions;
{
std::lock_guard<std::mutex> lock(g_registry_mutex);
auto it = g_pending_network_permissions.find(capture_key);
if (it != g_pending_network_permissions.end()) {
network_permissions = std::move(it->second);
g_pending_network_permissions.erase(it);
}
}
if (!network_permissions.empty()) {
bool granted = false;
{
py::gil_scoped_release release;
granted = PluginAuditManager::instance().request_network_permissions(plugin_key, network_permissions);
}
if (!granted) {
error = _u8L("Plugin network permission request denied");
BOOST_LOG_TRIVIAL(warning) << error << " for key " << plugin_key;
discard_pending_for_key();
clear_active_key();
return {};
}
}
} catch (py::error_already_set& err) { } catch (py::error_already_set& err) {
log_python_exception_keep(err); log_python_exception_keep(err);
error = err.what(); error = err.what();
@@ -323,6 +351,7 @@ void PythonPluginBridge::cancel_plugin_capture(const std::string& plugin_key)
g_pending_capabilities.erase(plugin_key); g_pending_capabilities.erase(plugin_key);
g_pending_package.erase(plugin_key); g_pending_package.erase(plugin_key);
g_pending_fs_read_permissions.erase(plugin_key); g_pending_fs_read_permissions.erase(plugin_key);
g_pending_network_permissions.erase(plugin_key);
} }
if (g_active_plugin_key == plugin_key) if (g_active_plugin_key == plugin_key)
@@ -350,6 +379,7 @@ void PythonPluginBridge::clear_pending_captures()
} }
g_pending_package.clear(); g_pending_package.clear();
g_pending_fs_read_permissions.clear(); g_pending_fs_read_permissions.clear();
g_pending_network_permissions.clear();
g_active_plugin_key.clear(); g_active_plugin_key.clear();
return; return;
} }
@@ -359,6 +389,7 @@ void PythonPluginBridge::clear_pending_captures()
g_pending_capabilities.clear(); g_pending_capabilities.clear();
g_pending_package.clear(); g_pending_package.clear();
g_pending_fs_read_permissions.clear(); g_pending_fs_read_permissions.clear();
g_pending_network_permissions.clear();
g_active_plugin_key.clear(); g_active_plugin_key.clear();
} }
@@ -558,24 +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) {
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
auto& requested = g_pending_fs_read_permissions[g_active_plugin_key]; // 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) { for (const std::string& path : fs_read) {
if (path.empty()) if (path.empty())
throw py::value_error("request_permissions(fs_read=...) does not accept empty paths"); throw py::value_error("request_permissions(fs_read=...) does not accept empty paths");
if (std::find(requested.begin(), requested.end(), path) == requested.end()) }
requested.push_back(path);
std::lock_guard<std::mutex> lock(g_registry_mutex);
auto& requested_fs = g_pending_fs_read_permissions[g_active_plugin_key];
for (const std::string& path : fs_read) {
if (std::find(requested_fs.begin(), requested_fs.end(), path) == requested_fs.end())
requested_fs.push_back(path);
}
if (!normalized_network.empty()) {
auto& requested_network = g_pending_network_permissions[g_active_plugin_key];
for (const std::string& entry : normalized_network) {
if (std::find(requested_network.begin(), requested_network.end(), entry) == requested_network.end())
requested_network.push_back(entry);
}
} }
}, },
py::arg("fs_read") = std::vector<std::string>{}, py::arg("fs_read") = std::vector<std::string>{}, py::arg("network") = std::vector<std::string>{},
R"pbdoc(Request filesystem read permissions while OrcaSlicer loads your module. R"pbdoc(Request permissions while OrcaSlicer loads your module.
The host presents the request to the user after register_capabilities() returns. Denying the `fs_read` lists filesystem paths to read. `network` lists http(s) URLs; each is reduced to its
request aborts the plugin load.)pbdoc"); 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) { m.def("plugin", [](py::object cls) {
if (g_active_plugin_key.empty()) if (g_active_plugin_key.empty())
+22
View File
@@ -505,6 +505,28 @@ TEST_CASE("A project whose components reference themselves fails to load", "[3mf
REQUIRE_FALSE(loaded); REQUIRE_FALSE(loaded);
} }
TEST_CASE("A project in the 2.4.x layout keeps auto drop off from its build item", "[3mf][Regression]")
{
// 2.4.x wrote auto_drop on the build item and left it off the object, so the object is re-laid out here.
ScopedTemporaryFile temp(".3mf");
{
Model model;
ModelObject* object = model.add_object();
object->add_volume(make_cube(10., 10., 10.));
object->add_instance();
object->auto_drop = false;
store_project(model, temp.string(), SaveStrategy::Zip64 | SaveStrategy::Silence);
}
REQUIRE(replace_in_3mf_entry(temp.string(), "model_settings.config", " auto_drop=\"0\"", ""));
REQUIRE(replace_in_3mf_entry(temp.string(), "3dmodel.model", "printable=\"1\"/>", "printable=\"1\" auto_drop=\"0\"/>"));
ScopedTemporaryDir backup_dir("orca_auto_drop_dst");
Model model;
REQUIRE(load_project(temp.string(), model, backup_dir));
REQUIRE(model.objects.size() == 1);
CHECK_FALSE(model.objects.front()->auto_drop);
}
TEST_CASE("An object loads up to the component reference budget and fails past it", "[3mf][Regression]") TEST_CASE("An object loads up to the component reference budget and fails past it", "[3mf][Regression]")
{ {
// The importer queues at most 100000 component references per object. Every reference besides the // The importer queues at most 100000 component references per object. Every reference besides the
@@ -150,6 +150,13 @@ public:
return m_close_frames; return m_close_frames;
} }
// The printer answers a close frame before its own read completes, so the agent's stop can return first.
bool wait_for_close_frames(int count)
{
std::unique_lock<std::mutex> lock(m_mutex);
return m_changed.wait_for(lock, 10s, [&] { return m_close_frames >= count; });
}
// Hangs up every connection that is waiting. // Hangs up every connection that is waiting.
void release() void release()
{ {
@@ -277,6 +284,7 @@ private:
if (ec == websocket::error::closed) { if (ec == websocket::error::closed) {
std::lock_guard<std::mutex> lock(m_mutex); std::lock_guard<std::mutex> lock(m_mutex);
++m_close_frames; ++m_close_frames;
m_changed.notify_all();
} }
if (ec) if (ec)
reading = false; reading = false;
@@ -449,6 +457,7 @@ TEST_CASE("Stopping the status stream sends the printer a close frame", "[Moonra
REQUIRE(f.printer.wait_for_ready_connections(1)); REQUIRE(f.printer.wait_for_ready_connections(1));
CHECK(f.stops_within(3s)); CHECK(f.stops_within(3s));
CHECK(f.printer.wait_for_close_frames(1));
CHECK(f.printer.close_frames() == 1); CHECK(f.printer.close_frames() == 1);
} }
+112
View File
@@ -14,6 +14,7 @@
#include <boost/filesystem.hpp> #include <boost/filesystem.hpp>
#include <string> #include <string>
#include <vector>
using namespace Slic3r; using namespace Slic3r;
namespace fs = boost::filesystem; namespace fs = boost::filesystem;
@@ -325,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]") 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");