From 6c791532c685a9ddd11e2086d16428836126bad1 Mon Sep 17 00:00:00 2001 From: Ian Chua Date: Tue, 20 Oct 2026 01:15:14 +0800 Subject: [PATCH] fix: update api to allow requesting network permissions --- .../orca_network_permission_probe_plugin.py | 39 ++ src/slic3r/plugin/PluginAuditManager.cpp | 548 +++++++++++++++--- src/slic3r/plugin/PluginAuditManager.hpp | 53 +- src/slic3r/plugin/PluginFsUtils.cpp | 42 +- src/slic3r/plugin/PluginFsUtils.hpp | 7 + src/slic3r/plugin/PluginManager.cpp | 8 +- src/slic3r/plugin/PythonPluginBridge.cpp | 75 ++- tests/slic3rutils/test_plugin_audit.cpp | 112 ++++ 8 files changed, 765 insertions(+), 119 deletions(-) create mode 100644 sandboxes/orca_network_permission_probe_plugin.py diff --git a/sandboxes/orca_network_permission_probe_plugin.py b/sandboxes/orca_network_permission_probe_plugin.py new file mode 100644 index 0000000000..8845427dc3 --- /dev/null +++ b/sandboxes/orca_network_permission_probe_plugin.py @@ -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) diff --git a/src/slic3r/plugin/PluginAuditManager.cpp b/src/slic3r/plugin/PluginAuditManager.cpp index e913763891..e525f8358a 100644 --- a/src/slic3r/plugin/PluginAuditManager.cpp +++ b/src/slic3r/plugin/PluginAuditManager.cpp @@ -5,10 +5,14 @@ #include "libslic3r/libslic3r.h" // GCODEVIEWER_APP_KEY, and SLIC3R_APP_KEY via libslic3r_version.h #include "libslic3r_version.h" +#include #include +#include +#include #include #include #include +#include #include #include @@ -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& 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 // --------------------------------------------------------------------------- @@ -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); } +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>(); + 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& paths) { @@ -492,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>(); - 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& 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 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 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; @@ -577,13 +833,38 @@ bool PluginAuditManager::last_violation(AuditViolation& violation) const bool PluginAuditManager::has_approved_ancestor(const std::string& plugin_key, const std::vector& 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& 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& 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& 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& call_site_ids) const { if (plugin_key.empty() || call_site_ids.empty()) return false; std::lock_guard 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) @@ -592,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& call_site_ids) +void PluginAuditManager::record_call_sites(CallSiteRegistry& registry, + const std::string& plugin_key, + const std::vector& call_site_ids) { if (plugin_key.empty() || call_site_ids.empty()) return; std::lock_guard 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()); } @@ -616,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) @@ -627,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) @@ -663,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 call_site_identities(const std::string& plugin_root) { std::vector ids; @@ -766,8 +1088,10 @@ std::vector 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; @@ -797,6 +1121,20 @@ bool has_permission(const std::vector& 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& 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()) 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); @@ -893,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 @@ -957,7 +1286,30 @@ int decide_audited_event(PluginAuditManager& mgr, const std::vector& call_site_ids) { std::vector 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)) @@ -973,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; } @@ -1024,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 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 diff --git a/src/slic3r/plugin/PluginAuditManager.hpp b/src/slic3r/plugin/PluginAuditManager.hpp index 41d3dca1e8..bd5ca801ea 100644 --- a/src/slic3r/plugin/PluginAuditManager.hpp +++ b/src/slic3r/plugin/PluginAuditManager.hpp @@ -138,6 +138,33 @@ public: bool request_filesystem_read_permissions(const std::string& plugin_key, const std::vector& 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& 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& 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& call_site_ids) const; void record_approved_call_sites(const std::string& plugin_key, const std::vector& 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& call_site_ids) const; + void record_approved_network_call_sites(const std::string& plugin_key, const std::vector& 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>; + + // 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& call_site_ids) const; + void record_call_sites(CallSiteRegistry& registry, const std::string& plugin_key, + const std::vector& call_site_ids); + std::vector m_global_allowed_roots; std::vector m_denied_filenames; std::vector m_denied_path_keywords; - std::unordered_map> 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 diff --git a/src/slic3r/plugin/PluginFsUtils.cpp b/src/slic3r/plugin/PluginFsUtils.cpp index 1dd8391ad7..ec3be1d871 100644 --- a/src/slic3r/plugin/PluginFsUtils.cpp +++ b/src/slic3r/plugin/PluginFsUtils.cpp @@ -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); } diff --git a/src/slic3r/plugin/PluginFsUtils.hpp b/src/slic3r/plugin/PluginFsUtils.hpp index 9c864ed73a..91a7820fd9 100644 --- a/src/slic3r/plugin/PluginFsUtils.hpp +++ b/src/slic3r/plugin/PluginFsUtils.hpp @@ -184,12 +184,19 @@ std::vector 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>& 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); diff --git a/src/slic3r/plugin/PluginManager.cpp b/src/slic3r/plugin/PluginManager.cpp index 98bf0b6ad6..2150103668 100644 --- a/src/slic3r/plugin/PluginManager.cpp +++ b/src/slic3r/plugin/PluginManager.cpp @@ -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; diff --git a/src/slic3r/plugin/PythonPluginBridge.cpp b/src/slic3r/plugin/PythonPluginBridge.cpp index 103de8e362..e12192af7d 100644 --- a/src/slic3r/plugin/PythonPluginBridge.cpp +++ b/src/slic3r/plugin/PythonPluginBridge.cpp @@ -50,6 +50,7 @@ std::mutex g_registry_mutex; std::unordered_map> g_pending_capabilities; std::unordered_map g_pending_package; std::unordered_map> g_pending_fs_read_permissions; +std::unordered_map> 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 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 PythonPluginBridge::finalize_plugin_capture(cons return {}; } } + + std::vector network_permissions; + { + std::lock_guard 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& fs_read) { + [](const std::vector& fs_read, const std::vector& network) { if (g_active_plugin_key.empty()) throw py::value_error("request_permissions() called outside plugin discovery context"); - std::lock_guard 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 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 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{}, - R"pbdoc(Request filesystem read permissions while OrcaSlicer loads your module. + py::arg("fs_read") = std::vector{}, py::arg("network") = std::vector{}, + 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()) diff --git a/tests/slic3rutils/test_plugin_audit.cpp b/tests/slic3rutils/test_plugin_audit.cpp index 267cdb4099..8fecf9d4e7 100644 --- a/tests/slic3rutils/test_plugin_audit.cpp +++ b/tests/slic3rutils/test_plugin_audit.cpp @@ -14,6 +14,7 @@ #include #include +#include using namespace Slic3r; 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 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 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 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 legacy_granted{"('api.example.com', 443)"}; + CHECK(PluginAuditManager::network_permission_matches(legacy_granted, "api.example.com")); + + const std::vector 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 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 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 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 granted{}; + CHECK_FALSE(PluginAuditManager::network_permission_matches(granted, "https://api.example.com")); + + const std::vector 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");