diff --git a/backend/src/app/binfile/common.clj b/backend/src/app/binfile/common.clj
index e2aed9a837..7fe8e6a532 100644
--- a/backend/src/app/binfile/common.clj
+++ b/backend/src/app/binfile/common.clj
@@ -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))})))
diff --git a/backend/src/app/binfile/v1.clj b/backend/src/app/binfile/v1.clj
index be65b423c9..fd340a487d 100644
--- a/backend/src/app/binfile/v1.clj
+++ b/backend/src/app/binfile/v1.clj
@@ -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))
diff --git a/backend/src/app/binfile/v2.clj b/backend/src/app/binfile/v2.clj
index 347074586b..2e0d04107a 100644
--- a/backend/src/app/binfile/v2.clj
+++ b/backend/src/app/binfile/v2.clj
@@ -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))
diff --git a/backend/src/app/binfile/v3.clj b/backend/src/app/binfile/v3.clj
index cc488f6ab8..cdc3dd66bb 100644
--- a/backend/src/app/binfile/v3.clj
+++ b/backend/src/app/binfile/v3.clj
@@ -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)
diff --git a/backend/test/backend_tests/binfile_svg_test.clj b/backend/test/backend_tests/binfile_svg_test.clj
new file mode 100644
index 0000000000..8b63de4ab8
--- /dev/null
+++ b/backend/test/backend_tests/binfile_svg_test.clj
@@ -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
+ "")
+
+(def ^:private evil-xhref-svg
+ "")
+
+(def ^:private clean-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)) "