From 29f940fb7ab521033b1e276b8285afbc3609df6c Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 14 May 2026 11:20:11 +0200 Subject: [PATCH 1/6] :bug: Sanitize comment content on rendering (#9605) Add escape-html function that escapes HTML special characters and apply it in the comment editor at four dom/set-html! call sites where user-provided text is inserted as innerHTML, preventing stored XSS. Signed-off-by: Andrey Antukh --- frontend/src/app/main/ui/comments.cljs | 8 ++++---- frontend/src/app/util/dom.cljs | 12 ++++++++++++ 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/frontend/src/app/main/ui/comments.cljs b/frontend/src/app/main/ui/comments.cljs index 8f34c41277..cb1e2da042 100644 --- a/frontend/src/app/main/ui/comments.cljs +++ b/frontend/src/app/main/ui/comments.cljs @@ -81,7 +81,7 @@ ([text] (-> (dom/create-element "span") (dom/set-data! "type" "text") - (dom/set-html! (if (empty? text) zero-width-space text))))) + (dom/set-html! (if (empty? text) zero-width-space (dom/escape-html text)))))) (defn- create-mention-node "Creates a mention node" @@ -313,7 +313,7 @@ after-span (create-text-node (dm/str " " suffix)) sel (wapi/get-selection)] - (dom/set-html! span-node (if (empty? prefix) zero-width-space prefix)) + (dom/set-html! span-node (if (empty? prefix) zero-width-space (dom/escape-html prefix))) (dom/insert-after! node span-node mention-span) (dom/insert-after! node mention-span after-span) (wapi/set-cursor-after! after-span) @@ -330,7 +330,7 @@ (let [node-text (dom/get-text span-node) at-symbol (if (blank-content? node-text) "@" " @")] - (dom/set-html! span-node (str/concat node-text at-symbol)) + (dom/set-html! span-node (str/concat (dom/escape-html node-text) at-symbol)) (wapi/set-cursor-after! span-node)))))) handle-key-down @@ -378,7 +378,7 @@ (when span-node (let [txt (.-textContent span-node)] - (dom/set-html! span-node (dm/str (subs txt 0 offset) "\n" zero-width-space (subs txt offset))) + (dom/set-html! span-node (dm/str (dom/escape-html (subs txt 0 offset)) "\n" zero-width-space (dom/escape-html (subs txt offset)))) (wapi/set-cursor! span-node (inc offset)) (handle-input))))) diff --git a/frontend/src/app/util/dom.cljs b/frontend/src/app/util/dom.cljs index 4e068cc5d3..71a5d9665e 100644 --- a/frontend/src/app/util/dom.cljs +++ b/frontend/src/app/util/dom.cljs @@ -319,6 +319,18 @@ ([document ^js text] (.createTextNode document text))) +(defn escape-html + "Escapes special HTML characters in a string so that it can be safely used + as innerHTML without risk of XSS." + [^js text] + (when (some? text) + (-> text + (str/replace "&" "&") + (str/replace "<" "<") + (str/replace ">" ">") + (str/replace "\"" """) + (str/replace "'" "'")))) + (defn set-html! [^js el html] (when (some? el) From 67d95679711da538b3b22a873bcb6c197104dc0c Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 14 May 2026 13:46:02 +0200 Subject: [PATCH 2/6] :bug: 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 --- backend/src/app/rpc/commands/fonts.clj | 5 +- backend/test/backend_tests/rpc_font_test.clj | 97 +++++++++++++++++++ common/src/app/common/types/font.cljc | 21 ++++ common/test/common_tests/types/font_test.cljc | 41 ++++++++ frontend/src/app/main/ui/dashboard/fonts.cljs | 9 +- frontend/translations/en.po | 4 + 6 files changed, 173 insertions(+), 4 deletions(-) create mode 100644 common/src/app/common/types/font.cljc create mode 100644 common/test/common_tests/types/font_test.cljc 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