mirror of
https://github.com/penpot/penpot.git
synced 2026-11-12 13:00:37 +00:00
✨ 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:
parent
37f4556b0b
commit
adda9ad908
@ -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)))
|
||||
|
||||
|
||||
@ -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))))))
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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"))))))
|
||||
|
||||
@ -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."
|
||||
|
||||
@ -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."
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user