mirror of
https://github.com/penpot/penpot.git
synced 2026-10-28 21:40:34 +00:00
🐛 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:
parent
a5fd2df1f0
commit
577cbe43e4
@ -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))]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user