From 577cbe43e425677136706335cca93bbe95338259 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bel=C3=A9n=20Albeza?= Date: Mon, 26 Oct 2026 12:36:13 +0100 Subject: [PATCH] :bug: Validate the text payload before pasting it (#12612) Text copied in Penpot carries its content in a data attribute of the clipboard HTML, which plugins and other apps can rewrite. The v3 editor and canvas paste used it as is, so bad data could reach WASM and the file and break the shape. The payload is now dropped, and the paste falls back to plain text in the caret style, unless its content passes the text shape schema, its span geometry holds only safe numbers and its file and team ids are uuids. Payloads in HTML over the paste size limit are not decoded. Copied text shapes pasted into the editor get the same checks, plus a numeric position; when they fail, nothing is pasted. Closes #12167 AI-assisted-by: claude-opus-5-5 --- .../src/app/main/data/workspace/texts.cljs | 41 ++++++++++------- frontend/src/app/util/text/clipboard.cljs | 37 ++++++++++++++-- .../util_text_clipboard_test.cljs | 44 +++++++++++++++++++ 3 files changed, 102 insertions(+), 20 deletions(-) diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs index c50af92813..bc077f92c4 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -22,6 +22,7 @@ [app.common.types.fills :as types.fills] [app.common.types.modifiers :as ctm] [app.common.types.shape.layout :as ctl] + [app.common.types.shape.text :as cts] [app.common.types.text :as txt] [app.common.uuid :as uuid] [app.main.data.changes :as dch] @@ -1617,9 +1618,6 @@ (rx/catch on-clipboard-error)) (rx/empty))))) -;; Bigger HTML is pasted as plain text rather than walked. -(def ^:private max-paste-html-length 1000000) - (defn payload-content "The content of a Penpot text `payload` without what the current file cannot reach. A payload without team counts as coming from the current team." @@ -1636,7 +1634,10 @@ [text] (when (str/starts-with? (str/trim (or text "")) "{") (let [data (ex/ignoring (t/decode-str text))] - (when (and (map? data) (= :copied-shapes (:type data))) + (when (and (map? data) + (= :copied-shapes (:type data)) + (map? (:objects data)) + (set? (:selected data))) data)))) (defn copied-text-geometry @@ -1660,22 +1661,30 @@ (mapv #(mapv (constantly nil) (:children %)) (-> content :children first :children)))) +(defn- pasteable-text? + "True when a copied text shape, which other apps or plugins may have written, can + be pasted into the text editor." + [{:keys [x y content]}] + (and (number? x) (number? y) (cts/valid-content? content))) + (defn- copied-shapes->payload "A text payload joining the content of the copied text shapes, top to bottom and - left to right; nil when none of the copied shapes is a text." + left to right; nil when none of the copied shapes is a text or it is not valid." [{:keys [objects selected file-id team-id text-geometry]}] (let [texts (->> selected (keep #(get objects %)) - (filter cfh/text-shape?) - (sort-by (juxt :y :x)))] - (when (seq texts) - (cond-> {:file-id file-id - :team-id team-id - :content (assoc-in (:content (first texts)) - [:children 0 :children] - (into [] (mapcat #(-> % :content :children first :children)) texts))} - (some? text-geometry) - (assoc :geometry {:origins (into [] (mapcat (partial text-origins text-geometry)) texts)}))))) + (filter cfh/text-shape?))] + (when (and (seq texts) (every? pasteable-text? texts)) + (let [texts (sort-by (juxt :y :x) texts) + payload (cond-> {:file-id file-id + :team-id team-id + :content (assoc-in (:content (first texts)) + [:children 0 :children] + (into [] (mapcat #(-> % :content :children first :children)) texts))} + (some? text-geometry) + (assoc :geometry {:origins (into [] (mapcat (partial text-origins text-geometry)) texts)}))] + (when (text-clipboard/valid-payload? payload) + payload))))) (defn- penpot-paste [state payload] @@ -1700,7 +1709,7 @@ (some->> (copied-shapes->payload shapes) (penpot-paste state)) :else - (when-let [fragment (or (when (and html-paste? (< 0 (count html) max-paste-html-length)) + (when-let [fragment (or (when (and html-paste? (< 0 (count html) text-clipboard/max-paste-html-length)) (some-> (text-clipboard/html->fragment html) (text-clipboard/without-overrides))) (text-clipboard/text->fragment text))] diff --git a/frontend/src/app/util/text/clipboard.cljs b/frontend/src/app/util/text/clipboard.cljs index 35df340afe..f8368aa34a 100644 --- a/frontend/src/app/util/text/clipboard.cljs +++ b/frontend/src/app/util/text/clipboard.cljs @@ -11,7 +11,9 @@ [app.common.data :as d] [app.common.exceptions :as ex] [app.common.fonts :as cfnt] + [app.common.schema :as sm] [app.common.transit :as t] + [app.common.types.shape.text :as cts] [app.common.types.text :as txt] [app.common.types.typography :as ctt] [cuerdas.core :as str])) @@ -277,6 +279,29 @@ (def ^:private payload-re #"data-penpot-text=\"([A-Za-z0-9+/=]*)\"") +;; Bigger HTML is pasted as plain text rather than read. +(def max-paste-html-length 1000000) + +(def ^:private schema:origin + [:map {:closed true} + [:x ::sm/safe-number] + [:y ::sm/safe-number] + [:box [:map {:closed true} + [:width ::sm/safe-number] + [:height ::sm/safe-number]]]]) + +(def ^:private valid-geometry? + (sm/lazy-validator + [:map {:closed true} + [:origins [:vector [:vector [:maybe schema:origin]]]]])) + +(defn valid-payload? + "True when the `content` and `geometry` of a payload, which other apps or plugins + may have written, are safe to paste into a text shape." + [{:keys [content geometry]}] + (and (cts/valid-content? content) + (or (nil? geometry) (valid-geometry? geometry)))) + (defn- encode-base64 [s] (->> (.encode (js/TextEncoder.) s) @@ -307,11 +332,15 @@ (defn html->payload "The Penpot payload in `html`, or nil when it has none." [html] - (when-let [[_ encoded] (some->> html (re-find payload-re))] - (let [payload (ex/ignoring (t/decode-str (decode-base64 encoded)))] + (when-let [[_ encoded] (when (< (count html) max-paste-html-length) + (some->> html (re-find payload-re)))] + (let [{:keys [type file-id team-id] :as payload} + (ex/ignoring (t/decode-str (decode-base64 encoded)))] (when (and (map? payload) - (= :copied-text (:type payload)) - (map? (:content payload))) + (= :copied-text type) + (uuid? file-id) + (or (nil? team-id) (uuid? team-id)) + (valid-payload? payload)) payload)))) ;; --- Pasting into another file or team diff --git a/frontend/test/frontend_tests/util_text_clipboard_test.cljs b/frontend/test/frontend_tests/util_text_clipboard_test.cljs index e5534a1517..88b1cbdcfe 100644 --- a/frontend/test/frontend_tests/util_text_clipboard_test.cljs +++ b/frontend/test/frontend_tests/util_text_clipboard_test.cljs @@ -228,6 +228,50 @@ (t/is (nil? (clipboard/html->payload ""))) (t/is (nil? (clipboard/html->payload nil))))) +(defn- tampered-html + "Payload markup carrying `payload` as is, as a plugin could write it." + [{:keys [content file-id team-id geometry]}] + (clipboard/payload->html content file-id team-id geometry)) + +(def ^:private valid-payload + {:content (text-content gradient-span) :file-id file-id :team-id team-id}) + +(t/deftest invalid-payload + (t/testing "a valid payload is read" + (t/is (some? (clipboard/html->payload (tampered-html valid-payload))))) + + (t/testing "a payload with a broken envelope is dropped" + (t/is (nil? (clipboard/html->payload (tampered-html (assoc valid-payload :file-id "file"))))) + (t/is (nil? (clipboard/html->payload (tampered-html (assoc valid-payload :team-id 42)))))) + + (t/testing "a payload whose content a text shape cannot hold is dropped" + (t/is (nil? (clipboard/html->payload + (tampered-html (assoc-in valid-payload [:content :children 0 :children] []))))) + (t/is (nil? (clipboard/html->payload + (tampered-html (assoc valid-payload :content (text-content (assoc gradient-span :text 42))))))) + (t/is (nil? (clipboard/html->payload + (tampered-html (assoc valid-payload :content + (text-content (assoc gradient-span :fills [{:fill-color "red"}])))))))) + + (t/testing "a payload with geometry that is not plain numbers is dropped" + (let [origin {:x 12.5 :y 0.2 :box {:width 200 :height 40}}] + (t/is (some? (clipboard/html->payload + (tampered-html (assoc valid-payload :geometry {:origins [[origin]]}))))) + (t/is (nil? (clipboard/html->payload + (tampered-html (assoc valid-payload :geometry {:origins [[(assoc origin :x "12")]]}))))) + (t/is (nil? (clipboard/html->payload + (tampered-html (assoc valid-payload :geometry {:origins [[(assoc origin :y ##NaN)]]}))))) + (t/is (nil? (clipboard/html->payload + (tampered-html (assoc valid-payload :geometry {:origins [[(assoc-in origin [:box :width] nil)]]}))))) + (t/is (nil? (clipboard/html->payload + (tampered-html (assoc valid-payload :geometry {:origins [[origin]] :extra 1})))))))) + +(t/deftest oversized-payload + (t/testing "HTML over the paste size limit is not decoded" + (let [hidden (clipboard/payload->html (text-content gradient-span) file-id team-id)] + (t/is (nil? (clipboard/html->payload + (str hidden (apply str (repeat clipboard/max-paste-html-length "x"))))))))) + ;; --- Clean-up (def ^:private image-fill