diff --git a/backend/deps.edn b/backend/deps.edn index 4a57cc1abe..3a8a282638 100644 --- a/backend/deps.edn +++ b/backend/deps.edn @@ -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"} diff --git a/backend/src/app/media.clj b/backend/src/app/media.clj index bdf6ed1c49..82dcff8f1d 100644 --- a/backend/src/app/media.clj +++ b/backend/src/app/media.clj @@ -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}))) diff --git a/backend/src/app/media/validation.clj b/backend/src/app/media/validation.clj index df7e534f54..af56280962 100644 --- a/backend/src/app/media/validation.clj +++ b/backend/src/app/media/validation.clj @@ -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 `")] + (recur (skip-ws s (+ j 2)))) + + (starts-at? s i "")] + (recur (skip-ws s (+ j 3)))) + + (starts-at? s i "} (.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 ` (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 ` (: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)) diff --git a/backend/src/app/rpc/commands/files_thumbnails.clj b/backend/src/app/rpc/commands/files_thumbnails.clj index 879dcee805..cde7989971 100644 --- a/backend/src/app/rpc/commands/files_thumbnails.clj +++ b/backend/src/app/rpc/commands/files_thumbnails.clj @@ -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) diff --git a/backend/src/app/rpc/commands/fonts.clj b/backend/src/app/rpc/commands/fonts.clj index 46aa009e20..f2a5ffa126 100644 --- a/backend/src/app/rpc/commands/fonts.clj +++ b/backend/src/app/rpc/commands/fonts.clj @@ -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)))) diff --git a/backend/src/app/rpc/commands/media.clj b/backend/src/app/rpc/commands/media.clj index 8a876460fb..63b2d8b123 100644 --- a/backend/src/app/rpc/commands/media.clj +++ b/backend/src/app/rpc/commands/media.clj @@ -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 diff --git a/backend/src/app/rpc/commands/profile.clj b/backend/src/app/rpc/commands/profile.clj index 3bd86b459b..6d5c90ba13 100644 --- a/backend/src/app/rpc/commands/profile.clj +++ b/backend/src/app/rpc/commands/profile.clj @@ -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 diff --git a/backend/src/app/rpc/commands/teams.clj b/backend/src/app/rpc/commands/teams.clj index d96c9a9cf1..4b45e38bdf 100644 --- a/backend/src/app/rpc/commands/teams.clj +++ b/backend/src/app/rpc/commands/teams.clj @@ -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 diff --git a/backend/src/app/rpc/management/exporter.clj b/backend/src/app/rpc/management/exporter.clj index 0b41f3d5b3..b5de544c1f 100644 --- a/backend/src/app/rpc/management/exporter.clj +++ b/backend/src/app/rpc/management/exporter.clj @@ -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)) diff --git a/backend/src/app/rpc/management/nitrate.clj b/backend/src/app/rpc/management/nitrate.clj index 55a409a0a5..ef3cdcaf2c 100644 --- a/backend/src/app/rpc/management/nitrate.clj +++ b/backend/src/app/rpc/management/nitrate.clj @@ -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)) diff --git a/backend/test/backend_tests/media_validation_test.clj b/backend/test/backend_tests/media_validation_test.clj index f4431e1b71..6dd46db4ec 100644 --- a/backend/test/backend_tests/media_validation_test.clj +++ b/backend/test/backend_tests/media_validation_test.clj @@ -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 "\n" + "\n" + "\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 "\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\n" + "")) + 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")) + 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) "") + 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)) diff --git a/backend/test/backend_tests/rpc_font_test.clj b/backend/test/backend_tests/rpc_font_test.clj index 4ef4533f7a..3da67f3951 100644 --- a/backend/test/backend_tests/rpc_font_test.clj +++ b/backend/test/backend_tests/rpc_font_test.clj @@ -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 diff --git a/backend/test/backend_tests/rpc_management_test.clj b/backend/test/backend_tests/rpc_management_test.clj index ef353511c9..3197a0026b 100644 --- a/backend/test/backend_tests/rpc_management_test.clj +++ b/backend/test/backend_tests/rpc_management_test.clj @@ -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