diff --git a/backend/src/app/rpc/commands/fonts.clj b/backend/src/app/rpc/commands/fonts.clj
index 6d86efd798..b243be5b2d 100644
--- a/backend/src/app/rpc/commands/fonts.clj
+++ b/backend/src/app/rpc/commands/fonts.clj
@@ -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"
diff --git a/backend/test/backend_tests/rpc_font_test.clj b/backend/test/backend_tests/rpc_font_test.clj
index dce0348e63..e955be39d3 100644
--- a/backend/test/backend_tests/rpc_font_test.clj
+++ b/backend/test/backend_tests/rpc_font_test.clj
@@ -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"
+ :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"}
+ 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))))))
diff --git a/common/src/app/common/types/font.cljc b/common/src/app/common/types/font.cljc
new file mode 100644
index 0000000000..61ee5a6059
--- /dev/null
+++ b/common/src/app/common/types/font.cljc
@@ -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)))]])
diff --git a/common/test/common_tests/types/font_test.cljc b/common/test/common_tests/types/font_test.cljc
new file mode 100644
index 0000000000..ee04e656fb
--- /dev/null
+++ b/common/test/common_tests/types/font_test.cljc
@@ -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