✨ Restrict global plugins to MCP and localhost (#12949)

Plugins with global scope or a management permission (allow:global,
manage:projects, manage:teams, manage:delete) keep running outside a file
and can drive the management API. That feature is still in progress, so
only the bundled MCP plugin and plugins served from localhost, 127.0.0.1
or [::1] get it. The host check ignores letter case and a trailing dot.

The check runs at three points:

- parse-manifest throws global-not-allowed, so the plugin manager and the
  dashboard install link refuse remote global plugins with a specific
  message. Opening an installed plugin fetches its manifest again, so
  plugins stored before this change are refused too.
- start-plugin! and load-plugin! refuse to run such a plugin and warn the
  user; this also covers bundled plugins without a URL.
- has-permission? withholds the global permissions from stored remote
  plugins, so the management API rejects them.

AI-assisted-by: claude-opus-5-5
This commit is contained in:
Alonso Torres 2026-11-11 10:28:11 +01:00 committed by GitHub
parent 37f4556b0b
commit adda9ad908
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 205 additions and 63 deletions

View File

@ -57,61 +57,75 @@
(update [_ state]
(update-in state [:workspace-local :open-plugins] (fnil disj #{}) id))))
(defn- global-blocked?
"True, after warning the user, when the plugin asks for global scope
or management permissions it is not allowed to have."
[plugin]
(when (and (preg/global-requested? plugin)
(not (preg/global-allowed? plugin)))
(log/warn :hint "Global plugins can only be loaded from localhost"
:plugin-id (:plugin-id plugin)
:host (:host plugin))
(st/emit! (ntf/warn (tr "workspace.plugins.error.global-not-allowed")))
true))
(defn start-plugin!
[{:keys [plugin-id name version description host code permissions allow-background scope]} ^js extensions]
(let [load-plugin (unchecked-get ug/global "ɵloadPlugin")]
(if (fn? load-plugin)
(-> (load-plugin
#js {:pluginId plugin-id
:name name
:version version
:description description
:host host
:code code
:allowBackground (boolean allow-background)
:scope scope
:permissions (apply array permissions)}
nil
extensions)
[{:keys [plugin-id name version description host code permissions allow-background scope] :as plugin} ^js extensions]
(when-not (global-blocked? plugin)
(let [load-plugin (unchecked-get ug/global "ɵloadPlugin")]
(if (fn? load-plugin)
(-> (load-plugin
#js {:pluginId plugin-id
:name name
:version version
:description description
:host host
:code code
:allowBackground (boolean allow-background)
:scope scope
:permissions (apply array permissions)}
nil
extensions)
(p/catch (fn [cause]
(ex/print-throwable cause :prefix "Plugin Error")
(errors/flash :cause cause :type :handled))))
(p/catch (fn [cause]
(ex/print-throwable cause :prefix "Plugin Error")
(errors/flash :cause cause :type :handled))))
(log/warn :hint "Plugin runtime not initialized yet"
:plugin-id plugin-id
:action "start-plugin!"))))
(defn- load-plugin!
[{:keys [plugin-id name version description host code icon permissions scope]}]
(st/emit! (pflag/initialize plugin-id version)
(save-current-plugin plugin-id))
(let [load-plugin (unchecked-get ug/global "ɵloadPlugin")]
(if (fn? load-plugin)
(-> (load-plugin
#js {:pluginId plugin-id
:name name
:description description
:version version
:host host
:code code
:icon icon
:scope scope
:permissions (apply array permissions)}
(fn []
(st/emit! (remove-current-plugin plugin-id))))
(p/catch (fn [cause]
(st/emit! (remove-current-plugin plugin-id))
(ex/print-throwable cause :prefix "Plugin Error")
(errors/flash :cause cause :type :handled))))
(do
(log/warn :hint "Plugin runtime not initialized yet"
:plugin-id plugin-id
:action "load-plugin!")
(st/emit! (remove-current-plugin plugin-id))))))
:action "start-plugin!")))))
(defn- load-plugin!
[{:keys [plugin-id name version description host code icon permissions scope] :as plugin}]
(when-not (global-blocked? plugin)
(st/emit! (pflag/initialize plugin-id version)
(save-current-plugin plugin-id))
(let [load-plugin (unchecked-get ug/global "ɵloadPlugin")]
(if (fn? load-plugin)
(-> (load-plugin
#js {:pluginId plugin-id
:name name
:description description
:version version
:host host
:code code
:icon icon
:scope scope
:permissions (apply array permissions)}
(fn []
(st/emit! (remove-current-plugin plugin-id))))
(p/catch (fn [cause]
(st/emit! (remove-current-plugin plugin-id))
(ex/print-throwable cause :prefix "Plugin Error")
(errors/flash :cause cause :type :handled))))
(do
(log/warn :hint "Plugin runtime not initialized yet"
:plugin-id plugin-id
:action "load-plugin!")
(st/emit! (remove-current-plugin plugin-id)))))))
(defn open-plugin!
[{:keys [url] :as manifest} user-can-edit?]
@ -154,10 +168,12 @@
;; Manifests match exactly - safe to load
:else
(load-plugin! manifest))))
(fn [_err]
(fn [err]
;; Error fetching the manifest - can't verify integrity
;; Show error instead of loading potentially tampered code
(st/emit! (ntf/warn (tr "workspace.plugins.error.unreachable"))))))
(st/emit! (ntf/warn (if (= ::preg/global-not-allowed (:type (ex-data err)))
(tr "workspace.plugins.error.global-not-allowed")
(tr "workspace.plugins.error.unreachable")))))))
;; Bundled plugins (no URL) - trusted, load directly
(load-plugin! manifest)))

View File

@ -224,8 +224,10 @@
(st/emit! (ev/event {::ev/name "install-plugin" :name (:name plugin) :url plugin-url}))
(open-permissions-dialog plugin))
(st/emit! (notif/error (tr "dashboard.plugins.parse-error")))))
(fn [_]
(st/emit! (notif/error (tr "dashboard.plugins.bad-url"))))))
(fn [err]
(st/emit! (notif/error (if (= ::preg/global-not-allowed (:type (ex-data err)))
(tr "workspace.plugins.error.global-not-allowed")
(tr "dashboard.plugins.bad-url")))))))
(binding [storage/*sync* true]
(swap! storage/session dissoc :plugin-url))))))

View File

@ -111,12 +111,13 @@
plugin-url* (mf/use-state "")
plugin-url (deref plugin-url*)
input-status* (mf/use-state nil) ;; :error-url :error-manifest :success
input-status* (mf/use-state nil) ;; :error-url :error-manifest :error-global :success
input-status (deref input-status*)
error-url? (= :error-url input-status)
error-manifest? (= :error-manifest input-status)
error? (or error-url? error-manifest?)
error-global? (= :error-global input-status)
error? (or error-url? error-manifest? error-global?)
permissions (mf/deref refs/permissions)
user-can-edit? (get permissions :can-edit)
@ -159,7 +160,10 @@
(fn [err]
(.error js/console err)
(reset! fetching-manifest? false)
(reset! input-status* :error-url))))))
(reset! input-status*
(if (= ::preg/global-not-allowed (:type (ex-data err)))
:error-global
:error-url)))))))
on-open-plugin
(mf/use-fn
@ -236,6 +240,10 @@
[:div {:class (stl/css-case :info true :error error?)}
(tr "workspace.plugins.error.manifest")])
(when error-global?
[:div {:class (stl/css-case :info true :error error?)}
(tr "workspace.plugins.error.global-not-allowed")])
(when-not (empty? plugins-state)
[:> i18n/tr-html*
{:class (stl/css :discover)

View File

@ -16,6 +16,7 @@
[app.main.store :as st]
[app.util.object :as obj]
[beicon.v2.core :as rx]
[cuerdas.core :as str]
[promesa.core :as p]))
;; Needs to be here because moving it to `app.main.data.workspace.mcp` will
@ -29,6 +30,33 @@
;; Keeps the rollbacks not reported yet across reloads
"allow:localstorage"})
;; Permissions only granted to plugins that pass `global-allowed?`
(def global-permissions
#{"allow:global" "manage:projects" "manage:teams" "manage:delete"})
(def ^:private local-hosts
#{"localhost" "127.0.0.1" "[::1]" "::1"})
(defn- local-url?
"True when the url host is a loopback name, ignoring case and a
trailing dot."
[url]
(contains? local-hosts (some-> url u/uri :host str/lower (str/rtrim "."))))
(defn global-requested?
"True when the plugin asks for global scope or a management permission."
[{:keys [scope permissions]}]
(or (= "global" scope)
(boolean (some global-permissions permissions))))
(defn global-allowed?
"True when the plugin may run with global scope and use the management
API. The feature is still in progress, so only the bundled MCP plugin
and plugins served from localhost get it."
[{:keys [plugin-id host]}]
(or (= plugin-id mcp-plugin-id)
(local-url? host)))
;; Promise that resolves when plugins runtime is initialized.
;; Lives here to avoid circular dependency: workspace.mcp -> app.plugins -> app.plugins.api -> workspace
(defonce ^:private runtime-ready-promise (p/deferred))
@ -121,9 +149,18 @@
:code code
:icon icon
:permissions (into #{} (map str) permissions)})]
(if (sm/validate ctp/schema:registry-entry manifest)
manifest
(.error js/console (clj->js (sm/explain ctp/schema:registry-entry manifest))))))
(cond
(not (sm/validate ctp/schema:registry-entry manifest))
(.error js/console (clj->js (sm/explain ctp/schema:registry-entry manifest)))
(and (global-requested? manifest)
(not (global-allowed? manifest)))
(throw (ex-info "Global plugins can only be loaded from localhost"
{:type ::global-not-allowed
:host origin}))
:else
manifest)))
(defn subscribe-registry!
"Subscribes f, called with no arguments on every registry change.
@ -273,13 +310,16 @@
(.error js/console "Failed to remove plugin:" err))))))))
(defn has-permission?
"True when the plugin was granted `permission`."
"True when the plugin was granted `permission`. The permissions in
`global-permissions` also need the plugin to pass `global-allowed?`."
[plugin-id permission]
(cond
(= plugin-id "00000000-0000-0000-0000-000000000000") true
(= plugin-id mcp-plugin-id) (contains? mcp-permissions permission)
:else (let [{:keys [permissions]} (dm/get-in @registry [:data plugin-id])]
(contains? permissions permission))))
:else (let [{:keys [permissions] :as plugin} (dm/get-in @registry [:data plugin-id])]
(and (contains? permissions permission)
(or (not (contains? global-permissions permission))
(global-allowed? plugin))))))
(defn- read-only-reason
"Why plugin writes must fail in `state`, or nil when they can go ahead.

View File

@ -347,3 +347,71 @@
:permissions #js ["content:read"]})]
(t/is (nil? (:scope manifest)))
(t/is (= #{"content:read"} (:permissions manifest)))))
;; --- global plugins restriction ---
(defn- global-not-allowed-error
"The ex-data type thrown by `(f)`, or nil when it does not throw."
[f]
(try
(f)
nil
(catch :default cause
(:type (ex-data cause)))))
(t/deftest global-plugins-are-allowed-for-mcp-and-localhost
(t/is (preg/global-allowed? {:plugin-id preg/mcp-plugin-id
:host "https://design.penpot.app/plugins/mcp/"}))
(doseq [host ["http://localhost:4400/" "http://127.0.0.1:4400/" "http://[::1]:4400/"
"http://LOCALHOST:4400/" "http://LocalHost/" "http://localhost.:4400/"]]
(t/is (preg/global-allowed? {:plugin-id "local" :host host}) host))
(t/is (not (preg/global-allowed? {:plugin-id "remote" :host "https://plugins.example.com/"})))
(t/is (not (preg/global-allowed? {:plugin-id "remote" :host "https://localhost.example.com/"}))))
(t/deftest parse-manifest-rejects-remote-global-scope
(t/is (= ::preg/global-not-allowed
(global-not-allowed-error
#(preg/parse-manifest
"https://plugins.example.com/manifest.json"
#js {:name "Remote global plugin"
:code "plugin.js"
:scope "global"
:permissions #js ["content:read"]})))))
(t/deftest parse-manifest-rejects-remote-management-permissions
(doseq [permission ["manage:projects" "manage:teams" "manage:delete" "allow:global"]]
(t/is (= ::preg/global-not-allowed
(global-not-allowed-error
#(preg/parse-manifest
"https://plugins.example.com/manifest.json"
#js {:name "Remote management plugin"
:code "plugin.js"
:permissions #js ["content:read" permission]})))
permission)))
(t/deftest parse-manifest-keeps-remote-workspace-plugins
(let [manifest (preg/parse-manifest
"https://plugins.example.com/manifest.json"
#js {:name "Remote workspace plugin"
:code "plugin.js"
:permissions #js ["content:read"]})]
(t/is (= #{"content:read"} (:permissions manifest)))))
(t/deftest ^:async stored-remote-plugins-lose-global-permissions
(await
(mock/with-mocks*
{rp/cmd! (record-cmd-mock (fn [_ _] (rx/of {:ok true})))}
(let [permissions #{"content:read" "allow:global" "manage:projects"}]
(preg/install-plugin! {:plugin-id "reg-global-remote"
:host "https://plugins.example.com/"
:scope "global"
:permissions permissions})
(preg/install-plugin! {:plugin-id "reg-global-local"
:host "http://localhost:4400/"
:scope "global"
:permissions permissions})
(t/is (preg/has-permission? "reg-global-remote" "content:read"))
(t/is (not (preg/has-permission? "reg-global-remote" "allow:global")))
(t/is (not (preg/has-permission? "reg-global-remote" "manage:projects")))
(t/is (preg/has-permission? "reg-global-local" "allow:global"))
(t/is (preg/has-permission? "reg-global-local" "manage:projects"))))))

View File

@ -8537,6 +8537,10 @@ msgstr "Discover [more plugins](%s)"
msgid "workspace.plugins.empty-plugins"
msgstr "No plugins installed yet"
#: src/app/main/data/plugins.cljs, src/app/main/ui/dashboard.cljs, src/app/main/ui/workspace/plugins.cljs
msgid "workspace.plugins.error.global-not-allowed"
msgstr "Plugins that work outside a file can only be installed from localhost for now."
#: src/app/main/ui/workspace/plugins.cljs:237
msgid "workspace.plugins.error.manifest"
msgstr "The plugin manifest is incorrect."

View File

@ -8614,6 +8614,10 @@ msgstr "Descubre [más extensiones](%s)"
msgid "workspace.plugins.empty-plugins"
msgstr "No se encuentran extensiones"
#: src/app/main/data/plugins.cljs, src/app/main/ui/dashboard.cljs, src/app/main/ui/workspace/plugins.cljs
msgid "workspace.plugins.error.global-not-allowed"
msgstr "Por ahora, las extensiones que funcionan fuera de un archivo solo se pueden instalar desde localhost."
#: src/app/main/ui/workspace/plugins.cljs:237
msgid "workspace.plugins.error.manifest"
msgstr "El manifiesto de la expansión es incorrecto."