Alonso Torres adda9ad908
✨ 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
2026-11-11 10:28:11 +01:00

577 lines
22 KiB
Clojure

;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.main.ui.workspace.plugins
(:require-macros [app.main.style :as stl])
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.uri :as u]
[app.config :as cfg]
[app.main.data.event :as ev]
[app.main.data.modal :as modal]
[app.main.data.plugins :as dp]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.components.search-bar :refer [search-bar*]]
[app.main.ui.components.title-bar :refer [title-bar*]]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
[app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]]
[app.main.ui.icons :as deprecated-icon]
[app.plugins.register :as preg]
[app.util.avatars :as avatars]
[app.util.dom :as dom]
[app.util.globals :as global]
[app.util.i18n :as i18n :refer [tr]]
[beicon.v2.core :as rx]
[cuerdas.core :as str]
[rumext.v2 :as mf]))
(defn- normalize-plugin-url
"Automatically appens manifest.json if the plugin-uri comes without it."
[plugin-url]
(if (str/ends-with? plugin-url "manifest.json")
plugin-url
(-> (u/uri plugin-url)
(update :path (fn [path]
(if (str/ends-with? path "/")
(str path "manifest.json")
(str path "/manifest.json"))))
(str))))
(defn- icon-url
"Creates an sanitizes de icon URL to display"
[host icon]
(dm/str host
(if (and (not (str/ends-with? host "/"))
(not (str/starts-with? icon "/")))
"/" "")
icon))
(mf/defc plugin-entry*
[{:keys [index manifest user-can-edit on-open-plugin on-remove-plugin remove-disabled]}]
(let [{:keys [plugin-id host icon name description permissions]} manifest
plugins-permissions-peek (deref refs/plugins-permissions-peek)
permissions (or (get plugins-permissions-peek plugin-id)
permissions)
is-edition-plugin? (or (contains? permissions "content:write")
(contains? permissions "library:write"))
can-open? (or user-can-edit
(not is-edition-plugin?))
handle-open-click
(mf/use-fn
(mf/deps index manifest on-open-plugin can-open?)
(fn []
(when (and can-open? on-open-plugin)
(on-open-plugin manifest))))
handle-delete-click
(mf/use-fn
(mf/deps index on-remove-plugin)
(fn []
(when on-remove-plugin
(on-remove-plugin index))))]
[:div {:class (stl/css :plugins-list-element)}
[:div {:class (stl/css :plugin-icon)}
[:img {:src (if (some? icon)
(icon-url host icon)
(avatars/generate {:name name}))}]]
[:div {:class (stl/css :plugin-description)}
[:div {:class (stl/css :plugin-title) :title name} name]
[:div {:class (stl/css :plugin-summary) :title description} (d/nilv description "")]]
[:> button* {:class (stl/css :open-button)
:variant "secondary"
:type "button"
:on-click handle-open-click
:title (when-not can-open? (tr "workspace.plugins.error.need-editor"))
:disabled (not can-open?)} (tr "workspace.plugins.button-open")]
[:> icon-button* {:variant "ghost"
:aria-label (tr "workspace.plugins.remove-plugin")
:on-click handle-delete-click
:disabled remove-disabled
:icon i/delete}]]))
(mf/defc plugin-management-dialog
{::mf/register modal/components
::mf/register-as :plugin-management}
[]
(let [plugins-state* (mf/use-state #(preg/plugins-list))
plugins-state (deref plugins-state*)
plugin-url* (mf/use-state "")
plugin-url (deref plugin-url*)
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-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)
fetching-manifest?
(mf/use-state false)
;; Ids with a persist in flight; their remove button is disabled
in-flight*
(mf/use-state #{})
on-url-change
(mf/use-fn
(fn [value]
(reset! input-status* nil)
(reset! plugin-url* value)))
on-install
(mf/use-fn
(mf/deps plugins-state plugin-url)
(fn []
(reset! fetching-manifest? true)
(->> (dp/fetch-manifest (normalize-plugin-url plugin-url))
(rx/subs!
(fn [plugin]
(reset! fetching-manifest? false)
(if plugin
(do
(st/emit! (ev/event {::ev/name "install-plugin" :name (:name plugin) :url plugin-url}))
(modal/show! :plugin-permissions
{:plugin plugin
:on-accept
#(do
(preg/install-plugin! plugin)
(modal/show! :plugin-management {}))})
(reset! input-status* :success)
(reset! plugin-url* ""))
;; Cannot get the manifest
(reset! input-status* :error-manifest)))
(fn [err]
(.error js/console err)
(reset! fetching-manifest? false)
(reset! input-status*
(if (= ::preg/global-not-allowed (:type (ex-data err)))
:error-global
:error-url)))))))
on-open-plugin
(mf/use-fn
(fn [manifest]
(st/emit! (ev/event {::ev/name "start-plugin"
::ev/origin "workspace:plugins"
:name (:name manifest)
:host (:host manifest)}))
(dp/open-plugin! manifest user-can-edit?)
(modal/hide!)))
on-remove-plugin
(mf/use-fn
(mf/deps plugins-state)
(fn [plugin-index]
(let [plugins-list (preg/plugins-list)
plugin (nth plugins-list plugin-index)
plugin-name (:name plugin)
;; Truncated so long names fit the confirm dialog
plugin-name (if (> (count plugin-name) 60)
(str (subs plugin-name 0 60) "…")
plugin-name)]
(modal/show!
{:type :confirm
:title (tr "workspace.plugins.remove-confirmation.title")
:message (tr "workspace.plugins.remove-confirmation.message" plugin-name)
:accept-label (tr "workspace.plugins.remove-plugin")
:on-accept (fn [_]
(st/emit! (ev/event {::ev/name "remove-plugin"
:name (:name plugin)
:host (:host plugin)}))
(dp/close-plugin! plugin)
(preg/remove-plugin! plugin)
(modal/show! :plugin-management {}))
:on-cancel (fn [_]
(modal/show! :plugin-management {}))}))))]
(mf/with-effect []
(let [listener (preg/subscribe-in-flight! #(reset! in-flight* %))]
(partial preg/unsubscribe-in-flight! listener)))
;; Re-reads the list on every registry change, including rollbacks
(mf/with-effect []
(let [listener (preg/subscribe-registry! #(reset! plugins-state* (preg/plugins-list)))]
(partial preg/unsubscribe-registry! listener)))
[:div {:class (stl/css :modal-overlay)}
[:div {:class (stl/css :modal-dialog :plugin-management)}
[:> icon-button* {:variant "ghost"
:tooltip-class (stl/css :close-btn)
:aria-label (tr "labels.close")
:icon i/close
:on-click modal/hide!}]
[:div {:class (stl/css :modal-title)} (tr "workspace.plugins.title")]
[:div {:class (stl/css :modal-content)}
[:div {:class (stl/css :top-bar)}
[:> search-bar* {:on-change on-url-change
:on-submit on-install
:value plugin-url
:placeholder (tr "workspace.plugins.search-placeholder")
:class (stl/css-case :input-error error?)}]
[:> button* {:variant "primary"
:disabled @fetching-manifest?
:on-click on-install}
(tr "workspace.plugins.install")]]
(when error-url?
[:div {:class (stl/css-case :info true :error error?)}
(tr "workspace.plugins.error.url")])
(when error-manifest?
[: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)
:on-click #(st/emit! (ev/event {::ev/name "open-plugins-list"}))
:content (tr "workspace.plugins.discover" cfg/plugins-list-uri)}])
[:hr]
(if (empty? plugins-state)
[:div {:class (stl/css :plugins-empty)}
[:div {:class (stl/css :plugins-empty-logo)}
[:> icon* {:icon-id i/puzzle :size "m"}]]
[:div {:class (stl/css :plugins-empty-text)} (tr "workspace.plugins.empty-plugins")]
[:a {:class (stl/css :plugins-link)
:href cfg/plugins-list-uri
:target "_blank"
:on-click #(st/emit! (ev/event {::ev/name "open-plugins-list"}))}
(tr "workspace.plugins.plugin-list-link")
[:> icon* {:icon-id i/external-link
:size "s"
:class (stl/css :plugins-link-icon)}]]]
[:*
[:> title-bar* {:collapsable false
:title (tr "workspace.plugins.installed-plugins")}]
[:div {:class (stl/css :plugins-list)}
(for [[idx manifest] (d/enumerate plugins-state)]
[:> plugin-entry* {:key (dm/str "plugin-" idx)
:index idx
:manifest manifest
:user-can-edit user-can-edit?
:remove-disabled (contains? @in-flight* (:plugin-id manifest))
:on-open-plugin on-open-plugin
:on-remove-plugin on-remove-plugin}])]])]]]))
(mf/defc plugins-permission-list*
{::mf/private true}
[{:keys [permissions]}]
[:div {:class (stl/css :permissions-list)}
(cond
(contains? permissions "content:write")
[:div {:class (stl/css :permissions-list-entry)}
deprecated-icon/oauth-1
[:p {:class (stl/css :permissions-list-text)}
(tr "workspace.plugins.permissions.content-write")]]
(contains? permissions "content:read")
[:div {:class (stl/css :permissions-list-entry)}
deprecated-icon/oauth-1
[:p {:class (stl/css :permissions-list-text)}
(tr "workspace.plugins.permissions.content-read")]])
(cond
(contains? permissions "user:read")
[:div {:class (stl/css :permissions-list-entry)}
deprecated-icon/oauth-2
[:p {:class (stl/css :permissions-list-text)}
(tr "workspace.plugins.permissions.user-read")]])
(cond
(contains? permissions "library:write")
[:div {:class (stl/css :permissions-list-entry)}
deprecated-icon/oauth-3
[:p {:class (stl/css :permissions-list-text)}
(tr "workspace.plugins.permissions.library-write")]]
(contains? permissions "library:read")
[:div {:class (stl/css :permissions-list-entry)}
deprecated-icon/oauth-3
[:p {:class (stl/css :permissions-list-text)}
(tr "workspace.plugins.permissions.library-read")]])
(cond
(contains? permissions "comment:write")
[:div {:class (stl/css :permissions-list-entry)}
deprecated-icon/oauth-1
[:p {:class (stl/css :permissions-list-text)}
(tr "workspace.plugins.permissions.comment-write")]]
(contains? permissions "comment:read")
[:div {:class (stl/css :permissions-list-entry)}
deprecated-icon/oauth-1
[:p {:class (stl/css :permissions-list-text)}
(tr "workspace.plugins.permissions.comment-read")]])
(cond
(contains? permissions "allow:downloads")
[:div {:class (stl/css :permissions-list-entry)}
deprecated-icon/oauth-1
[:p {:class (stl/css :permissions-list-text)}
(tr "workspace.plugins.permissions.allow-download")]])
(cond
(contains? permissions "allow:localstorage")
[:div {:class (stl/css :permissions-list-entry)}
deprecated-icon/oauth-1
[:p {:class (stl/css :permissions-list-text)}
(tr "workspace.plugins.permissions.allow-localstorage")]])
(cond
(contains? permissions "allow:global")
[:div {:class (stl/css :permissions-list-entry)}
deprecated-icon/oauth-1
[:p {:class (stl/css :permissions-list-text)}
(tr "workspace.plugins.permissions.allow-global")]])
(cond
(contains? permissions "manage:projects")
[:div {:class (stl/css :permissions-list-entry)}
deprecated-icon/oauth-1
[:p {:class (stl/css :permissions-list-text)}
(tr "workspace.plugins.permissions.manage-projects")]])
(cond
(contains? permissions "manage:teams")
[:div {:class (stl/css :permissions-list-entry)}
deprecated-icon/oauth-1
[:p {:class (stl/css :permissions-list-text)}
(tr "workspace.plugins.permissions.manage-teams")]])
(cond
(contains? permissions "manage:delete")
[:div {:class (stl/css :permissions-list-entry)}
deprecated-icon/oauth-1
[:p {:class (stl/css :permissions-list-text)}
(tr "workspace.plugins.permissions.manage-delete")]])
(cond
(contains? permissions "clipboard:write")
[:div {:class (stl/css :permissions-list-entry)}
deprecated-icon/oauth-1
[:p {:class (stl/css :permissions-list-text)}
(tr "workspace.plugins.permissions.clipboard-write")]]
(contains? permissions "clipboard:read")
[:div {:class (stl/css :permissions-list-entry)}
deprecated-icon/oauth-1
[:p {:class (stl/css :permissions-list-text)}
(tr "workspace.plugins.permissions.clipboard-read")]])])
(mf/defc plugins-permissions-dialog
{::mf/register modal/components
::mf/register-as :plugin-permissions}
[{:keys [plugin on-accept on-close]}]
(let [host
(:host plugin)
permissions
(-> plugin :permissions set)
on-accept-dialog
(mf/use-fn
(mf/deps on-accept)
(fn [event]
(dom/prevent-default event)
(st/emit! (ev/event {::ev/name "allow-plugin-permissions"
:host host
:permissions (str/join ", " permissions)})
(modal/hide))
(when on-accept (on-accept))))
on-close-dialog
(mf/use-fn
(mf/deps on-close)
(fn [event]
(dom/prevent-default event)
(st/emit! (ev/event {::ev/name "reject-plugin-permissions"
:host host
:permissions (str/join ", " permissions)})
(modal/hide))
(when on-close (on-close))))]
(mf/with-effect [on-accept-dialog]
(.addEventListener ^js global/document "keydown" on-accept-dialog)
#(.removeEventListener ^js global/document "keydown" on-accept-dialog))
[:div {:class (stl/css :modal-overlay)}
[:div {:class (stl/css :modal-dialog :plugin-permissions)}
[:> icon-button* {:variant "ghost"
:tooltip-class (stl/css :close-btn)
:aria-label (tr "labels.close")
:icon i/close
:on-click on-close-dialog}]
[:div {:class (stl/css :modal-title)} (tr "workspace.plugins.permissions.title" (str/upper (:name plugin)))]
[:div {:class (stl/css :modal-content)}
[:> plugins-permission-list* {:permissions permissions}]
(when-not (contains? cfg/plugins-whitelist host)
[:div {:class (stl/css :permissions-disclaimer)}
(tr "workspace.plugins.permissions.disclaimer")])]
[:div {:class (stl/css :modal-footer)}
[:div {:class (stl/css :action-buttons)}
[:> button* {:variant "secondary"
:class (stl/css :button-expand)
:on-click on-close-dialog}
(tr "ds.confirm-cancel")]
[:> button* {:variant "primary"
:class (stl/css :button-expand)
:on-click on-accept-dialog}
(tr "ds.confirm-allow")]]]]]))
(mf/defc plugins-permissions-updated-dialog
{::mf/register modal/components
::mf/register-as :plugin-permissions-update}
[{:keys [plugin on-accept on-close]}]
(let [host
(:host plugin)
permissions
(-> plugin :permissions set)
on-accept-dialog
(mf/use-fn
(mf/deps on-accept)
(fn [event]
(dom/prevent-default event)
(st/emit! (ev/event {::ev/name "allow-plugin-permissions"
:host host
:permissions (->> permissions (str/join ", "))})
(modal/hide))
(when on-accept (on-accept))))
on-close-dialog
(mf/use-fn
(mf/deps on-close)
(fn [event]
(dom/prevent-default event)
(st/emit! (ev/event {::ev/name "reject-plugin-permissions"
:host host
:permissions (->> permissions (str/join ", "))})
(modal/hide))
(when on-close (on-close))))]
(mf/with-effect [on-accept-dialog]
(.addEventListener ^js global/document "keydown" on-accept-dialog)
#(.removeEventListener ^js global/document "keydown" on-accept-dialog))
[:div {:class (stl/css :modal-overlay)}
[:div {:class (stl/css :modal-dialog :plugin-permissions)}
[:> icon-button* {:variant "ghost"
:tooltip-class (stl/css :close-btn)
:aria-label (tr "labels.close")
:icon i/close
:on-click on-close-dialog}]
[:div {:class (stl/css :modal-title)}
(tr "workspace.plugins.permissions-update.title" (str/upper (:name plugin)))]
[:div {:class (stl/css :modal-content)}
[:div {:class (stl/css :modal-paragraph)}
(tr "workspace.plugins.permissions-update.warning")]
[:> plugins-permission-list* {:permissions permissions}]]
[:div {:class (stl/css :modal-footer)}
[:div {:class (stl/css :action-buttons)}
[:> button* {:variant "secondary"
:class (stl/css :button-expand)
:on-click on-close-dialog}
(tr "ds.confirm-cancel")]
[:> button* {:variant "primary"
:class (stl/css :button-expand)
:on-click on-accept-dialog}
(tr "ds.confirm-allow")]]]]]))
(mf/defc plugins-try-out-dialog
{::mf/register modal/components
::mf/register-as :plugin-try-out}
[{:keys [plugin on-accept on-close]}]
(let [{:keys [icon host name]} plugin
on-accept-dialog
(mf/use-fn
(mf/deps on-accept)
(fn [event]
(dom/prevent-default event)
(st/emit! (ev/event {::ev/name "try-out-accept"})
(modal/hide))
(when on-accept (on-accept))))
on-close-dialog
(mf/use-fn
(mf/deps on-close)
(fn [event]
(dom/prevent-default event)
(st/emit! (ev/event {::ev/name "try-out-cancel"})
(modal/hide))
(when on-close (on-close))))]
(mf/with-effect [on-accept-dialog]
(.addEventListener ^js global/document "keydown" on-accept-dialog)
#(.removeEventListener ^js global/document "keydown" on-accept-dialog))
[:div {:class (stl/css :modal-overlay)}
[:div {:class (stl/css :modal-dialog :plugin-try-out)}
[:> icon-button* {:variant "ghost"
:tooltip-class (stl/css :close-btn)
:aria-label (tr "labels.close")
:icon i/close
:on-click on-close-dialog}]
[:div {:class (stl/css :modal-title)}
[:div {:class (stl/css :plugin-icon)}
[:img {:src (if (some? icon)
(icon-url host icon)
(avatars/generate {:name name}))}]]
(tr "workspace.plugins.try-out.title" (str/upper (:name plugin)))]
[:div {:class (stl/css :modal-content)}
[:div {:class (stl/css :modal-message)}
(tr "workspace.plugins.try-out.message")]]
[:div {:class (stl/css :modal-footer)}
[:div {:class (stl/css :action-buttons)}
[:> button* {:variant "secondary"
:class (stl/css :button-expand)
:on-click on-close-dialog}
(tr "workspace.plugins.try-out.cancel")]
[:> button* {:variant "primary"
:class (stl/css :button-expand)
:on-click on-accept-dialog}
(tr "workspace.plugins.try-out.try")]]]]]))