🐛 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
This commit is contained in:
Belén Albeza 2026-10-26 12:36:13 +01:00 committed by GitHub
parent a5fd2df1f0
commit 577cbe43e4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 102 additions and 20 deletions

View File

@ -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))]

View File

@ -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

View File

@ -228,6 +228,50 @@
(t/is (nil? (clipboard/html->payload "<span data-penpot-text=\"bm90IHRyYW5zaXQ=\"></span>")))
(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