penpot/common/test/common_tests/media_test.cljc
Andrey Antukh 10504ff2bf
🐛 Sanitize SVG on binfile import (#12105)
* 🐛 Sanitize smuggled SVG on binfile import

Route imported SVG storage objects through the SVG sanitizer on
all three binfile paths (v3, v1, v2), closing the second bypass
of GHSA-ffhp-m958-qxvr. Integrity checks still run on the raw
bundle bytes first; only the persisted copy is the sanitized one.

Adds a shared sanitize-imported-svg helper with unit tests and a
v3 export-tamper-import test proving smuggled scripts no longer
survive the import.

AI-assisted-by: muse-spark-1.3-contributor

* 🐛 Normalize SVG content-type on binfile import

Close the residual GHSA-ffhp-m958-qxvr bypass where a crafted
content-type spelling (uppercase, parameters) skipped the import
sanitizer. Detection now uses a shared case-insensitive predicate,
stored values are canonicalized, and the v3 re-read honors the
import size limit.

Extends the tamper tests to obfuscated spellings and adds
exhaustive v1 coverage, including the tempfile branch.

AI-assisted-by: muse-spark-1.3-contributor

* 🐛 Gate binfile content-type on known storage types

Define the complete set of storage content-types in
app.common.media and enforce it on the binfile import schema, so
unknown types fail closed instead of passing through. Detection
keeps a single normalization at the boundary with an exact
predicate, and the string helpers use cuerdas.

AI-assisted-by: muse-spark-1.3-contributor

* 🐛 Keep only produced types in storage-object-types

Drop apng, avif, penpot and plain text from the set: no flow
stores them, they only exist in the extension mapping table.
Every member must have a producing code path; unproduced types
stay out until some flow actually stores them.

AI-assisted-by: muse-spark-1.3-contributor

* 🐛 Reject unknown content-type on v1 binfile import

Enforce the storage content-type allowlist on the v1 import
path, like v3 already does via schema. Bundles declaring types
outside the set, or none at all, fail closed with the same
media-type-not-allowed error as uploads. The sanitize branch is
explicit: SVG bytes are sanitized, anything else passes through.

AI-assisted-by: muse-spark-1.3-contributor
2026-10-06 14:21:12 +02:00

102 lines
5.3 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 common-tests.media-test
(:require
[app.common.media :as media]
[clojure.test :as t]))
(t/deftest test-storage-object-types
(t/testing "covers every image and font type the system stores"
(t/is (every? #(contains? media/storage-object-types %) media/image-types))
(t/is (every? #(contains? media/storage-object-types %) media/font-types))
(t/is (contains? media/storage-object-types "image/svg+xml"))
(t/is (contains? media/storage-object-types "application/octet-stream"))))
(t/deftest test-parse-font-weight
(t/testing "matches weight tokens with proper boundaries"
(t/is (= 700 (media/parse-font-weight "Roboto-Bold")))
(t/is (= 700 (media/parse-font-weight "Roboto_Bold")))
(t/is (= 700 (media/parse-font-weight "Roboto Bold")))
(t/is (= 700 (media/parse-font-weight "Bold")))
(t/is (= 800 (media/parse-font-weight "Roboto-ExtraBold")))
(t/is (= 600 (media/parse-font-weight "OpenSans-SemiBold")))
(t/is (= 300 (media/parse-font-weight "Lato-Light")))
(t/is (= 100 (media/parse-font-weight "Roboto-Thin")))
(t/is (= 200 (media/parse-font-weight "Roboto-ExtraLight")))
(t/is (= 500 (media/parse-font-weight "Roboto-Medium")))
(t/is (= 900 (media/parse-font-weight "Roboto-Black"))))
(t/testing "does not match weight tokens embedded in words"
(t/is (= 400 (media/parse-font-weight "Boldini")))
(t/is (= 400 (media/parse-font-weight "Lighthaus")))
(t/is (= 400 (media/parse-font-weight "Blackwood")))
(t/is (= 400 (media/parse-font-weight "Thinker")))
(t/is (= 400 (media/parse-font-weight "Mediaeval")))))
(t/deftest test-parse-font-style
(t/testing "matches italic with proper boundaries"
(t/is (= "italic" (media/parse-font-style "Roboto-Italic")))
(t/is (= "italic" (media/parse-font-style "Roboto_Italic")))
(t/is (= "italic" (media/parse-font-style "Roboto Italic")))
(t/is (= "italic" (media/parse-font-style "Italic")))
(t/is (= "italic" (media/parse-font-style "Roboto-BoldItalic"))))
(t/testing "does not match italic embedded in words"
(t/is (= "normal" (media/parse-font-style "Italica")))
(t/is (= "normal" (media/parse-font-style "Roboto-Regular")))))
(t/deftest test-strip-image-extension
(t/testing "removes extension from supported image files"
(t/is (= (media/strip-image-extension "foo.png") "foo"))
(t/is (= (media/strip-image-extension "foo.webp") "foo"))
(t/is (= (media/strip-image-extension "foo.jpg") "foo"))
(t/is (= (media/strip-image-extension "foo.jpeg") "foo"))
(t/is (= (media/strip-image-extension "foo.svg") "foo"))
(t/is (= (media/strip-image-extension "foo.gif") "foo")))
(t/testing "does not remove extension for unsupported files"
(t/is (= (media/strip-image-extension "foo.txt") "foo.txt"))
(t/is (= (media/strip-image-extension "foo.bmp") "foo.bmp")))
(t/testing "leaves filename intact when it has no extension"
(t/is (= (media/strip-image-extension "README") "README"))))
(t/deftest test-font-display-variant
(t/testing "preserves the foundry-supplied variant string verbatim"
(t/is (= "Thin" (media/font-display-variant "Thin" 100 "normal")))
(t/is (= "SemiBold" (media/font-display-variant "SemiBold" 600 "normal")))
(t/is (= "Medium Oblique" (media/font-display-variant "Medium Oblique" 500 "italic")))
(t/is (= "Ultra" (media/font-display-variant "Ultra" 900 "normal"))))
(t/testing "trims surrounding whitespace from upstream variant strings"
(t/is (= "Bold" (media/font-display-variant " Bold " 700 "normal"))))
(t/testing "ignores blank or nil variant strings"
(t/is (= "Hairline" (media/font-display-variant nil 100 "normal")))
(t/is (= "Regular" (media/font-display-variant "" 400 "normal")))
(t/is (= "Bold" (media/font-display-variant " " 700 "normal")))
(t/is (= "Bold Italic" (media/font-display-variant nil 700 "italic"))))
(t/testing "fallback covers every supported numeric weight"
(t/is (= "Hairline" (media/font-display-variant nil 100 "normal")))
(t/is (= "Extra Light" (media/font-display-variant nil 200 "normal")))
(t/is (= "Light" (media/font-display-variant nil 300 "normal")))
(t/is (= "Regular" (media/font-display-variant nil 400 "normal")))
(t/is (= "Medium" (media/font-display-variant nil 500 "normal")))
(t/is (= "Semi Bold" (media/font-display-variant nil 600 "normal")))
(t/is (= "Bold" (media/font-display-variant nil 700 "normal")))
(t/is (= "Extra Bold" (media/font-display-variant nil 800 "normal")))
(t/is (= "Black" (media/font-display-variant nil 900 "normal")))
(t/is (= "Extra Black" (media/font-display-variant nil 950 "normal"))))
(t/testing "italic suffix only applied via the fallback path"
(t/is (= "Italic" (media/font-display-variant "Italic" 400 "italic")))
(t/is (= "Regular Italic" (media/font-display-variant nil 400 "italic"))))
(t/testing "stored variant survives even when its derived weight disagrees"
(t/is (= "Ultra" (media/font-display-variant "Ultra" 400 "normal")))))