mirror of
https://github.com/penpot/penpot.git
synced 2026-08-18 18:58:40 +00:00
The previous object-size check trusted the ZIP entry header's declared size (ZipEntry.getSize()), which a malicious zip-bomb can forge. The check would pass, then the full decompressed payload would be read anyway during hashing and storage persistence. Add size-limiting-stream, a FilterInputStream wrapper that counts actual bytes read and raises :validation :max-file-size-reached when the configured limit is exceeded. Wire it into zip-entry-storage-content so both the hash calculation and storage write paths are bounded by real decompressed bytes, not declared header size. Also wire import limits into management.clj (clone-template) and debug.clj (import-handler + clone path) for defense-in-depth, and add a test that exercises the object-size limit with a real storage object in the exported ZIP. AI-assisted-by: mimo-v2.5-pro
318 lines
11 KiB
Clojure
318 lines
11 KiB
Clojure
;; 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-test
|
|
"Internal binfile test, no RPC involved"
|
|
(:require
|
|
[app.binfile.common :as bfc]
|
|
[app.binfile.v1 :as v1]
|
|
[app.binfile.v3 :as v3]
|
|
[app.common.features :as cfeat]
|
|
[app.common.files.validate :as cfv]
|
|
[app.common.pprint :as pp]
|
|
[app.common.thumbnails :as thc]
|
|
[app.common.types.shape :as cts]
|
|
[app.common.uuid :as uuid]
|
|
[app.db :as db]
|
|
[app.db.sql :as sql]
|
|
[app.http :as http]
|
|
[app.rpc :as-alias rpc]
|
|
[app.storage :as sto]
|
|
[app.storage.tmp :as tmp]
|
|
[backend-tests.helpers :as th]
|
|
[backend-tests.storage-test :as stt]
|
|
[clojure.test :as t]
|
|
[cuerdas.core :as str]
|
|
[datoteka.fs :as fs]
|
|
[datoteka.io :as io])
|
|
(:import
|
|
java.io.ByteArrayInputStream
|
|
java.io.DataInputStream))
|
|
|
|
(t/use-fixtures :once th/state-init)
|
|
(t/use-fixtures :each th/database-reset)
|
|
|
|
(defn- update-file!
|
|
[& {:keys [profile-id file-id changes revn] :or {revn 0}}]
|
|
(let [params {::th/type :update-file
|
|
::rpc/profile-id profile-id
|
|
:id file-id
|
|
:session-id (uuid/random)
|
|
:revn revn
|
|
:vern 0
|
|
:features cfeat/supported-features
|
|
:changes changes}
|
|
out (th/command! params)]
|
|
;; (th/print-result! out)
|
|
(t/is (nil? (:error out)))
|
|
(:result out)))
|
|
|
|
(defn- prepare-simple-file
|
|
[profile]
|
|
(let [page-id-1 (uuid/custom 1 1)
|
|
page-id-2 (uuid/custom 1 2)
|
|
shape-id (uuid/custom 2 1)
|
|
file (th/create-file* 1 {:profile-id (:id profile)
|
|
:project-id (:default-project-id profile)
|
|
:is-shared false})]
|
|
(update-file!
|
|
:file-id (:id file)
|
|
:profile-id (:id profile)
|
|
:revn 0
|
|
:vern 0
|
|
:changes
|
|
[{:type :add-page
|
|
:name "test 1"
|
|
:id page-id-1}
|
|
{:type :add-page
|
|
:name "test 2"
|
|
:id page-id-2}])
|
|
|
|
(update-file!
|
|
:file-id (:id file)
|
|
:profile-id (:id profile)
|
|
:revn 0
|
|
:vern 0
|
|
:changes
|
|
[{:type :add-obj
|
|
:page-id page-id-1
|
|
: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})}])
|
|
|
|
(dissoc file :data)))
|
|
|
|
(def ^:private svg-raw-page-id (uuid/custom 1 1))
|
|
(def ^:private svg-raw-root-id (uuid/custom 3 1))
|
|
(def ^:private svg-raw-child-id (uuid/custom 3 2))
|
|
|
|
(defn- prepare-svg-raw-file
|
|
"A file containing an svg-raw subtree (an svg-raw parent with an
|
|
svg-raw child), which is what importing an SVG produces."
|
|
[profile]
|
|
(let [page-id svg-raw-page-id
|
|
root-id svg-raw-root-id
|
|
child-id svg-raw-child-id
|
|
|
|
file (th/create-file* 1 {:profile-id (:id profile)
|
|
:project-id (:default-project-id profile)
|
|
:is-shared false})]
|
|
(update-file!
|
|
:file-id (:id file)
|
|
:profile-id (:id profile)
|
|
:revn 0
|
|
:vern 0
|
|
:changes
|
|
[{:type :add-page
|
|
:name "page 1"
|
|
:id page-id}])
|
|
|
|
(update-file!
|
|
:file-id (:id file)
|
|
:profile-id (:id profile)
|
|
:revn 0
|
|
:vern 0
|
|
:changes
|
|
[{:type :add-obj
|
|
:page-id page-id
|
|
:id root-id
|
|
:parent-id uuid/zero
|
|
:frame-id uuid/zero
|
|
:components-v2 true
|
|
:obj (cts/setup-shape
|
|
{:id root-id
|
|
:name "svg-root"
|
|
:frame-id uuid/zero
|
|
:parent-id uuid/zero
|
|
:type :svg-raw
|
|
:content {:tag :svg :attrs {} :content []}})}
|
|
{:type :add-obj
|
|
:page-id page-id
|
|
:id child-id
|
|
:parent-id root-id
|
|
:frame-id uuid/zero
|
|
:components-v2 true
|
|
:obj (cts/setup-shape
|
|
{:id child-id
|
|
:name "svg-text"
|
|
:frame-id uuid/zero
|
|
:parent-id root-id
|
|
:type :svg-raw
|
|
:content {:tag :text :attrs {} :content []}})}])
|
|
|
|
(dissoc file :data)))
|
|
|
|
(t/deftest import-binfile-v3-preserves-svg-raw-children
|
|
(let [profile (th/create-profile* 1)
|
|
file (prepare-svg-raw-file profile)
|
|
output (tmp/tempfile :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 output))
|
|
|
|
(let [result (-> th/*system*
|
|
(assoc ::bfc/project-id (:default-project-id profile))
|
|
(assoc ::bfc/profile-id (:id profile))
|
|
(assoc ::bfc/input output)
|
|
(v3/import-files!))
|
|
imported (:result (th/command! {::th/type :get-file
|
|
::rpc/profile-id (:id profile)
|
|
:id (first result)
|
|
:components-v2 true}))
|
|
root (get-in imported [:data :pages-index svg-raw-page-id
|
|
:objects svg-raw-root-id])]
|
|
|
|
(t/is (= (count result) 1))
|
|
|
|
;; The child ids of an svg-raw shape must survive the JSON round
|
|
;; trip as uuids; when they came back as plain strings they no
|
|
;; longer resolved against the objects map.
|
|
(t/is (every? uuid? (:shapes root)))
|
|
(t/is (= [svg-raw-child-id] (vec (:shapes root))))
|
|
|
|
;; ...so the imported file passes referential integrity instead
|
|
;; of failing with :child-not-found on the next update-file.
|
|
(t/is (nil? (cfv/validate-file imported []))))))
|
|
|
|
(t/deftest export-binfile-v3
|
|
(let [profile (th/create-profile* 1)
|
|
file (prepare-simple-file profile)
|
|
output (tmp/tempfile :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 output))
|
|
|
|
(let [result (-> th/*system*
|
|
(assoc ::bfc/project-id (:default-project-id profile))
|
|
(assoc ::bfc/profile-id (:id profile))
|
|
(assoc ::bfc/input output)
|
|
(v3/import-files!))]
|
|
(t/is (= (count result) 1))
|
|
(t/is (every? uuid? result)))))
|
|
|
|
(t/deftest read-obj-rejects-oversized-buffer
|
|
;; N1-07: read-obj! must reject objects exceeding max-object-size
|
|
;; before attempting to allocate the buffer
|
|
(let [size (+ bfc/max-object-size 1)
|
|
baos (java.io.ByteArrayOutputStream. 17)
|
|
dos (java.io.DataOutputStream. baos)]
|
|
(.writeByte dos 5)
|
|
(.writeLong dos (long size))
|
|
(.flush dos)
|
|
(let [input (java.io.DataInputStream.
|
|
(ByteArrayInputStream. (.toByteArray baos)))]
|
|
(binding [v1/*position* (atom 0)]
|
|
(let [out (try
|
|
(v1/read-obj! input)
|
|
nil
|
|
(catch clojure.lang.ExceptionInfo e
|
|
(ex-data e)))]
|
|
;; Without the guard, read-obj! will either OOM or proceed
|
|
;; to read-bytes! on a truncated stream (no :max-file-size-reached).
|
|
;; With the guard, it raises :validation :max-file-size-reached.
|
|
(t/is (= :validation (:type out)))
|
|
(t/is (= :max-file-size-reached (:code out))))))))
|
|
|
|
(t/deftest import-rejects-too-many-zip-entries
|
|
;; import must reject ZIP files exceeding max-zip-entries
|
|
(let [profile (th/create-profile* 1)
|
|
file (prepare-simple-file profile)
|
|
output (tmp/tempfile :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 output))
|
|
|
|
;; Import with max-zip-entries=1 — the exported ZIP has more entries
|
|
(let [cfg (-> th/*system*
|
|
(assoc ::bfc/project-id (:default-project-id profile))
|
|
(assoc ::bfc/profile-id (:id profile))
|
|
(assoc ::bfc/input output)
|
|
(assoc ::bfc/import-max-zip-entries 1))
|
|
out (try
|
|
(v3/import-files! cfg)
|
|
:no-error
|
|
(catch Throwable e
|
|
(let [d (or (ex-data e) (some-> (ex-cause e) ex-data))]
|
|
d)))]
|
|
(t/is (= :validation (:type out)))
|
|
(t/is (= :too-many-zip-entries (:code out))))))
|
|
|
|
(defn- prepare-file-with-media
|
|
"Creates a file with a media object backed by a real storage object,
|
|
so that v3 export produces objects/ entries."
|
|
[profile]
|
|
(let [storage (-> (:app.storage/storage th/*system*)
|
|
(stt/configure-storage-backend))
|
|
|
|
sobject (sto/put-object! storage {::sto/content (sto/content "media-bytes")
|
|
:content-type "image/svg+xml"
|
|
:bucket "file-media-object"})
|
|
|
|
file (th/create-file* 1 {:profile-id (:id profile)
|
|
:project-id (:default-project-id profile)
|
|
:is-shared false})
|
|
|
|
mobj (th/create-file-media-object* {:file-id (:id file)
|
|
:is-local true
|
|
:media-id (:id sobject)})]
|
|
(update-file!
|
|
:file-id (:id file)
|
|
:profile-id (:id profile)
|
|
:revn 0
|
|
:vern 0
|
|
:changes
|
|
[{:type :add-media
|
|
:object mobj}])
|
|
|
|
(dissoc file :data)))
|
|
|
|
(t/deftest import-rejects-oversized-object
|
|
;; import must reject storage objects exceeding max-object-size
|
|
(let [profile (th/create-profile* 1)
|
|
file (prepare-file-with-media profile)
|
|
output (tmp/tempfile :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 output))
|
|
|
|
;; Import with max-object-size=1 — the media object will exceed this
|
|
(let [cfg (-> th/*system*
|
|
(assoc ::bfc/project-id (:default-project-id profile))
|
|
(assoc ::bfc/profile-id (:id profile))
|
|
(assoc ::bfc/input output)
|
|
(assoc ::bfc/import-max-object-size 1))
|
|
out (try
|
|
(v3/import-files! cfg)
|
|
:no-error
|
|
(catch Throwable e
|
|
(let [d (or (ex-data e) (some-> (ex-cause e) ex-data))]
|
|
d)))]
|
|
(t/is (= :validation (:type out)))
|
|
(t/is (= :max-file-size-reached (:code out))))))
|