🐛 Prevent CSS injection vulnerability in font family names

Add a shared `schema:font-family` whitelist validator in
app.common.types.font that only allows letters, digits, spaces,
hyphens, underscores, and dots in font family names. Apply the schema
to create-font-variant and update-font RPC endpoints on the
backend, and add client-side validation in the dashboard fonts UI.
Include unit tests for the schema and integration tests for the RPC
handlers.

Signed-off-by: Andrey Antukh <niwi@niwi.nz>
This commit is contained in:
Andrey Antukh 2026-05-14 13:46:02 +02:00 committed by GitHub
parent 29f940fb7a
commit 67d9567971
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 173 additions and 4 deletions

View File

@ -13,6 +13,7 @@
[app.common.media :as cm]
[app.common.schema :as sm]
[app.common.time :as ct]
[app.common.types.font :as types.font]
[app.common.uuid :as uuid]
[app.db :as db]
[app.db.sql :as-alias sql]
@ -96,7 +97,7 @@
[:map {:title "create-font-variant"}
[:team-id ::sm/uuid]
[:font-id ::sm/uuid]
[:font-family ::sm/text]
[:font-family types.font/schema:font-family]
[:font-weight [::sm/one-of {:format "number"} valid-weight]]
[:font-style [::sm/one-of {:format "string"} valid-style]]
[:data {:optional true} [:map-of ::sm/text [:or ::sm/bytes [::sm/vec ::sm/bytes]]]]
@ -273,7 +274,7 @@
[:map {:title "update-font"}
[:team-id ::sm/uuid]
[:id ::sm/uuid]
[:name :string]])
[:name types.font/schema:font-family]])
(sv/defmethod ::update-font
{::doc/added "1.18"

View File

@ -790,3 +790,100 @@
(t/is (some? (:error out)))
(t/is (= :validation (-> out :error ex-data :type)))
(t/is (= :media-type-not-allowed (-> out :error ex-data :code))))))
;; --- Font family name validation / XSS prevention
(t/deftest create-font-variant-with-invalid-family
(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 100)
data (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*))]
;; name with < should fail
(let [params {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id :font-id font-id
:font-family "evil<script>alert(1)</script>"
:font-weight 400 :font-style "normal"
:data {"font/ttf" data}}
out (th/command! params)]
(t/is (not (th/success? out)))
(t/is (th/ex-of-type? (:error out) :validation))
(t/is (th/ex-of-code? (:error out) :params-validation)))
;; name with ' should fail
(let [params {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id :font-id font-id
:font-family "evil'name"
:font-weight 400 :font-style "normal"
:data {"font/ttf" data}}
out (th/command! params)]
(t/is (not (th/success? out)))
(t/is (th/ex-of-type? (:error out) :validation)))
;; name with } should fail
(let [params {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id :font-id font-id
:font-family "evil}name"
:font-weight 400 :font-style "normal"
:data {"font/ttf" data}}
out (th/command! params)]
(t/is (not (th/success? out)))
(t/is (th/ex-of-type? (:error out) :validation)))
;; valid name should succeed
(let [params {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id :font-id (uuid/custom 10 101)
:font-family "Source Sans Pro"
:font-weight 400 :font-style "normal"
:data {"font/ttf" data}}
out (th/command! params)]
(t/is (th/success? out))))))
(t/deftest update-font-with-invalid-family
(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 102)
data (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*))]
;; Create a valid font first
(let [params {::th/type :create-font-variant
::rpc/profile-id (:id prof)
:team-id team-id :font-id font-id
:font-family "ValidFont"
:font-weight 400 :font-style "normal"
:data {"font/ttf" data}}
out (th/command! params)]
(t/is (th/success? out)))
;; rename with < should fail
(let [params {::th/type :update-font
::rpc/profile-id (:id prof)
:team-id team-id :id font-id
:name "evil<script>x</script>"}
out (th/command! params)]
(t/is (not (th/success? out)))
(t/is (th/ex-of-type? (:error out) :validation))
(t/is (th/ex-of-code? (:error out) :params-validation)))
;; rename with ' should fail
(let [params {::th/type :update-font
::rpc/profile-id (:id prof)
:team-id team-id :id font-id
:name "evil'name"}
out (th/command! params)]
(t/is (not (th/success? out)))
(t/is (th/ex-of-type? (:error out) :validation)))
;; valid rename should succeed
(let [params {::th/type :update-font
::rpc/profile-id (:id prof)
:team-id team-id :id font-id
:name "Valid Font Name"}
out (th/command! params)]
(t/is (th/success? out))))))

View File

@ -0,0 +1,21 @@
;; 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
(ns app.common.types.font
(:require
[app.common.schema :as sm]))
(def ^:private font-family-re
;; \p{L} (Unicode letter) works in Java regex natively, but in JavaScript it
;; requires the "u" flag which ClojureScript regex literals don't support.
#?(:clj #"[\p{L}\d _.-]+"
:cljs (js/RegExp. "[\\p{L}\\d _.-]+" "u")))
(def schema:font-family
[:and
[::sm/text {:max 250}]
[:fn {:error/code "errors.font-family-invalid-chars"}
(fn [s] (boolean (re-matches font-family-re s)))]])

View File

@ -0,0 +1,41 @@
;; 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
(ns common-tests.types.font-test
(:require
[app.common.schema :as sm]
[app.common.types.font :as ctf]
[clojure.test :as t]))
(t/deftest font-family-schema-valid
(t/is (sm/validate ctf/schema:font-family "Source Sans Pro"))
(t/is (sm/validate ctf/schema:font-family "Roboto"))
(t/is (sm/validate ctf/schema:font-family "Open Sans 300"))
(t/is (sm/validate ctf/schema:font-family "Font-Name_v2"))
(t/is (sm/validate ctf/schema:font-family "Noto Sans CJK SC"))
(t/is (sm/validate ctf/schema:font-family "A"))
;; hyphens, underscores and dots are allowed
(t/is (sm/validate ctf/schema:font-family "Fira-Code"))
(t/is (sm/validate ctf/schema:font-family "font_name"))
(t/is (sm/validate ctf/schema:font-family "Soucre Sans Pro 3.0"))
;; Unicode letters are allowed
(t/is (sm/validate ctf/schema:font-family "思源黑体"))
(t/is (sm/validate ctf/schema:font-family "العربية")))
(t/deftest font-family-schema-invalid
;; HTML injection characters
(t/is (not (sm/validate ctf/schema:font-family "evil<script>")))
(t/is (not (sm/validate ctf/schema:font-family "<test>name")))
;; CSS injection characters
(t/is (not (sm/validate ctf/schema:font-family "evil'name")))
(t/is (not (sm/validate ctf/schema:font-family "evil\"name")))
(t/is (not (sm/validate ctf/schema:font-family "evil}name")))
(t/is (not (sm/validate ctf/schema:font-family "evil;name")))
(t/is (not (sm/validate ctf/schema:font-family "evil\\name")))
;; empty string
(t/is (not (sm/validate ctf/schema:font-family "")))
;; too long
(t/is (not (sm/validate ctf/schema:font-family (apply str (repeat 251 "a"))))))

View File

@ -9,6 +9,8 @@
(:require
[app.common.data.macros :as dm]
[app.common.media :as cm]
[app.common.schema :as sm]
[app.common.types.font :as ctf]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.main.data.fonts :as df]
@ -139,7 +141,8 @@
(dom/get-data "id")
(uuid/parse))
name (dom/get-value target)]
(when-not (str/blank? name)
(when (and (not (str/blank? name))
(sm/validate ctf/schema:font-family name))
(swap! fonts* df/rename-and-regroup id name installed-fonts)))))
on-change-name
@ -317,7 +320,9 @@
(fn [_]
(reset! edition* false)
(when-not (str/blank? font-family)
(st/emit! (df/update-font {:id font-id :name font-family})))))
(if (sm/validate ctf/schema:font-family font-family)
(st/emit! (df/update-font {:id font-id :name font-family}))
(st/emit! (ntf/error (tr "errors.font-family-invalid-chars")))))))
on-key-down
(mf/use-fn

View File

@ -1439,6 +1439,10 @@ msgstr "Invalid text"
msgid "errors.team-name-invalid-chars"
msgstr "The team name can't contain any of the following characters:'.', ':' or '/'"
#: common/src/app/common/types/font.cljc
msgid "errors.font-family-invalid-chars"
msgstr "The font family name can only contain letters, numbers, spaces, hyphens, underscores, and dots."
#: src/app/main/ui/static.cljs:74
msgid "errors.invite-invalid"
msgstr "Invite invalid"