;; 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 "click") (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)) "")))))) (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 :dir "/tmp/penpot" :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/.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/multipart-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/file-ids #{(:id file)}) (assoc ::bfc/embed-assets false) (assoc ::bfc/include-libraries false) (assoc ::bfc/output (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/team-id (:default-team-id profile)) (assoc ::bfc/profile-id (:id profile)) (assoc ::bfc/team-id (:default-team-id profile)) (assoc ::bfc/input evil) (v3/import-files!))] (t/is (= 1 (count (:file-ids result)))) (let [rows (th/db-query :file-media-object {:file-id (first (:file-ids 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 " (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/file-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!)) file-id (first (:file-ids result))] (t/is (= 1 (count (:file-ids result)))) (let [rows (th/db-query :file-media-object {:file-id file-id}) 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 " 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 ""))] (str "" pad ""))) (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")))))