🐛 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:
Andrey Antukh 2026-10-06 14:21:12 +02:00 committed by GitHub
parent 4b70c9aa21
commit 10504ff2bf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 556 additions and 5 deletions

View File

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

View File

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

View File

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

View File

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

View 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")))))

View File

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

View File

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