mirror of
https://github.com/penpot/penpot.git
synced 2026-10-29 05:50:35 +00:00
🐛 Sanitize SVG on binfile import (#12105)
* 🐛 Sanitize smuggled SVG on binfile import Route imported SVG storage objects through the SVG sanitizer on all three binfile paths (v3, v1, v2), closing the second bypass of GHSA-ffhp-m958-qxvr. Integrity checks still run on the raw bundle bytes first; only the persisted copy is the sanitized one. Adds a shared sanitize-imported-svg helper with unit tests and a v3 export-tamper-import test proving smuggled scripts no longer survive the import. AI-assisted-by: muse-spark-1.3-contributor * 🐛 Normalize SVG content-type on binfile import Close the residual GHSA-ffhp-m958-qxvr bypass where a crafted content-type spelling (uppercase, parameters) skipped the import sanitizer. Detection now uses a shared case-insensitive predicate, stored values are canonicalized, and the v3 re-read honors the import size limit. Extends the tamper tests to obfuscated spellings and adds exhaustive v1 coverage, including the tempfile branch. AI-assisted-by: muse-spark-1.3-contributor * 🐛 Gate binfile content-type on known storage types Define the complete set of storage content-types in app.common.media and enforce it on the binfile import schema, so unknown types fail closed instead of passing through. Detection keeps a single normalization at the boundary with an exact predicate, and the string helpers use cuerdas. AI-assisted-by: muse-spark-1.3-contributor * 🐛 Keep only produced types in storage-object-types Drop apng, avif, penpot and plain text from the set: no flow stores them, they only exist in the extension mapping table. Every member must have a producing code path; unproduced types stay out until some flow actually stores them. AI-assisted-by: muse-spark-1.3-contributor * 🐛 Reject unknown content-type on v1 binfile import Enforce the storage content-type allowlist on the v1 import path, like v3 already does via schema. Bundles declaring types outside the set, or none at all, fail closed with the same media-type-not-allowed error as uploads. The sanitize branch is explicit: SVG bytes are sanitized, anything else passes through. AI-assisted-by: muse-spark-1.3-contributor
This commit is contained in:
parent
4b70c9aa21
commit
10504ff2bf
@ -15,6 +15,7 @@
|
||||
[app.common.files.migrations :as fmg]
|
||||
[app.common.files.validate :as fval]
|
||||
[app.common.logging :as l]
|
||||
[app.common.media :as cm]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.time :as ct]
|
||||
[app.common.types.file :as ctf]
|
||||
@ -27,6 +28,7 @@
|
||||
[app.features.file-migrations :as fmigr]
|
||||
[app.loggers.audit :as-alias audit]
|
||||
[app.loggers.webhooks :as-alias webhooks]
|
||||
[app.media.svg :as svg]
|
||||
[app.storage :as sto]
|
||||
[app.util.blob :as blob]
|
||||
[app.util.pointer-map :as pmap]
|
||||
@ -898,3 +900,74 @@
|
||||
(cons (:id file)))
|
||||
load-fn #(get-file cfg % :migrate? false)]
|
||||
(weak/loadable-weak-value-map library-ids load-fn {id file})))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; SVG IMPORT SANITIZATION
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(def svg-content-type
|
||||
"Canonical content type of SVG media objects."
|
||||
"image/svg+xml")
|
||||
|
||||
(defn normalize-content-type
|
||||
"Canonicalize a stored content-type: lowercase, trimmed,
|
||||
parameters after `;` dropped. Returns nil for missing or unusable
|
||||
values so ancient bundle entries without content-type keep passing
|
||||
through untouched."
|
||||
[ctype]
|
||||
(when (string? ctype)
|
||||
(let [clean (-> ctype
|
||||
(str/split #";" 2)
|
||||
(first)
|
||||
(str/trim)
|
||||
(str/lower))]
|
||||
(when-not (str/empty? clean)
|
||||
clean))))
|
||||
|
||||
(defn svg-object?
|
||||
"True when the storage `object` claims the canonical SVG content
|
||||
type. Expects an already normalized object (see
|
||||
`normalize-content-type`): every import path normalizes the
|
||||
metadata right after reading it, so the comparison stays an exact
|
||||
match in a single place."
|
||||
[object]
|
||||
(= svg-content-type (:content-type object)))
|
||||
|
||||
(def schema:content-type
|
||||
"Typed content-type for binfile storage objects: a member of
|
||||
`cm/storage-object-types`. Values are canonicalized with
|
||||
`normalize-content-type` before validation, so legacy spellings
|
||||
keep importing while unknown types are rejected."
|
||||
[::sm/one-of {:format :string} cm/storage-object-types])
|
||||
|
||||
(defn check-storage-content-type
|
||||
"Check an imported storage `object` (already normalized, see
|
||||
`normalize-content-type`) against `cm/storage-object-types`.
|
||||
Returns nil when the type is allowed; raises `:type :validation`
|
||||
with `:code :media-type-not-allowed` (same as the upload path)
|
||||
when it is missing or unknown, so crafted bundles fail closed."
|
||||
[object]
|
||||
(when-not (contains? cm/storage-object-types (:content-type object))
|
||||
(ex/raise :type :validation
|
||||
:code :media-type-not-allowed
|
||||
:hint "storage object declares an unknown content-type"
|
||||
:content-type (:content-type object))))
|
||||
|
||||
(defn sanitize-imported-svg
|
||||
"Sanitize the raw `bytes` of an imported storage `object` when it
|
||||
holds an SVG document. Expects an already normalized object (see
|
||||
`normalize-content-type`); returns nil when the object is not an
|
||||
SVG.
|
||||
|
||||
Otherwise returns a map with the sanitized `:bytes`, their `:size`
|
||||
and their blake2b `:hash`, ready to persist with `sto/put-object!`.
|
||||
|
||||
Raises a `:validation` exception when the SVG cannot be parsed, the
|
||||
same error the upload path reports."
|
||||
[object ^bytes raw]
|
||||
(when (svg-object? object)
|
||||
(let [sanitized (svg/sanitize-svg (String. ^bytes raw "UTF-8"))
|
||||
bytes (.getBytes ^String sanitized "UTF-8")]
|
||||
{:bytes bytes
|
||||
:size (alength ^bytes bytes)
|
||||
:hash (sto/calculate-hash (java.io.ByteArrayInputStream. bytes))})))
|
||||
|
||||
@ -607,16 +607,26 @@
|
||||
|
||||
(doseq [expected-storage-id ids]
|
||||
(let [id (read-uuid! input)
|
||||
mdata (read-obj! input)]
|
||||
mdata (d/update-when (read-obj! input) :content-type bfc/normalize-content-type)]
|
||||
|
||||
(when (not= id expected-storage-id)
|
||||
(ex/raise :type :validation
|
||||
:code :inconsistent-penpot-file
|
||||
:hint "the penpot file seems corrupt, found unexpected uuid (storage-object-id)"))
|
||||
|
||||
(bfc/check-storage-content-type mdata)
|
||||
|
||||
(l/dbg :hint "readed storage object" :id (str id) ::l/sync? true)
|
||||
|
||||
(let [[size resource] (read-stream! input)
|
||||
[resource size] (if (bfc/svg-object? mdata)
|
||||
(let [raw (if (bytes? resource)
|
||||
resource
|
||||
(with-open [istream (jio/input-stream resource)]
|
||||
(io/read istream)))
|
||||
{:keys [bytes size]} (bfc/sanitize-imported-svg mdata raw)]
|
||||
[bytes size])
|
||||
[resource size])
|
||||
hash (sto/calculate-hash resource)
|
||||
content (-> (sto/content resource size)
|
||||
(sto/wrap-with-hash hash))
|
||||
|
||||
@ -197,9 +197,17 @@
|
||||
|
||||
(defn- read-storage-object!
|
||||
[{:keys [::sto/storage ::bfc/timestamp] :as cfg} id]
|
||||
(let [mdata (read-obj cfg :storage-object id)
|
||||
(let [mdata (d/update-when (read-obj cfg :storage-object id)
|
||||
:content-type bfc/normalize-content-type)
|
||||
data (read-blob cfg :storage-object id)
|
||||
hash (sto/calculate-hash data)
|
||||
;; NOTE: v2 bundles are legacy and no longer produced; the
|
||||
;; sanitizer call stays as defense in depth and is covered by
|
||||
;; the shared helper unit tests, with no dedicated v2
|
||||
;; import test.
|
||||
clean (when (bfc/svg-object? mdata)
|
||||
(bfc/sanitize-imported-svg mdata data))
|
||||
data (or (:bytes clean) data)
|
||||
hash (or (:hash clean) (sto/calculate-hash data))
|
||||
|
||||
content (-> (sto/content data)
|
||||
(sto/wrap-with-hash hash))
|
||||
|
||||
@ -74,7 +74,7 @@
|
||||
[:map {:title "StorageObject"}
|
||||
[:id ::sm/uuid]
|
||||
[:size ::sm/int]
|
||||
[:content-type :string]
|
||||
[:content-type bfc/schema:content-type]
|
||||
[:bucket [::sm/one-of {:format :string} sto/valid-buckets]]
|
||||
[:hash {:optional true} :string]])
|
||||
|
||||
@ -871,6 +871,7 @@
|
||||
(doseq [{:keys [id entry]} entries]
|
||||
(let [object (-> (read-entry input entry)
|
||||
(decode-storage-object)
|
||||
(d/update-when :content-type bfc/normalize-content-type)
|
||||
(update :bucket d/nilv sto/default-bucket)
|
||||
(validate-storage-object))
|
||||
|
||||
@ -906,7 +907,17 @@
|
||||
:expected-hash (:hash object)
|
||||
:found-hash (sto/get-hash content))))
|
||||
|
||||
(let [params (-> object
|
||||
(let [clean (when (bfc/svg-object? object)
|
||||
(let [limit (::bfc/import-max-object-size cfg)
|
||||
raw (with-open [istream (cond-> (zip-entry-stream input (get-zip-entry input path))
|
||||
limit (size-limiting-stream limit))]
|
||||
(io/read istream))]
|
||||
(bfc/sanitize-imported-svg object raw)))
|
||||
content (if-let [{:keys [bytes hash]} clean]
|
||||
(-> (sto/content bytes)
|
||||
(sto/wrap-with-hash hash))
|
||||
content)
|
||||
params (-> object
|
||||
(dissoc :id :size)
|
||||
(assoc ::sto/content content)
|
||||
(assoc ::sto/deduplicate? true)
|
||||
|
||||
425
backend/test/backend_tests/binfile_svg_test.clj
Normal file
425
backend/test/backend_tests/binfile_svg_test.clj
Normal file
@ -0,0 +1,425 @@
|
||||
;; 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 INC Sucursal en España SL
|
||||
|
||||
(ns backend-tests.binfile-svg-test
|
||||
"SVG sanitization on the binfile import path.
|
||||
|
||||
Second case of GHSA-ffhp-m958-qxvr: a crafted `.penpot` bundle can
|
||||
smuggle an unsanitized SVG storage object past the upload-path
|
||||
sanitizer, and the raw bytes are served same-origin as
|
||||
`image/svg+xml`."
|
||||
(:require
|
||||
[app.binfile.common :as bfc]
|
||||
[app.binfile.v1 :as v1]
|
||||
[app.binfile.v3 :as v3]
|
||||
[app.common.features :as cfeat]
|
||||
[app.common.types.shape :as cts]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.db :as db]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.storage :as sto]
|
||||
[app.storage.tmp :as tmp]
|
||||
[backend-tests.helpers :as th]
|
||||
[clojure.data.json :as json]
|
||||
[clojure.java.io :as jio]
|
||||
[clojure.string :as str]
|
||||
[clojure.test :as t]
|
||||
[datoteka.fs :as fs]
|
||||
[datoteka.io :as io])
|
||||
(:import
|
||||
java.io.ByteArrayInputStream
|
||||
java.util.zip.ZipEntry
|
||||
java.util.zip.ZipFile
|
||||
java.util.zip.ZipOutputStream))
|
||||
|
||||
(t/use-fixtures :once th/state-init)
|
||||
(t/use-fixtures :each th/database-reset)
|
||||
|
||||
(def ^:private evil-svg
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"64\" height=\"64\"><script>PWNMARK_SCRIPT</script><circle r=\"20\"/></svg>")
|
||||
|
||||
(def ^:private evil-xhref-svg
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:x=\"http://www.w3.org/1999/xlink\" width=\"64\" height=\"64\"><a x:href=\"javascript:PWNMARK_XHREF\"><text x=\"10\" y=\"30\">click</text></a></svg>")
|
||||
|
||||
(def ^:private clean-svg
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"10\" height=\"10\"><circle r=\"4\"/></svg>")
|
||||
|
||||
(defn- utf8bytes
|
||||
[^String s]
|
||||
(.getBytes s "UTF-8"))
|
||||
|
||||
(defn- bytes-str
|
||||
[^bytes data]
|
||||
(String. data "UTF-8"))
|
||||
|
||||
(t/deftest sanitize-imported-svg-removes-script
|
||||
(let [result (bfc/sanitize-imported-svg {:content-type "image/svg+xml" :bucket "file-media-object"}
|
||||
(utf8bytes evil-svg))]
|
||||
(t/is (some? result))
|
||||
(t/is (not (str/includes? (bytes-str (:bytes result)) "<script")))
|
||||
(t/is (not (str/includes? (bytes-str (:bytes result)) "PWNMARK_SCRIPT")))))
|
||||
|
||||
(t/deftest sanitize-imported-svg-removes-aliased-xhref
|
||||
(let [object {:content-type "image/svg+xml; charset=utf-8" :bucket "file-media-object"}
|
||||
object (update object :content-type bfc/normalize-content-type)
|
||||
result (bfc/sanitize-imported-svg object (utf8bytes evil-xhref-svg))]
|
||||
(t/is (some? result))
|
||||
(t/is (not (str/includes? (bytes-str (:bytes result)) "javascript:")))
|
||||
(t/is (not (str/includes? (bytes-str (:bytes result)) "PWNMARK_XHREF")))))
|
||||
|
||||
(t/deftest svg-object-predicate-expects-canonical
|
||||
(t/is (true? (bfc/svg-object? {:content-type "image/svg+xml"})))
|
||||
(t/is (false? (bfc/svg-object? {:content-type "IMAGE/SVG+XML"})))
|
||||
(t/is (false? (bfc/svg-object? {:content-type nil})))
|
||||
(t/is (false? (bfc/svg-object? {}))))
|
||||
|
||||
(t/deftest sanitize-imported-svg-matches-obfuscated-spelling
|
||||
(t/testing "spellings normalized at the import boundary still sanitize"
|
||||
(doseq [ctype ["IMAGE/SVG+XML" "Image/Svg+Xml" " image/svg+xml " "image/svg+xml; charset=utf-8"]]
|
||||
(let [object {:content-type ctype :bucket "file-media-object"}
|
||||
object (update object :content-type bfc/normalize-content-type)
|
||||
result (bfc/sanitize-imported-svg object (utf8bytes evil-svg))]
|
||||
(t/is (some? result) (str "expected sanitize for " (pr-str ctype)))
|
||||
(when (some? result)
|
||||
(t/is (not (str/includes? (bytes-str (:bytes result)) "<script"))
|
||||
(str "script survived for " (pr-str ctype))))))))
|
||||
|
||||
(t/deftest sanitize-imported-svg-ignores-missing-or-blank-type
|
||||
(t/testing "ancient bundle entries without content-type pass through untouched"
|
||||
(let [raw (utf8bytes "not-an-svg")]
|
||||
(t/is (nil? (bfc/sanitize-imported-svg {} raw)))
|
||||
(t/is (nil? (bfc/sanitize-imported-svg {:content-type nil} raw)))
|
||||
(t/is (nil? (bfc/sanitize-imported-svg {:content-type ""} raw)))
|
||||
(t/is (nil? (bfc/sanitize-imported-svg {:content-type " "} raw))))))
|
||||
|
||||
(t/deftest sanitize-imported-svg-ignores-non-svg
|
||||
(t/testing "objects that are not SVG pass through untouched"
|
||||
(let [raw (utf8bytes "not-an-svg")]
|
||||
(t/is (nil? (bfc/sanitize-imported-svg {:content-type "image/jpeg" :bucket "file-media-object"} raw)))
|
||||
(t/is (nil? (bfc/sanitize-imported-svg {:content-type "image/png" :bucket "file-media-object"} raw))))))
|
||||
|
||||
(t/deftest sanitize-imported-svg-reports-size-and-hash
|
||||
(let [result (bfc/sanitize-imported-svg {:content-type "image/svg+xml" :bucket "file-media-object"}
|
||||
(utf8bytes clean-svg))]
|
||||
(t/is (some? result))
|
||||
(t/is (= (alength ^bytes (:bytes result)) (:size result)))
|
||||
(t/is (str/starts-with? (:hash result) "blake2b:"))
|
||||
(t/is (str/includes? (bytes-str (:bytes result)) "<circle"))))
|
||||
|
||||
(t/deftest sanitize-imported-svg-rejects-broken-svg
|
||||
(t/testing "unparseable SVG raises the same validation error as the upload path"
|
||||
(t/is (thrown-with-msg? Exception #"SVG parsing failed during sanitization"
|
||||
(bfc/sanitize-imported-svg {:content-type "image/svg+xml" :bucket "file-media-object"}
|
||||
(utf8bytes "<svg><not-closed>"))))))
|
||||
|
||||
(t/deftest check-storage-content-type-allows-known-types
|
||||
(doseq [ctype ["image/svg+xml" "image/jpeg" "font/woff2" "application/octet-stream"]]
|
||||
(t/is (nil? (bfc/check-storage-content-type {:content-type ctype}))
|
||||
(str "expected pass for " (pr-str ctype)))))
|
||||
|
||||
(t/deftest check-storage-content-type-rejects-unknown-types
|
||||
(doseq [ctype ["text/html" "application/x-font-woff" nil "" " "]]
|
||||
(let [object (if (nil? ctype) {} {:content-type ctype})
|
||||
out (try (bfc/check-storage-content-type object)
|
||||
nil
|
||||
(catch clojure.lang.ExceptionInfo e
|
||||
(ex-data e)))]
|
||||
(t/is (= :validation (:type out)) (str "expected validation for " (pr-str ctype)))
|
||||
(t/is (= :media-type-not-allowed (:code out)) (str "expected media-type-not-allowed for " (pr-str ctype))))))
|
||||
|
||||
(t/deftest storage-object-schema-constrains-content-type
|
||||
(t/testing "known stored types validate, unknown types do not"
|
||||
(let [base {:id (uuid/random) :size 10 :bucket "file-media-object"}]
|
||||
(doseq [ctype ["image/svg+xml" "image/jpeg" "image/png"
|
||||
"font/woff2" "font/ttf" "application/octet-stream" "application/pdf"]]
|
||||
(t/is (some? (v3/validate-storage-object (assoc base :content-type ctype)))
|
||||
(str "expected valid for " (pr-str ctype))))
|
||||
(doseq [ctype ["" " " "image /svg" "image/apng" "image/avif"
|
||||
"application/x-font-woff" (apply str (repeat 200 "x"))]]
|
||||
(t/is (thrown? clojure.lang.ExceptionInfo
|
||||
(v3/validate-storage-object (assoc base :content-type ctype)))
|
||||
(str "expected rejection for " (pr-str ctype)))))))
|
||||
|
||||
(defn- write-temp-svg
|
||||
"Write `text` to a tempfile and return `[path size]`."
|
||||
[text]
|
||||
(let [path (fs/create-tempfile :prefix "penpot-svg-import-test-" :suffix ".svg")
|
||||
data (utf8bytes text)]
|
||||
(spit (str path) text :encoding "UTF-8")
|
||||
[path (alength data)]))
|
||||
|
||||
(defn- tamper-svg-entry!
|
||||
"Copy `src` zip to `dst` zip replacing the `objects/<storage-id>.svg`
|
||||
payload with `evil-bytes` and fixing its `.json` sidecar (size and
|
||||
blake2b hash) so the bundle passes the integrity checks. When
|
||||
`content-type` is given, the sidecar declares it instead of the
|
||||
original spelling."
|
||||
[src dst storage-id ^bytes evil-bytes & {:keys [content-type]}]
|
||||
(let [svg-name (str "objects/" storage-id ".svg")
|
||||
json-name (str "objects/" storage-id ".json")]
|
||||
(with-open [zin (ZipFile. (.toFile ^java.nio.file.Path src))
|
||||
zout (ZipOutputStream. (io/output-stream dst))]
|
||||
(doseq [entry (enumeration-seq (.entries zin))]
|
||||
(let [name (.getName ^ZipEntry entry)]
|
||||
(cond
|
||||
(= name svg-name)
|
||||
(do (.putNextEntry zout (ZipEntry. ^String name))
|
||||
(.write zout evil-bytes 0 (alength evil-bytes))
|
||||
(.closeEntry zout))
|
||||
|
||||
(= name json-name)
|
||||
(let [raw (slurp (.getInputStream zin entry) :encoding "UTF-8")
|
||||
parsed (json/read-str raw)
|
||||
updated (cond-> (assoc parsed
|
||||
"size" (alength evil-bytes)
|
||||
"hash" (sto/calculate-hash (ByteArrayInputStream. evil-bytes)))
|
||||
content-type (assoc "contentType" content-type))]
|
||||
(.putNextEntry zout (ZipEntry. ^String name))
|
||||
(.write zout (utf8bytes (json/write-str updated)))
|
||||
(.closeEntry zout))
|
||||
|
||||
:else
|
||||
(do (.putNextEntry zout (ZipEntry. ^String name))
|
||||
(with-open [in (.getInputStream zin entry)]
|
||||
(io/copy in zout))
|
||||
(.closeEntry zout))))))))
|
||||
|
||||
(defn- setup-file-with-svg-media
|
||||
"Upload `clean-svg`, attach it to a shape fill and return
|
||||
`[file uploaded]` ready to export."
|
||||
[profile n]
|
||||
(let [file (th/create-file* n {:profile-id (:id profile)
|
||||
:project-id (:default-project-id profile)
|
||||
:is-shared false})
|
||||
[svg-path svg-size] (write-temp-svg clean-svg)
|
||||
out (th/command! {::th/type :upload-file-media-object
|
||||
::rpc/profile-id (:id profile)
|
||||
:file-id (:id file)
|
||||
:is-local true
|
||||
:name "benign.svg"
|
||||
:content {:filename "benign.svg"
|
||||
:path svg-path
|
||||
:mtype "image/svg+xml"
|
||||
:size svg-size}})
|
||||
_ (t/is (nil? (:error out)) (str "upload failed: " (pr-str (:error out))))
|
||||
uploaded (:result out)
|
||||
page-id (first (get-in file [:data :pages]))
|
||||
shape-id (uuid/random)
|
||||
upd (th/command! {::th/type :update-file
|
||||
::rpc/profile-id (:id profile)
|
||||
:id (:id file)
|
||||
:session-id (uuid/random)
|
||||
:revn 0
|
||||
:vern 0
|
||||
:features cfeat/supported-features
|
||||
:changes
|
||||
[{:type :add-obj
|
||||
:page-id page-id
|
||||
:id shape-id
|
||||
:parent-id uuid/zero
|
||||
:frame-id uuid/zero
|
||||
:components-v2 true
|
||||
:obj (cts/setup-shape
|
||||
{:id shape-id
|
||||
:name "image"
|
||||
:frame-id uuid/zero
|
||||
:parent-id uuid/zero
|
||||
:type :rect
|
||||
:fills [{:fill-opacity 1
|
||||
:fill-image {:id (:id uploaded)
|
||||
:width (:width uploaded)
|
||||
:height (:height uploaded)
|
||||
:mtype "image/svg+xml"}}]})}]})]
|
||||
(t/is (nil? (:error upd)) (str "update-file failed: " (pr-str (:error upd))))
|
||||
(t/is (uuid? (:media-id uploaded)))
|
||||
[file uploaded]))
|
||||
|
||||
(defn- export-tamper-import-v3
|
||||
"Export `file` to v3, tamper its SVG payload with `evil-text`
|
||||
(declaring `content-type` when given) and import the bundle.
|
||||
Returns `[served-bytes stored-content-type]` of the imported
|
||||
media."
|
||||
[profile file media-id evil-text content-type]
|
||||
(let [bundle (tmp/tempfile :prefix "penpot-export-" :suffix ".zip")]
|
||||
(v3/export-files!
|
||||
(-> th/*system*
|
||||
(assoc ::bfc/ids #{(:id file)})
|
||||
(assoc ::bfc/embed-assets false)
|
||||
(assoc ::bfc/include-libraries false))
|
||||
(io/output-stream bundle))
|
||||
|
||||
(let [evil (tmp/tempfile :prefix "penpot-evil-" :suffix ".zip")
|
||||
_ (tamper-svg-entry! bundle evil media-id (utf8bytes evil-text) :content-type content-type)
|
||||
result (-> th/*system*
|
||||
(assoc ::bfc/project-id (:default-project-id profile))
|
||||
(assoc ::bfc/profile-id (:id profile))
|
||||
(assoc ::bfc/input evil)
|
||||
(v3/import-files!))]
|
||||
(t/is (= 1 (count result)))
|
||||
(let [rows (th/db-query :file-media-object {:file-id (first result)})
|
||||
storage (:app.storage/storage th/*system*)]
|
||||
(t/is (= 1 (count rows)))
|
||||
(let [object (sto/get-object storage (:media-id (first rows)))]
|
||||
[(bytes-str (sto/get-object-bytes storage object))
|
||||
(:content-type (meta object))])))))
|
||||
|
||||
(t/deftest import-binfile-v3-sanitizes-smuggled-svg
|
||||
(let [profile (th/create-profile* 1)
|
||||
[file uploaded] (setup-file-with-svg-media profile 1)
|
||||
[served _stored] (export-tamper-import-v3 profile file (:media-id uploaded) evil-svg nil)]
|
||||
(t/is (not (str/includes? served "<script")))
|
||||
(t/is (not (str/includes? served "PWNMARK_SCRIPT")))))
|
||||
|
||||
(t/deftest import-binfile-v3-sanitizes-obfuscated-content-type
|
||||
(t/testing "non-canonical content-type spellings still sanitize and store canonical"
|
||||
(doseq [[n ctype] [[2 "IMAGE/SVG+XML"]
|
||||
[3 "image/svg+xml; charset=utf-8"]]]
|
||||
(let [profile (th/create-profile* n)
|
||||
[file uploaded] (setup-file-with-svg-media profile n)
|
||||
[served stored] (export-tamper-import-v3 profile file (:media-id uploaded) evil-svg ctype)]
|
||||
(t/is (not (str/includes? served "<script")) (str "script survived for " (pr-str ctype)))
|
||||
(t/is (not (str/includes? served "PWNMARK_SCRIPT")) (str "marker survived for " (pr-str ctype)))
|
||||
(t/is (= "image/svg+xml" stored) (str "stored type not canonical for " (pr-str ctype)))))))
|
||||
|
||||
;; ----------------------------------------------------------------
|
||||
;; v1 import path: exhaustive coverage (F2)
|
||||
;; ----------------------------------------------------------------
|
||||
|
||||
(t/deftest sanitize-imported-svg-from-tempfile-resource
|
||||
(t/testing "bytes read back from a tempfile (v1 large-object shape) sanitize"
|
||||
(let [path (fs/create-tempfile :prefix "penpot-svg-resource-" :suffix ".svg")
|
||||
_ (spit (str path) evil-svg :encoding "UTF-8")
|
||||
raw (with-open [istream (jio/input-stream path)]
|
||||
(io/read istream))
|
||||
result (bfc/sanitize-imported-svg {:content-type "image/svg+xml"} raw)]
|
||||
(t/is (some? result))
|
||||
(t/is (not (str/includes? (bytes-str (:bytes result)) "<script")))
|
||||
(t/is (not (str/includes? (bytes-str (:bytes result)) "PWNMARK_SCRIPT"))))))
|
||||
|
||||
(defn- seed-file-with-svg-media
|
||||
"Seed storage and file_media_object rows directly (bypassing the
|
||||
upload sanitizer) so exports carry `svg-text` verbatim. Attaches
|
||||
the media to a shape fill and returns `[file media-id]`. The
|
||||
storage object declares `content-type`, which may use a
|
||||
non-canonical spelling to prove the predicate."
|
||||
[profile n svg-text content-type]
|
||||
(let [file (th/create-file* n {:profile-id (:id profile)
|
||||
:project-id (:default-project-id profile)
|
||||
:is-shared false})
|
||||
storage (:app.storage/storage th/*system*)
|
||||
raw (utf8bytes svg-text)
|
||||
content (-> (sto/content raw)
|
||||
(sto/wrap-with-hash (sto/calculate-hash (ByteArrayInputStream. raw))))
|
||||
sobj (sto/put-object! storage {::sto/content content
|
||||
:content-type content-type
|
||||
:bucket "file-media-object"})
|
||||
fmo-id (uuid/random)
|
||||
_ (th/db-insert! :file-media-object
|
||||
{:id fmo-id
|
||||
:file-id (:id file)
|
||||
:is-local true
|
||||
:name "seed.svg"
|
||||
:media-id (:id sobj)
|
||||
:width 64
|
||||
:height 64
|
||||
:mtype content-type})
|
||||
page-id (first (get-in file [:data :pages]))
|
||||
shape-id (uuid/random)
|
||||
upd (th/command! {::th/type :update-file
|
||||
::rpc/profile-id (:id profile)
|
||||
:id (:id file)
|
||||
:session-id (uuid/random)
|
||||
:revn 0
|
||||
:vern 0
|
||||
:features cfeat/supported-features
|
||||
:changes
|
||||
[{:type :add-obj
|
||||
:page-id page-id
|
||||
:id shape-id
|
||||
:parent-id uuid/zero
|
||||
:frame-id uuid/zero
|
||||
:components-v2 true
|
||||
:obj (cts/setup-shape
|
||||
{:id shape-id
|
||||
:name "image"
|
||||
:frame-id uuid/zero
|
||||
:parent-id uuid/zero
|
||||
:type :rect
|
||||
:fills [{:fill-opacity 1
|
||||
:fill-image {:id fmo-id
|
||||
:width 64
|
||||
:height 64
|
||||
:mtype "image/svg+xml"}}]})}]})]
|
||||
(t/is (nil? (:error upd)) (str "update-file failed: " (pr-str (:error upd))))
|
||||
[file (:id sobj)]))
|
||||
|
||||
(defn- export-v1
|
||||
"Export `file` to v1 and return the bundle path."
|
||||
[profile file]
|
||||
(let [bundle (tmp/tempfile :prefix "penpot-export-v1-" :suffix ".bin")]
|
||||
(v1/export-files!
|
||||
(-> th/*system*
|
||||
(assoc ::bfc/ids #{(:id file)})
|
||||
(assoc ::bfc/embed-assets false)
|
||||
(assoc ::bfc/include-libraries false))
|
||||
(io/output-stream bundle))
|
||||
bundle))
|
||||
|
||||
(defn- export-import-v1
|
||||
"Export `file` to v1, import it back and return the served bytes
|
||||
of its single media object."
|
||||
[profile file]
|
||||
(let [bundle (export-v1 profile file)]
|
||||
(let [result (-> th/*system*
|
||||
(assoc ::bfc/project-id (:default-project-id profile))
|
||||
(assoc ::bfc/profile-id (:id profile))
|
||||
(assoc ::bfc/input (io/input-stream bundle))
|
||||
(v1/import-files!))]
|
||||
(t/is (= 1 (count result)))
|
||||
(let [rows (th/db-query :file-media-object {:file-id (first result)})
|
||||
storage (:app.storage/storage th/*system*)]
|
||||
(t/is (= 1 (count rows)))
|
||||
(let [object (sto/get-object storage (:media-id (first rows)))]
|
||||
[(bytes-str (sto/get-object-bytes storage object))
|
||||
(:content-type (meta object))])))))
|
||||
|
||||
(t/deftest import-binfile-v1-sanitizes-seeded-svg
|
||||
(let [profile (th/create-profile* 4)
|
||||
[file _seed] (seed-file-with-svg-media profile 4 evil-svg "IMAGE/SVG+XML")
|
||||
[served stored] (export-import-v1 profile file)]
|
||||
(t/is (not (str/includes? served "<script")))
|
||||
(t/is (not (str/includes? served "PWNMARK_SCRIPT")))
|
||||
(t/is (= "image/svg+xml" stored))))
|
||||
|
||||
(t/deftest import-binfile-v1-rejects-unknown-content-type
|
||||
(let [profile (th/create-profile* 6)
|
||||
[file _seed] (seed-file-with-svg-media profile 6 evil-svg "text/html")
|
||||
bundle (export-v1 profile file)
|
||||
out (try
|
||||
(-> th/*system*
|
||||
(assoc ::bfc/project-id (:default-project-id profile))
|
||||
(assoc ::bfc/profile-id (:id profile))
|
||||
(assoc ::bfc/input (io/input-stream bundle))
|
||||
(v1/import-files!))
|
||||
nil
|
||||
(catch clojure.lang.ExceptionInfo e
|
||||
(ex-data e)))]
|
||||
(t/is (= :validation (:type out)))
|
||||
(t/is (= :media-type-not-allowed (:code out)))
|
||||
(t/is (= [(:id file)]
|
||||
(mapv :id (th/db-query :file {:project-id (:default-project-id profile)}))))))
|
||||
|
||||
(def ^:private large-clean-svg
|
||||
(let [pad (apply str (repeat 110000 "<!--0123456789ABCDEF-->"))]
|
||||
(str "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"64\" height=\"64\">" pad "<circle r=\"20\"/></svg>")))
|
||||
|
||||
(t/deftest import-binfile-v1-roundtrips-large-svg
|
||||
(t/testing "objects over the tempfile threshold (tempfile branch) import fine"
|
||||
(t/is (> (alength (utf8bytes large-clean-svg)) bfc/temp-file-threshold))
|
||||
(let [profile (th/create-profile* 5)
|
||||
[file _seed] (seed-file-with-svg-media profile 5 large-clean-svg "image/svg+xml")
|
||||
[served _stored] (export-import-v1 profile file)]
|
||||
(t/is (str/includes? served "circle")))))
|
||||
@ -25,6 +25,23 @@
|
||||
(def tempfile-types
|
||||
(conj image-types "application/pdf" "application/zip"))
|
||||
|
||||
(def storage-object-types
|
||||
"Every content-type the system stores in storage objects: uploaded
|
||||
images and fonts, generated thumbnails, temporary files and the
|
||||
`application/octet-stream` fallback used by file data and font
|
||||
variants. Sorted for determinism. Every member must have a
|
||||
producing code path; anticipated but unproduced types (such as
|
||||
`image/apng`) stay out until some flow actually stores them.
|
||||
Import boundaries normalize the stored string (lowercase, no
|
||||
parameters) before checking membership, so legacy spelling
|
||||
variants keep importing while unknown types are rejected."
|
||||
(into (sorted-set)
|
||||
(concat image-types
|
||||
font-types
|
||||
["application/octet-stream"
|
||||
"application/pdf"
|
||||
"application/zip"])))
|
||||
|
||||
(defn format->extension
|
||||
[format]
|
||||
(case format
|
||||
|
||||
@ -9,6 +9,13 @@
|
||||
[app.common.media :as media]
|
||||
[clojure.test :as t]))
|
||||
|
||||
(t/deftest test-storage-object-types
|
||||
(t/testing "covers every image and font type the system stores"
|
||||
(t/is (every? #(contains? media/storage-object-types %) media/image-types))
|
||||
(t/is (every? #(contains? media/storage-object-types %) media/font-types))
|
||||
(t/is (contains? media/storage-object-types "image/svg+xml"))
|
||||
(t/is (contains? media/storage-object-types "application/octet-stream"))))
|
||||
|
||||
(t/deftest test-parse-font-weight
|
||||
(t/testing "matches weight tokens with proper boundaries"
|
||||
(t/is (= 700 (media/parse-font-weight "Roboto-Bold")))
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user