mirror of
https://github.com/penpot/penpot.git
synced 2026-11-10 20:10:37 +00:00
✨ Strictly validate uploads against file bytes on disk (#12801)
* ✨ Strictly validate uploads against file bytes on disk Sniff the real content type of every upload with simplemagic magic bytes instead of trusting the client-supplied mtype header. Declared types outside the allowlist keep the not-allowed code; allowed declarations whose bytes differ are rejected as mismatch. Covers SVG by probing its root tag over leading bytes (magic never matches XML text), TTF/OTF by magic name and WOFF/WOFF2 by four-cc. Read the real file size from disk instead of trusting the declared :size. Files over the limit are rejected even when the declared size lies downwards, and small files pass even when it lies upwards. Run the path check first everywhere so forged server paths keep the params-validation code. Recheck from-url downloads after the write, with a declared-only fast fail at header time. Thread the assembled path through font validation. Refresh the affected tests around real and sparse fixture files, including a PNG-as-font chunked-upload rejection case. Closes #12796 AI-assisted-by: muse-spark-1.3-contributor * 🐛 Accept BOM-prefixed SVG uploads and cover WOFF/WOFF2/OTF/WEBP Address review comments on #12801: - SVG files saved with a UTF-8 BOM were rejected as :media-type-mismatch: read-head kept U+FEFF and the <svg probe never ran. A leading BOM is now stripped before the probe, and a generic XML sniff (application/xml, text/xml) is treated as "no magic evidence" for declared image/svg+xml, so the root-tag probe decides whether the file opens with an <svg root. - sniff-match?/svg-file? docstrings claimed the magic database never matches SVG; simplemagic 1.17 does match an SVG with an XML prolog as image/svg+xml. Reworded to match reality. - Added matching-pass tests for WOFF, WOFF2, OTF and WEBP (they hit the read-four-cc, magic-name and direct magic branches) and three BOM SVG regression cases. AI-assisted-by: inkling-2-small-free
This commit is contained in:
parent
7f0b62b994
commit
c39e3e8301
@ -54,6 +54,8 @@
|
||||
|
||||
org.jsoup/jsoup {:mvn/version "1.23.2"}
|
||||
|
||||
com.j256.simplemagic/simplemagic {:mvn/version "1.17"}
|
||||
|
||||
at.yawk.lz4/lz4-java
|
||||
{:mvn/version "1.12.0"}
|
||||
|
||||
|
||||
@ -19,6 +19,7 @@
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.media :as cm]
|
||||
[app.config :as cf]
|
||||
[app.db :as-alias db]
|
||||
[app.http.client :as http]
|
||||
@ -58,9 +59,22 @@
|
||||
:code :unknown-size
|
||||
:hint "seems like the url points to resource with unknown size"))
|
||||
|
||||
(-> {:size size :mtype mtype}
|
||||
(validation/check-media-type)
|
||||
(validation/check-media-size))))]
|
||||
;; Fast fail on the declared header type and size; the
|
||||
;; downloaded bytes are strictly verified after the write
|
||||
;; (there is no file on disk yet to measure or sniff).
|
||||
(when-not (contains? cm/image-types mtype)
|
||||
(ex/raise :type :validation
|
||||
:code :media-type-not-allowed
|
||||
:hint "seems like you are uploading an invalid media object"))
|
||||
|
||||
(when (> size (cf/get :media-max-file-size))
|
||||
(ex/raise :type :restriction
|
||||
:code :media-max-file-size-reached
|
||||
:hint (str/ffmt "the uploaded file size % is greater than the maximum %"
|
||||
size
|
||||
(cf/get :media-max-file-size))))
|
||||
|
||||
{:size size :mtype mtype}))]
|
||||
|
||||
(let [{:keys [body] :as response}
|
||||
(try
|
||||
@ -102,7 +116,11 @@
|
||||
:hint "unexpected state: unable to write to file"))
|
||||
|
||||
;; Sanitize: strip trailing data after image EOF markers
|
||||
(let [new-size (sanitize/truncate-after-eof path mtype)]
|
||||
(let [new-size (sanitize/truncate-after-eof path mtype)
|
||||
upload {:path path :mtype mtype :size new-size}]
|
||||
;; Strict: the downloaded bytes must be what the remote
|
||||
;; header claimed, same rule as direct uploads.
|
||||
(validation/check-media-type upload)
|
||||
{:path path
|
||||
:mtype mtype
|
||||
:size new-size})))
|
||||
|
||||
@ -13,7 +13,11 @@
|
||||
[app.common.schema :as sm]
|
||||
[app.config :as cf]
|
||||
[cuerdas.core :as str]
|
||||
[datoteka.fs :as fs]))
|
||||
[datoteka.fs :as fs])
|
||||
(:import
|
||||
com.j256.simplemagic.ContentInfo
|
||||
com.j256.simplemagic.ContentInfoUtil
|
||||
java.nio.file.Path))
|
||||
|
||||
(def schema:upload
|
||||
[:map {:title "Upload"}
|
||||
@ -50,37 +54,271 @@
|
||||
:hint "invalid upload path"))
|
||||
upload))
|
||||
|
||||
(def ^:private ^ContentInfoUtil magic-util
|
||||
"Shared magic-byte detector (thread-safe for matching, read-only
|
||||
after construction). Built once: parsing the embedded magic database
|
||||
on every upload would be wasteful."
|
||||
(ContentInfoUtil.))
|
||||
|
||||
(defn- normalize-mtype
|
||||
"Lowercase a mime string and drop any `;` parameters. Returns nil
|
||||
for missing or blank values."
|
||||
[mtype]
|
||||
(when (string? mtype)
|
||||
(let [clean (-> (str/split mtype #";" 2)
|
||||
(first)
|
||||
(str/trim)
|
||||
(str/lower))]
|
||||
(when-not (str/empty? clean)
|
||||
clean))))
|
||||
|
||||
(def ^:private simplemagic-aliases
|
||||
"Spellings reported by `ContentInfoUtil` that differ from the Penpot
|
||||
vocabulary in `app.common.media`. Single place to extend when the
|
||||
magic database gains new spellings."
|
||||
{"image/x-png" "image/png"
|
||||
"image/x-jpeg" "image/jpeg"
|
||||
"image/jpg" "image/jpeg"
|
||||
"application/x-font-ttf" "font/ttf"
|
||||
"application/x-font-otf" "font/otf"
|
||||
"application/font-sfnt" "font/ttf"
|
||||
"application/x-font-woff" "font/woff"
|
||||
"application/font-woff" "font/woff"
|
||||
"application/x-font-woff2" "font/woff2"
|
||||
"application/font-woff2" "font/woff2"})
|
||||
|
||||
(def ^:private magic-name->mtype
|
||||
"The magic database knows TrueType and OpenType by name but reports
|
||||
an empty mime for them. Map those names to the Penpot vocabulary."
|
||||
{"TrueType" "font/ttf"
|
||||
"OpenType" "font/otf"})
|
||||
|
||||
(defn- read-four-cc
|
||||
"Reads the FourCC signature of the file at `path` — its first four
|
||||
bytes as an ASCII string. Used only for the font containers the
|
||||
magic database does not know (WOFF/WOFF2). Returns nil on any read
|
||||
failure."
|
||||
[^Path path]
|
||||
(try
|
||||
(with-open [in (java.io.FileInputStream. (str path))]
|
||||
(let [buf (byte-array 4)
|
||||
n (.read in buf)]
|
||||
(when (= n 4)
|
||||
(String. ^bytes buf "US-ASCII"))))
|
||||
(catch Exception _ nil)))
|
||||
|
||||
(def ^:private four-cc->mtype
|
||||
{"wOFF" "font/woff"
|
||||
"wOF2" "font/woff2"})
|
||||
|
||||
(def ^:private svg-probe-bytes
|
||||
"How many leading bytes the SVG root probe reads. The root element
|
||||
of a real document sits at the top; 64KB leaves room for a long
|
||||
prolog or comments while keeping memory constant."
|
||||
65536)
|
||||
|
||||
(defn- starts-at?
|
||||
"True when `prefix` occurs in `s` exactly at `i`."
|
||||
[^String s ^long i ^String prefix]
|
||||
(let [plen (.length prefix)]
|
||||
(and (<= (+ i plen) (.length s))
|
||||
(.regionMatches s (int i) prefix (int 0) (int plen)))))
|
||||
|
||||
(defn- skip-ws
|
||||
"Index of the first non-whitespace char in `s` at or after `i`."
|
||||
[^String s ^long i]
|
||||
(let [n (.length s)]
|
||||
(loop [i i]
|
||||
(if (and (< i n) (Character/isWhitespace (.charAt s i)))
|
||||
(recur (inc i))
|
||||
i))))
|
||||
|
||||
(defn- index-of-from
|
||||
"Index of the first occurrence of `end` in `s` at or after `i`, or
|
||||
nil when absent."
|
||||
[^String s ^long i ^String end]
|
||||
(loop [i i]
|
||||
(cond
|
||||
(> i (- (.length s) (.length end))) nil
|
||||
(starts-at? s i end) i
|
||||
:else (recur (inc i)))))
|
||||
|
||||
(defn- skip-markup-decl
|
||||
"Index just past a `<!...>` declaration opening at `i` (which points
|
||||
at `<`), honouring `[...]` nesting so a doctype internal subset
|
||||
cannot fool it. Returns nil when unterminated."
|
||||
[^String s ^long i]
|
||||
(let [n (.length s)]
|
||||
(loop [i (+ i 2) depth 0]
|
||||
(when (< i n)
|
||||
(let [c (.charAt s i)]
|
||||
(cond
|
||||
(= c \[) (recur (inc i) (inc depth))
|
||||
(= c \]) (recur (inc i) (dec depth))
|
||||
(and (= c \>) (zero? depth)) (inc i)
|
||||
:else (recur (inc i) depth)))))))
|
||||
|
||||
(defn- svg-root-at?
|
||||
"True when the first markup in `s` is a lowercase `<svg` root
|
||||
element, skipping the XML prolog, comments and doctype. Anything
|
||||
else — including a truncated head — is false; full validity stays
|
||||
downstream with the SVG parser."
|
||||
[^String s]
|
||||
(loop [i (skip-ws s 0)]
|
||||
(when (< i (.length s))
|
||||
(cond
|
||||
(starts-at? s i "<?")
|
||||
(when-some [j (index-of-from s (+ i 2) "?>")]
|
||||
(recur (skip-ws s (+ j 2))))
|
||||
|
||||
(starts-at? s i "<!--")
|
||||
(when-some [j (index-of-from s (+ i 4) "-->")]
|
||||
(recur (skip-ws s (+ j 3))))
|
||||
|
||||
(starts-at? s i "<!")
|
||||
(when-some [j (skip-markup-decl s i)]
|
||||
(recur (skip-ws s j)))
|
||||
|
||||
(starts-at? s i "<svg")
|
||||
(let [j (+ i 4)]
|
||||
(boolean
|
||||
(when (< j (.length s))
|
||||
(contains? #{\space \tab \newline \return \/ \>} (.charAt s j)))))
|
||||
|
||||
:else false))))
|
||||
|
||||
(defn- read-head
|
||||
"Read at most `svg-probe-bytes` leading bytes of the file at `path`,
|
||||
decoded as UTF-8 like the rest of the pipeline. Malformed sequences
|
||||
become replacement chars and can never match a tag. A leading U+FEFF
|
||||
(UTF-8 BOM) is stripped: it is an encoding artifact, not content.
|
||||
Returns nil on any read failure."
|
||||
[^Path path]
|
||||
(try
|
||||
(with-open [in (java.io.FileInputStream. (str path))]
|
||||
(let [buf (byte-array svg-probe-bytes)
|
||||
n (.read in buf)]
|
||||
(when (pos? n)
|
||||
(let [s (String. ^bytes buf (int 0) (int n) "UTF-8")]
|
||||
(if (and (pos? (.length s)) (= \uFEFF (.charAt s 0)))
|
||||
(subs s 1)
|
||||
s)))))
|
||||
(catch Exception _ nil)))
|
||||
|
||||
(defn- svg-file?
|
||||
"True when the file at `path` opens with an `<svg` root element.
|
||||
This is the arbiter for SVG when magic returns nothing or a generic
|
||||
XML type. Reads at most `svg-probe-bytes` leading bytes, so memory
|
||||
stays constant no matter the file size. Full validity stays
|
||||
downstream (`parse-svg` + sanitize): this only tells SVG-looking
|
||||
text apart from other magic-less files."
|
||||
[^Path path]
|
||||
(boolean
|
||||
(when (and (some? path) (fs/exists? path))
|
||||
(some-> (read-head path) (svg-root-at?)))))
|
||||
|
||||
(defn detect-mtype
|
||||
"Sniff the real mime type of the file at `path` using magic bytes.
|
||||
Returns a normalized Penpot mime string, or nil when the file cannot
|
||||
be read or nothing matches."
|
||||
[^Path path]
|
||||
(try
|
||||
(when (and (some? path) (fs/exists? path))
|
||||
(let [^ContentInfo info (.findMatch magic-util (str path))]
|
||||
(if (some? info)
|
||||
(let [raw (normalize-mtype (.getMimeType info))]
|
||||
(if (some? raw)
|
||||
(get simplemagic-aliases raw raw)
|
||||
(get magic-name->mtype (.getName info))))
|
||||
(get four-cc->mtype (read-four-cc path)))))
|
||||
(catch Exception _ nil)))
|
||||
|
||||
(defn- svg-declared?
|
||||
[declared]
|
||||
(= declared "image/svg+xml"))
|
||||
|
||||
(defn- sniff-match?
|
||||
"Strict comparison of the declared client mime against the sniffed
|
||||
file content. SVG is the only exception: magic may return
|
||||
`image/svg+xml` (an SVG with an XML prolog) or a generic XML type;
|
||||
when sniffed is nil or a generic XML type, a declared `image/svg+xml`
|
||||
is accepted only when the root-tag probe confirms an `<svg` root.
|
||||
Everything else must be exactly equal — including
|
||||
`application/octet-stream`, which never matches."
|
||||
[declared sniffed path]
|
||||
(and (some? declared)
|
||||
(or (= declared sniffed)
|
||||
(and (svg-declared? declared)
|
||||
(or (nil? sniffed)
|
||||
(contains? #{"application/xml" "text/xml"} sniffed))
|
||||
(svg-file? path)))))
|
||||
|
||||
(defn check-media-type
|
||||
([upload] (check-media-type upload cm/image-types))
|
||||
([upload allowed]
|
||||
(when-not (contains? allowed (:mtype upload))
|
||||
(ex/raise :type :validation
|
||||
:code :media-type-not-allowed
|
||||
:hint "Seems like you are uploading an invalid media object"))
|
||||
(let [declared (normalize-mtype (:mtype upload))
|
||||
path (:path upload)
|
||||
sniffed (detect-mtype path)]
|
||||
;; Declared type outside the allowlist is rejected on its face,
|
||||
;; with the established code.
|
||||
(when-not (contains? allowed declared)
|
||||
(ex/raise :type :validation
|
||||
:code :media-type-not-allowed
|
||||
:hint "seems like you are uploading an invalid media object"))
|
||||
;; Declared type allowed but file bytes differ: the client lied.
|
||||
(when-not (sniff-match? declared sniffed path)
|
||||
(ex/raise :type :validation
|
||||
:code :media-type-mismatch
|
||||
:hint (str/ffmt "the uploaded file content (%) does not match the declared type (%)"
|
||||
(or sniffed "unknown")
|
||||
(or (:mtype upload) "unknown")))))
|
||||
|
||||
upload))
|
||||
|
||||
(defn- real-size
|
||||
"Bytes on disk for the file at `path`. Returns nil when the file
|
||||
cannot be read — callers run `check-upload-path` first, so a nil
|
||||
here fails closed with the path code instead of a limit code."
|
||||
[path]
|
||||
(try
|
||||
(when (some? path)
|
||||
(fs/size path))
|
||||
(catch Exception _ nil)))
|
||||
|
||||
(defn check-media-size
|
||||
"Rejects when the file bytes on disk exceed the configured
|
||||
`:media-max-file-size` limit. The declared `:size` is ignored —
|
||||
only the file at `:path` counts."
|
||||
[upload]
|
||||
(let [max-size (cf/get :media-max-file-size)]
|
||||
(when (> (:size upload) max-size)
|
||||
(let [max-size (cf/get :media-max-file-size)
|
||||
disk-size (real-size (:path upload))]
|
||||
(when (nil? disk-size)
|
||||
(ex/raise :type :validation
|
||||
:code :params-validation
|
||||
:hint "invalid upload path"))
|
||||
(when (> disk-size max-size)
|
||||
(ex/raise :type :restriction
|
||||
:code :media-max-file-size-reached
|
||||
:hint (str/ffmt "the uploaded file size % is greater than the maximum %"
|
||||
(:size upload)
|
||||
disk-size
|
||||
max-size)))
|
||||
upload))
|
||||
|
||||
(defn check-font-size
|
||||
"Validates that the font file `upload` does not exceed the configured
|
||||
`:font-max-file-size` limit. Accepts the same map shape as
|
||||
`check-media-size` — requires a `:size` key in bytes."
|
||||
"Rejects when the font file bytes on disk exceed the configured
|
||||
`:font-max-file-size` limit. The declared `:size` is ignored —
|
||||
only the file at `:path` counts."
|
||||
[upload]
|
||||
(let [max-size (cf/get :font-max-file-size)]
|
||||
(when (> (:size upload) max-size)
|
||||
(let [max-size (cf/get :font-max-file-size)
|
||||
disk-size (real-size (:path upload))]
|
||||
(when (nil? disk-size)
|
||||
(ex/raise :type :validation
|
||||
:code :params-validation
|
||||
:hint "invalid upload path"))
|
||||
(when (> disk-size max-size)
|
||||
(ex/raise :type :restriction
|
||||
:code :font-max-file-size-reached
|
||||
:hint (str/ffmt "the uploaded font size % is greater than the maximum %"
|
||||
(:size upload)
|
||||
disk-size
|
||||
max-size)))
|
||||
upload))
|
||||
|
||||
@ -302,9 +302,9 @@
|
||||
::rpc/multipart-only-params [:media]}
|
||||
|
||||
[cfg {:keys [::rpc/profile-id file-id object-id media tag]}]
|
||||
(media.v/check-upload-path media)
|
||||
(media.v/check-media-type media)
|
||||
(media.v/check-media-size media)
|
||||
(media.v/check-upload-path media)
|
||||
|
||||
(db/run! cfg files/check-edition-permissions! profile-id file-id)
|
||||
(when-let [file (files/get-minimal-file cfg file-id {::db/check-deleted false})]
|
||||
@ -405,9 +405,9 @@
|
||||
::rpc/multipart-only-params [:media]}
|
||||
|
||||
[cfg {:keys [::rpc/profile-id file-id] :as params}]
|
||||
(media.v/check-upload-path (:media params))
|
||||
(media.v/check-media-type (:media params))
|
||||
(media.v/check-media-size (:media params))
|
||||
(media.v/check-upload-path (:media params))
|
||||
|
||||
(db/run! cfg files/check-edition-permissions! profile-id file-id)
|
||||
|
||||
|
||||
@ -122,7 +122,7 @@
|
||||
(let [data (reduce-kv
|
||||
(fn [acc mtype session-id]
|
||||
(let [assembled (assemble-chunks cfg profile-id session-id)]
|
||||
(-> {:mtype mtype :size (:size assembled)}
|
||||
(-> {:mtype mtype :size (:size assembled) :path (:path assembled)}
|
||||
(media.v/check-media-type cm/font-types)
|
||||
(media.v/check-font-size))
|
||||
(assoc acc mtype (:path assembled))))
|
||||
|
||||
@ -65,9 +65,9 @@
|
||||
[:process-image/global]]}
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id content] :as params}]
|
||||
(files/check-edition-permissions! pool profile-id file-id)
|
||||
(media.v/check-upload-path content)
|
||||
(media.v/check-media-type content)
|
||||
(media.v/check-media-size content)
|
||||
(media.v/check-upload-path content)
|
||||
|
||||
(let [team-id (:team-id (db/exec-one! pool [sql:get-team-id-for-file file-id]))]
|
||||
;; TEMPORARILY DISABLED (2026-10-19): media-storage-bytes-per-team
|
||||
|
||||
@ -313,9 +313,9 @@
|
||||
::sm/result :nil}
|
||||
[cfg {:keys [::rpc/profile-id file] :as params}]
|
||||
;; Validate incoming mime type
|
||||
(media.v/check-upload-path file)
|
||||
(media.v/check-media-type file #{"image/jpeg" "image/png" "image/webp"})
|
||||
(media.v/check-media-size file)
|
||||
(media.v/check-upload-path file)
|
||||
(update-profile-photo cfg (assoc params :profile-id profile-id)))
|
||||
|
||||
(defn update-profile-photo
|
||||
|
||||
@ -1102,9 +1102,9 @@
|
||||
[cfg {:keys [::rpc/profile-id file] :as params}]
|
||||
;; Validate incoming mime type
|
||||
|
||||
(media.v/check-upload-path file)
|
||||
(media.v/check-media-type file #{"image/jpeg" "image/png" "image/webp"})
|
||||
(media.v/check-media-size file)
|
||||
(media.v/check-upload-path file)
|
||||
(update-team-photo cfg (assoc params :profile-id profile-id)))
|
||||
|
||||
(defn update-team-photo
|
||||
|
||||
@ -39,8 +39,8 @@
|
||||
::sm/result schema:upload-tempfile-result
|
||||
::rpc/allowed-shared-keys allowed-shared-key-ids}
|
||||
[cfg {:keys [::rpc/profile-id content]}]
|
||||
(media.v/check-media-type content cm/tempfile-types)
|
||||
(media.v/check-upload-path content)
|
||||
(media.v/check-media-type content cm/tempfile-types)
|
||||
(let [storage (sto/resolve cfg)
|
||||
hash (sto/calculate-hash (:path content))
|
||||
data (-> (sto/content (:path content))
|
||||
|
||||
@ -209,8 +209,8 @@
|
||||
::sm/result schema:upload-organization-logo-result
|
||||
::nitrate/sso false}
|
||||
[{:keys [::sto/storage]} {:keys [content organization-id previous-id]}]
|
||||
(media.v/check-media-type content cm/image-types)
|
||||
(media.v/check-upload-path content)
|
||||
(media.v/check-media-type content cm/image-types)
|
||||
(when previous-id
|
||||
(sto/touch-object! storage previous-id))
|
||||
(let [hash (sto/calculate-hash (:path content))
|
||||
|
||||
@ -6,41 +6,173 @@
|
||||
|
||||
(ns backend-tests.media-validation-test
|
||||
(:require
|
||||
[app.common.media :as cm]
|
||||
[app.media.validation :as media.v]
|
||||
[backend-tests.helpers :as th]
|
||||
[clojure.test :as t]
|
||||
[datoteka.fs :as fs]))
|
||||
[datoteka.fs :as fs]
|
||||
[datoteka.io :as io]))
|
||||
|
||||
(t/deftest check-media-type
|
||||
(t/testing "allowed mtype passes through"
|
||||
(let [upload {:filename "a.png" :size 1 :path (fs/path "/tmp/penpot/a.png") :mtype "image/png"}]
|
||||
(t/testing "matching png passes through"
|
||||
(let [path (th/tempfile "backend_tests/test_files/sample.png")
|
||||
upload {:filename "sample.png" :size 1234 :path path :mtype "image/png"}]
|
||||
(t/is (= upload (media.v/check-media-type upload)))))
|
||||
(t/testing "disallowed mtype is rejected"
|
||||
(let [upload {:filename "a.txt" :size 1 :path (fs/path "/tmp/penpot/a.txt") :mtype "text/plain"}
|
||||
(t/testing "matching jpeg passes through"
|
||||
(let [path (th/tempfile "backend_tests/test_files/sample.jpg")
|
||||
upload {:filename "sample.jpg" :size 7923 :path path :mtype "image/jpeg"}]
|
||||
(t/is (= upload (media.v/check-media-type upload)))))
|
||||
(t/testing "svg with prolog passes through"
|
||||
(let [path (th/tempfile "backend_tests/test_files/sample2.svg")
|
||||
upload {:filename "sample.svg" :size 1234 :path path :mtype "image/svg+xml"}]
|
||||
(t/is (= upload (media.v/check-media-type upload)))))
|
||||
(t/testing "svg with comment and doctype passes through"
|
||||
(let [path (fs/create-tempfile :dir "/tmp/penpot" :prefix "penpot-test-svg-")
|
||||
_ (spit (str path) (str "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
|
||||
"<!-- a comment -->\n"
|
||||
"<!DOCTYPE svg PUBLIC \"-//W3C//DTD SVG 1.1//EN\" "
|
||||
"\"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd\">\n"
|
||||
(slurp (io/resource "backend_tests/test_files/sample1.svg"))))
|
||||
upload {:filename "sample.svg" :size 1234 :path path :mtype "image/svg+xml"}]
|
||||
(t/is (= upload (media.v/check-media-type upload)))))
|
||||
(t/testing "svg with utf-8 comment passes through"
|
||||
(let [path (fs/create-tempfile :dir "/tmp/penpot" :prefix "penpot-test-usvg-")
|
||||
_ (spit (str path) (str "<!-- café con ñ y emojis \uD83D\uDE00 -->\n"
|
||||
(slurp (io/resource "backend_tests/test_files/sample1.svg"))))
|
||||
upload {:filename "sample.svg" :size 1234 :path path :mtype "image/svg+xml"}]
|
||||
(t/is (= upload (media.v/check-media-type upload)))))
|
||||
(t/testing "matching bom svg with prolog passes through"
|
||||
(let [path (fs/create-tempfile :dir "/tmp/penpot" :prefix "penpot-test-bom-svg-")
|
||||
_ (spit (str path) (str "\uFEFF<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\"><rect width=\"10\" height=\"10\"/></svg>"))
|
||||
upload {:filename "bom.svg" :size 1234 :path path :mtype "image/svg+xml"}]
|
||||
(t/is (= upload (media.v/check-media-type upload)))))
|
||||
(t/testing "matching bom svg without prolog passes through"
|
||||
(let [path (fs/create-tempfile :dir "/tmp/penpot" :prefix "penpot-test-bom-svg2-")
|
||||
_ (spit (str path) (str "\uFEFF<svg xmlns=\"http://www.w3.org/2000/svg\"><rect width=\"10\" height=\"10\"/></svg>"))
|
||||
upload {:filename "bom2.svg" :size 1234 :path path :mtype "image/svg+xml"}]
|
||||
(t/is (= upload (media.v/check-media-type upload)))))
|
||||
(t/testing "bom plain text declared as svg is rejected"
|
||||
(let [path (fs/create-tempfile :dir "/tmp/penpot" :prefix "penpot-test-bom-txt-")
|
||||
_ (spit (str path) "\uFEFFjust some text, no markup at all")
|
||||
upload {:filename "evil.svg" :size 1234 :path path :mtype "image/svg+xml"}
|
||||
error (try (media.v/check-media-type upload) nil
|
||||
(catch clojure.lang.ExceptionInfo e e))]
|
||||
(t/is (some? error))
|
||||
(t/is (th/ex-of-type? error :validation))
|
||||
(t/is (th/ex-of-code? error :media-type-mismatch))))
|
||||
(t/testing "plain text declared as svg is rejected"
|
||||
(let [path (fs/create-tempfile :dir "/tmp/penpot" :prefix "penpot-test-txt-")
|
||||
_ (spit (str path) "just some text, no markup at all")
|
||||
upload {:filename "evil.svg" :size 1234 :path path :mtype "image/svg+xml"}
|
||||
error (try (media.v/check-media-type upload) nil
|
||||
(catch clojure.lang.ExceptionInfo e e))]
|
||||
(t/is (some? error))
|
||||
(t/is (th/ex-of-type? error :validation))
|
||||
(t/is (th/ex-of-code? error :media-type-mismatch))))
|
||||
(t/testing "uppercase svg root is rejected"
|
||||
(let [path (fs/create-tempfile :dir "/tmp/penpot" :prefix "penpot-test-usvg-")
|
||||
_ (spit (str path) "<SVG xmlns=\"http://www.w3.org/2000/svg\"></SVG>")
|
||||
upload {:filename "upper.svg" :size 1234 :path path :mtype "image/svg+xml"}
|
||||
error (try (media.v/check-media-type upload) nil
|
||||
(catch clojure.lang.ExceptionInfo e e))]
|
||||
(t/is (some? error))
|
||||
(t/is (th/ex-of-type? error :validation))
|
||||
(t/is (th/ex-of-code? error :media-type-mismatch))))
|
||||
(t/testing "matching font passes through"
|
||||
(let [path (th/tempfile "backend_tests/test_files/font-1.ttf")
|
||||
upload {:filename "font.ttf" :size 1234 :path path :mtype "font/ttf"}]
|
||||
(t/is (= upload (media.v/check-media-type upload cm/font-types)))))
|
||||
(t/testing "matching woff passes through"
|
||||
(let [path (th/tempfile "backend_tests/test_files/font-1.woff")
|
||||
upload {:filename "font.woff" :size 1234 :path path :mtype "font/woff"}]
|
||||
(t/is (= upload (media.v/check-media-type upload cm/font-types)))))
|
||||
(t/testing "matching woff2 passes through"
|
||||
(let [path (th/tempfile "backend_tests/test_files/font-1.woff2")
|
||||
upload {:filename "font.woff2" :size 1234 :path path :mtype "font/woff2"}]
|
||||
(t/is (= upload (media.v/check-media-type upload cm/font-types)))))
|
||||
(t/testing "matching otf passes through"
|
||||
(let [path (th/tempfile "backend_tests/test_files/font-1.otf")
|
||||
upload {:filename "font.otf" :size 1234 :path path :mtype "font/otf"}]
|
||||
(t/is (= upload (media.v/check-media-type upload cm/font-types)))))
|
||||
(t/testing "matching webp passes through"
|
||||
(let [path (th/tempfile "backend_tests/test_files/sample.webp")
|
||||
upload {:filename "sample.webp" :size 1234 :path path :mtype "image/webp"}]
|
||||
(t/is (= upload (media.v/check-media-type upload)))))
|
||||
(t/testing "png bytes declared as jpeg are rejected"
|
||||
(let [path (th/tempfile "backend_tests/test_files/sample.png")
|
||||
upload {:filename "sample.png" :size 1234 :path path :mtype "image/jpeg"}
|
||||
error (try (media.v/check-media-type upload) nil
|
||||
(catch clojure.lang.ExceptionInfo e e))]
|
||||
(t/is (some? error))
|
||||
(t/is (th/ex-of-type? error :validation))
|
||||
(t/is (th/ex-of-code? error :media-type-mismatch))))
|
||||
(t/testing "octet-stream declared for png is rejected"
|
||||
(let [path (th/tempfile "backend_tests/test_files/sample.png")
|
||||
upload {:filename "sample.png" :size 1234 :path path :mtype "application/octet-stream"}
|
||||
error (try (media.v/check-media-type upload) nil
|
||||
(catch clojure.lang.ExceptionInfo e e))]
|
||||
(t/is (some? error))
|
||||
(t/is (th/ex-of-type? error :validation))
|
||||
(t/is (th/ex-of-code? error :media-type-not-allowed))))
|
||||
(t/testing "png bytes declared as svg are rejected"
|
||||
(let [path (th/tempfile "backend_tests/test_files/sample.png")
|
||||
upload {:filename "sample.png" :size 1234 :path path :mtype "image/svg+xml"}
|
||||
error (try (media.v/check-media-type upload) nil
|
||||
(catch clojure.lang.ExceptionInfo e e))]
|
||||
(t/is (some? error))
|
||||
(t/is (th/ex-of-type? error :validation))
|
||||
(t/is (th/ex-of-code? error :media-type-mismatch))))
|
||||
(t/testing "disallowed real type is rejected"
|
||||
(let [path (th/tempfile "backend_tests/test_files/file-with-library.penpot")
|
||||
upload {:filename "bundle.penpot" :size 1234 :path path :mtype "application/zip"}
|
||||
error (try (media.v/check-media-type upload) nil
|
||||
(catch clojure.lang.ExceptionInfo e e))]
|
||||
(t/is (some? error))
|
||||
(t/is (th/ex-of-type? error :validation))
|
||||
(t/is (th/ex-of-code? error :media-type-not-allowed)))))
|
||||
|
||||
(defn- sparse-file!
|
||||
"Grow the file at `path` to `size` bytes without writing them. The
|
||||
file stays sparse so no disk is used, while `fs/size` still reports
|
||||
the full length."
|
||||
[path size]
|
||||
(with-open [raf (java.io.RandomAccessFile. (str path) "rw")]
|
||||
(.setLength raf size))
|
||||
path)
|
||||
|
||||
(t/deftest check-media-size
|
||||
(t/testing "small file passes through"
|
||||
(let [upload {:filename "a.png" :size 1 :path (fs/path "/tmp/penpot/a.png") :mtype "image/png"}]
|
||||
(t/testing "small file passes even when the declared size lies upwards"
|
||||
(let [path (th/tempfile "backend_tests/test_files/sample.jpg")
|
||||
upload {:filename "sample.jpg" :size (* 1024 1024 31) :path path :mtype "image/jpeg"}]
|
||||
(t/is (= upload (media.v/check-media-size upload)))))
|
||||
(t/testing "oversized file is rejected"
|
||||
(let [upload {:filename "a.png" :size (* 1024 1024 31) :path (fs/path "/tmp/penpot/a.png") :mtype "image/png"}
|
||||
(t/testing "file over the limit is rejected even when declared size lies downwards"
|
||||
(let [path (sparse-file! (fs/create-tempfile :dir "/tmp/penpot" :prefix "penpot-test-big-")
|
||||
(* 1024 1024 31))
|
||||
upload {:filename "big.jpg" :size 1 :path path :mtype "image/jpeg"}
|
||||
error (try (media.v/check-media-size upload) nil
|
||||
(catch clojure.lang.ExceptionInfo e e))]
|
||||
(t/is (some? error))
|
||||
(t/is (th/ex-of-type? error :restriction))
|
||||
(t/is (th/ex-of-code? error :media-max-file-size-reached)))))
|
||||
(t/is (th/ex-of-code? error :media-max-file-size-reached))))
|
||||
(t/testing "missing file fails closed with the path code"
|
||||
(let [upload {:filename "gone.jpg" :size 1 :path (fs/path "/tmp/penpot/no-such-file-12345.jpg")
|
||||
:mtype "image/jpeg"}
|
||||
error (try (media.v/check-media-size upload) nil
|
||||
(catch clojure.lang.ExceptionInfo e e))]
|
||||
(t/is (some? error))
|
||||
(t/is (th/ex-of-type? error :validation))
|
||||
(t/is (th/ex-of-code? error :params-validation)))))
|
||||
|
||||
(t/deftest check-font-size
|
||||
(t/testing "small font passes through"
|
||||
(let [upload {:filename "a.ttf" :size 1 :path (fs/path "/tmp/penpot/a.ttf") :mtype "font/ttf"}]
|
||||
(t/testing "small font passes even when the declared size lies upwards"
|
||||
(let [path (th/tempfile "backend_tests/test_files/font-1.ttf")
|
||||
upload {:filename "font.ttf" :size (* 1024 1024 31) :path path :mtype "font/ttf"}]
|
||||
(t/is (= upload (media.v/check-font-size upload)))))
|
||||
(t/testing "oversized font is rejected"
|
||||
(let [upload {:filename "a.ttf" :size (* 1024 1024 31) :path (fs/path "/tmp/penpot/a.ttf") :mtype "font/ttf"}
|
||||
(t/testing "font over the limit is rejected even when declared size lies downwards"
|
||||
(let [path (sparse-file! (fs/create-tempfile :dir "/tmp/penpot" :prefix "penpot-test-big-font-")
|
||||
(* 1024 1024 31))
|
||||
upload {:filename "big.ttf" :size 1 :path path :mtype "font/ttf"}
|
||||
error (try (media.v/check-font-size upload) nil
|
||||
(catch clojure.lang.ExceptionInfo e e))]
|
||||
(t/is (some? error))
|
||||
|
||||
@ -522,6 +522,26 @@
|
||||
(t/is (= :validation (-> out :error ex-data :type)))
|
||||
(t/is (= :media-type-not-allowed (-> out :error ex-data :code))))))
|
||||
|
||||
(t/deftest create-font-variant-png-bytes-chunked-upload
|
||||
"PNG bytes uploaded in chunks but declared as font/ttf are rejected."
|
||||
(with-mocks [_mock {:target 'app.rpc.quotes/check! :return nil}]
|
||||
(let [prof (th/create-profile* 1 {:is-active true})
|
||||
team-id (:default-team-id prof)
|
||||
font-id (uuid/custom 10 63)
|
||||
png-bytes (-> (io/resource "backend_tests/test_files/sample.png") (io/read*))
|
||||
session-id (upload-font-chunked! prof png-bytes "font/ttf" (* 4 1024 1024))
|
||||
out (th/command! {::th/type :create-font-variant
|
||||
::rpc/profile-id (:id prof)
|
||||
:team-id team-id
|
||||
:font-id font-id
|
||||
:font-family "png-as-font"
|
||||
:font-weight 400
|
||||
:font-style "normal"
|
||||
:uploads {"font/ttf" session-id}})]
|
||||
(t/is (some? (:error out)))
|
||||
(t/is (= :validation (-> out :error ex-data :type)))
|
||||
(t/is (= :media-type-mismatch (-> out :error ex-data :code))))))
|
||||
|
||||
;; --- Font family name validation / XSS prevention
|
||||
|
||||
(t/deftest create-font-variant-with-invalid-family
|
||||
|
||||
@ -46,14 +46,13 @@
|
||||
|
||||
(t/deftest upload-tempfile-returns-fresh-object-for-same-content
|
||||
(let [profile (th/create-profile* 1 {:is-active true})
|
||||
path (fs/create-tempfile :dir "/tmp/penpot" :prefix "test-upload-tempfile-")
|
||||
_ (io/write* path "content")
|
||||
path (th/tempfile "backend_tests/test_files/sample.png")
|
||||
params {::th/type :upload-tempfile
|
||||
::rpc/profile-id (:id profile)
|
||||
:content {:filename "export.png"
|
||||
:path path
|
||||
:mtype "image/png"
|
||||
:size 7}}
|
||||
:size (fs/size path)}}
|
||||
config (assoc cf/config :public-uri "https://example.com/penpot")
|
||||
out1 (binding [cf/config config]
|
||||
(th/multipart-management-command! params))
|
||||
@ -92,9 +91,7 @@
|
||||
(t/deftest upload-tempfile-accepts-both-declared-keys
|
||||
(let [profile (th/create-profile* 1 {:is-active true})]
|
||||
(doseq [key-id [:exporter :media-processor]]
|
||||
(let [path (fs/create-tempfile :dir "/tmp/penpot"
|
||||
:prefix (str "test-upload-" (name key-id) "-"))
|
||||
_ (io/write* path "content")
|
||||
(let [path (th/tempfile "backend_tests/test_files/sample.png")
|
||||
out (th/multipart-management-command!
|
||||
{::th/type :upload-tempfile
|
||||
::rpc/auth-key-id key-id
|
||||
@ -102,7 +99,7 @@
|
||||
:content {:filename "export.png"
|
||||
:path path
|
||||
:mtype "image/png"
|
||||
:size 7}})]
|
||||
:size (fs/size path)}})]
|
||||
(t/is (nil? (:error out)) (name key-id))))))
|
||||
|
||||
(t/deftest duplicate-file
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user