diff --git a/frontend/src/app/main/data/plugins.cljs b/frontend/src/app/main/data/plugins.cljs index e9b15e0849..f68806fc4a 100644 --- a/frontend/src/app/main/data/plugins.cljs +++ b/frontend/src/app/main/data/plugins.cljs @@ -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))) diff --git a/frontend/src/app/main/ui/dashboard.cljs b/frontend/src/app/main/ui/dashboard.cljs index c83a33e97d..13059c1036 100644 --- a/frontend/src/app/main/ui/dashboard.cljs +++ b/frontend/src/app/main/ui/dashboard.cljs @@ -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)))))) diff --git a/frontend/src/app/main/ui/workspace/plugins.cljs b/frontend/src/app/main/ui/workspace/plugins.cljs index cd73c45664..86e237fdc9 100644 --- a/frontend/src/app/main/ui/workspace/plugins.cljs +++ b/frontend/src/app/main/ui/workspace/plugins.cljs @@ -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) diff --git a/frontend/src/app/plugins/register.cljs b/frontend/src/app/plugins/register.cljs index 3c919eed50..5c7c4b4fe6 100644 --- a/frontend/src/app/plugins/register.cljs +++ b/frontend/src/app/plugins/register.cljs @@ -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. diff --git a/frontend/test/frontend_tests/plugins/register_test.cljs b/frontend/test/frontend_tests/plugins/register_test.cljs index a4399ab6af..afa0cfe304 100644 --- a/frontend/test/frontend_tests/plugins/register_test.cljs +++ b/frontend/test/frontend_tests/plugins/register_test.cljs @@ -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")))))) diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 185602dbb5..c0ef6a81ee 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -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." diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 5871401d67..c8506b8466 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -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."