From 10504ff2bfc71f9f858c50bb268b97399ac60a2b Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 6 Oct 2026 14:21:12 +0200 Subject: [PATCH] :bug: Sanitize SVG on binfile import (#12105) * :bug: 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 * :bug: 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 * :bug: 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 * :bug: 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 * :bug: 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 --- backend/src/app/binfile/common.clj | 73 +++ backend/src/app/binfile/v1.clj | 12 +- backend/src/app/binfile/v2.clj | 12 +- backend/src/app/binfile/v3.clj | 15 +- .../test/backend_tests/binfile_svg_test.clj | 425 ++++++++++++++++++ common/src/app/common/media.cljc | 17 + common/test/common_tests/media_test.cljc | 7 + 7 files changed, 556 insertions(+), 5 deletions(-) create mode 100644 backend/test/backend_tests/binfile_svg_test.clj diff --git a/backend/src/app/binfile/common.clj b/backend/src/app/binfile/common.clj index e2aed9a837..7fe8e6a532 100644 --- a/backend/src/app/binfile/common.clj +++ b/backend/src/app/binfile/common.clj @@ -15,6 +15,7 @@ [app.common.files.migrations :as fmg] [app.common.files.validate :as fval] [app.common.logging :as l] + [app.common.media :as cm] [app.common.schema :as sm] [app.common.time :as ct] [app.common.types.file :as ctf] @@ -27,6 +28,7 @@ [app.features.file-migrations :as fmigr] [app.loggers.audit :as-alias audit] [app.loggers.webhooks :as-alias webhooks] + [app.media.svg :as svg] [app.storage :as sto] [app.util.blob :as blob] [app.util.pointer-map :as pmap] @@ -898,3 +900,74 @@ (cons (:id file))) load-fn #(get-file cfg % :migrate? false)] (weak/loadable-weak-value-map library-ids load-fn {id file}))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; SVG IMPORT SANITIZATION +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(def svg-content-type + "Canonical content type of SVG media objects." + "image/svg+xml") + +(defn normalize-content-type + "Canonicalize a stored content-type: lowercase, trimmed, + parameters after `;` dropped. Returns nil for missing or unusable + values so ancient bundle entries without content-type keep passing + through untouched." + [ctype] + (when (string? ctype) + (let [clean (-> ctype + (str/split #";" 2) + (first) + (str/trim) + (str/lower))] + (when-not (str/empty? clean) + clean)))) + +(defn svg-object? + "True when the storage `object` claims the canonical SVG content + type. Expects an already normalized object (see + `normalize-content-type`): every import path normalizes the + metadata right after reading it, so the comparison stays an exact + match in a single place." + [object] + (= svg-content-type (:content-type object))) + +(def schema:content-type + "Typed content-type for binfile storage objects: a member of + `cm/storage-object-types`. Values are canonicalized with + `normalize-content-type` before validation, so legacy spellings + keep importing while unknown types are rejected." + [::sm/one-of {:format :string} cm/storage-object-types]) + +(defn check-storage-content-type + "Check an imported storage `object` (already normalized, see + `normalize-content-type`) against `cm/storage-object-types`. + Returns nil when the type is allowed; raises `:type :validation` + with `:code :media-type-not-allowed` (same as the upload path) + when it is missing or unknown, so crafted bundles fail closed." + [object] + (when-not (contains? cm/storage-object-types (:content-type object)) + (ex/raise :type :validation + :code :media-type-not-allowed + :hint "storage object declares an unknown content-type" + :content-type (:content-type object)))) + +(defn sanitize-imported-svg + "Sanitize the raw `bytes` of an imported storage `object` when it + holds an SVG document. Expects an already normalized object (see + `normalize-content-type`); returns nil when the object is not an + SVG. + + Otherwise returns a map with the sanitized `:bytes`, their `:size` + and their blake2b `:hash`, ready to persist with `sto/put-object!`. + + Raises a `:validation` exception when the SVG cannot be parsed, the + same error the upload path reports." + [object ^bytes raw] + (when (svg-object? object) + (let [sanitized (svg/sanitize-svg (String. ^bytes raw "UTF-8")) + bytes (.getBytes ^String sanitized "UTF-8")] + {:bytes bytes + :size (alength ^bytes bytes) + :hash (sto/calculate-hash (java.io.ByteArrayInputStream. bytes))}))) diff --git a/backend/src/app/binfile/v1.clj b/backend/src/app/binfile/v1.clj index be65b423c9..fd340a487d 100644 --- a/backend/src/app/binfile/v1.clj +++ b/backend/src/app/binfile/v1.clj @@ -607,16 +607,26 @@ (doseq [expected-storage-id ids] (let [id (read-uuid! input) - mdata (read-obj! input)] + mdata (d/update-when (read-obj! input) :content-type bfc/normalize-content-type)] (when (not= id expected-storage-id) (ex/raise :type :validation :code :inconsistent-penpot-file :hint "the penpot file seems corrupt, found unexpected uuid (storage-object-id)")) + (bfc/check-storage-content-type mdata) + (l/dbg :hint "readed storage object" :id (str id) ::l/sync? true) (let [[size resource] (read-stream! input) + [resource size] (if (bfc/svg-object? mdata) + (let [raw (if (bytes? resource) + resource + (with-open [istream (jio/input-stream resource)] + (io/read istream))) + {:keys [bytes size]} (bfc/sanitize-imported-svg mdata raw)] + [bytes size]) + [resource size]) hash (sto/calculate-hash resource) content (-> (sto/content resource size) (sto/wrap-with-hash hash)) diff --git a/backend/src/app/binfile/v2.clj b/backend/src/app/binfile/v2.clj index 347074586b..2e0d04107a 100644 --- a/backend/src/app/binfile/v2.clj +++ b/backend/src/app/binfile/v2.clj @@ -197,9 +197,17 @@ (defn- read-storage-object! [{:keys [::sto/storage ::bfc/timestamp] :as cfg} id] - (let [mdata (read-obj cfg :storage-object id) + (let [mdata (d/update-when (read-obj cfg :storage-object id) + :content-type bfc/normalize-content-type) data (read-blob cfg :storage-object id) - hash (sto/calculate-hash data) + ;; NOTE: v2 bundles are legacy and no longer produced; the + ;; sanitizer call stays as defense in depth and is covered by + ;; the shared helper unit tests, with no dedicated v2 + ;; import test. + clean (when (bfc/svg-object? mdata) + (bfc/sanitize-imported-svg mdata data)) + data (or (:bytes clean) data) + hash (or (:hash clean) (sto/calculate-hash data)) content (-> (sto/content data) (sto/wrap-with-hash hash)) diff --git a/backend/src/app/binfile/v3.clj b/backend/src/app/binfile/v3.clj index cc488f6ab8..cdc3dd66bb 100644 --- a/backend/src/app/binfile/v3.clj +++ b/backend/src/app/binfile/v3.clj @@ -74,7 +74,7 @@ [:map {:title "StorageObject"} [:id ::sm/uuid] [:size ::sm/int] - [:content-type :string] + [:content-type bfc/schema:content-type] [:bucket [::sm/one-of {:format :string} sto/valid-buckets]] [:hash {:optional true} :string]]) @@ -871,6 +871,7 @@ (doseq [{:keys [id entry]} entries] (let [object (-> (read-entry input entry) (decode-storage-object) + (d/update-when :content-type bfc/normalize-content-type) (update :bucket d/nilv sto/default-bucket) (validate-storage-object)) @@ -906,7 +907,17 @@ :expected-hash (:hash object) :found-hash (sto/get-hash content)))) - (let [params (-> object + (let [clean (when (bfc/svg-object? object) + (let [limit (::bfc/import-max-object-size cfg) + raw (with-open [istream (cond-> (zip-entry-stream input (get-zip-entry input path)) + limit (size-limiting-stream limit))] + (io/read istream))] + (bfc/sanitize-imported-svg object raw))) + content (if-let [{:keys [bytes hash]} clean] + (-> (sto/content bytes) + (sto/wrap-with-hash hash)) + content) + params (-> object (dissoc :id :size) (assoc ::sto/content content) (assoc ::sto/deduplicate? true) diff --git a/backend/test/backend_tests/binfile_svg_test.clj b/backend/test/backend_tests/binfile_svg_test.clj new file mode 100644 index 0000000000..8b63de4ab8 --- /dev/null +++ b/backend/test/backend_tests/binfile_svg_test.clj @@ -0,0 +1,425 @@ +;; 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 backend-tests.binfile-svg-test + "SVG sanitization on the binfile import path. + + Second case of GHSA-ffhp-m958-qxvr: a crafted `.penpot` bundle can + smuggle an unsanitized SVG storage object past the upload-path + sanitizer, and the raw bytes are served same-origin as + `image/svg+xml`." + (:require + [app.binfile.common :as bfc] + [app.binfile.v1 :as v1] + [app.binfile.v3 :as v3] + [app.common.features :as cfeat] + [app.common.types.shape :as cts] + [app.common.uuid :as uuid] + [app.db :as db] + [app.rpc :as-alias rpc] + [app.storage :as sto] + [app.storage.tmp :as tmp] + [backend-tests.helpers :as th] + [clojure.data.json :as json] + [clojure.java.io :as jio] + [clojure.string :as str] + [clojure.test :as t] + [datoteka.fs :as fs] + [datoteka.io :as io]) + (:import + java.io.ByteArrayInputStream + java.util.zip.ZipEntry + java.util.zip.ZipFile + java.util.zip.ZipOutputStream)) + +(t/use-fixtures :once th/state-init) +(t/use-fixtures :each th/database-reset) + +(def ^:private evil-svg + "") + +(def ^:private evil-xhref-svg + "click") + +(def ^:private clean-svg + "") + +(defn- utf8bytes + [^String s] + (.getBytes s "UTF-8")) + +(defn- bytes-str + [^bytes data] + (String. data "UTF-8")) + +(t/deftest sanitize-imported-svg-removes-script + (let [result (bfc/sanitize-imported-svg {:content-type "image/svg+xml" :bucket "file-media-object"} + (utf8bytes evil-svg))] + (t/is (some? result)) + (t/is (not (str/includes? (bytes-str (:bytes result)) "")))))) + +(t/deftest check-storage-content-type-allows-known-types + (doseq [ctype ["image/svg+xml" "image/jpeg" "font/woff2" "application/octet-stream"]] + (t/is (nil? (bfc/check-storage-content-type {:content-type ctype})) + (str "expected pass for " (pr-str ctype))))) + +(t/deftest check-storage-content-type-rejects-unknown-types + (doseq [ctype ["text/html" "application/x-font-woff" nil "" " "]] + (let [object (if (nil? ctype) {} {:content-type ctype}) + out (try (bfc/check-storage-content-type object) + nil + (catch clojure.lang.ExceptionInfo e + (ex-data e)))] + (t/is (= :validation (:type out)) (str "expected validation for " (pr-str ctype))) + (t/is (= :media-type-not-allowed (:code out)) (str "expected media-type-not-allowed for " (pr-str ctype)))))) + +(t/deftest storage-object-schema-constrains-content-type + (t/testing "known stored types validate, unknown types do not" + (let [base {:id (uuid/random) :size 10 :bucket "file-media-object"}] + (doseq [ctype ["image/svg+xml" "image/jpeg" "image/png" + "font/woff2" "font/ttf" "application/octet-stream" "application/pdf"]] + (t/is (some? (v3/validate-storage-object (assoc base :content-type ctype))) + (str "expected valid for " (pr-str ctype)))) + (doseq [ctype ["" " " "image /svg" "image/apng" "image/avif" + "application/x-font-woff" (apply str (repeat 200 "x"))]] + (t/is (thrown? clojure.lang.ExceptionInfo + (v3/validate-storage-object (assoc base :content-type ctype))) + (str "expected rejection for " (pr-str ctype))))))) + +(defn- write-temp-svg + "Write `text` to a tempfile and return `[path size]`." + [text] + (let [path (fs/create-tempfile :prefix "penpot-svg-import-test-" :suffix ".svg") + data (utf8bytes text)] + (spit (str path) text :encoding "UTF-8") + [path (alength data)])) + +(defn- tamper-svg-entry! + "Copy `src` zip to `dst` zip replacing the `objects/.svg` + payload with `evil-bytes` and fixing its `.json` sidecar (size and + blake2b hash) so the bundle passes the integrity checks. When + `content-type` is given, the sidecar declares it instead of the + original spelling." + [src dst storage-id ^bytes evil-bytes & {:keys [content-type]}] + (let [svg-name (str "objects/" storage-id ".svg") + json-name (str "objects/" storage-id ".json")] + (with-open [zin (ZipFile. (.toFile ^java.nio.file.Path src)) + zout (ZipOutputStream. (io/output-stream dst))] + (doseq [entry (enumeration-seq (.entries zin))] + (let [name (.getName ^ZipEntry entry)] + (cond + (= name svg-name) + (do (.putNextEntry zout (ZipEntry. ^String name)) + (.write zout evil-bytes 0 (alength evil-bytes)) + (.closeEntry zout)) + + (= name json-name) + (let [raw (slurp (.getInputStream zin entry) :encoding "UTF-8") + parsed (json/read-str raw) + updated (cond-> (assoc parsed + "size" (alength evil-bytes) + "hash" (sto/calculate-hash (ByteArrayInputStream. evil-bytes))) + content-type (assoc "contentType" content-type))] + (.putNextEntry zout (ZipEntry. ^String name)) + (.write zout (utf8bytes (json/write-str updated))) + (.closeEntry zout)) + + :else + (do (.putNextEntry zout (ZipEntry. ^String name)) + (with-open [in (.getInputStream zin entry)] + (io/copy in zout)) + (.closeEntry zout)))))))) + +(defn- setup-file-with-svg-media + "Upload `clean-svg`, attach it to a shape fill and return + `[file uploaded]` ready to export." + [profile n] + (let [file (th/create-file* n {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false}) + [svg-path svg-size] (write-temp-svg clean-svg) + out (th/command! {::th/type :upload-file-media-object + ::rpc/profile-id (:id profile) + :file-id (:id file) + :is-local true + :name "benign.svg" + :content {:filename "benign.svg" + :path svg-path + :mtype "image/svg+xml" + :size svg-size}}) + _ (t/is (nil? (:error out)) (str "upload failed: " (pr-str (:error out)))) + uploaded (:result out) + page-id (first (get-in file [:data :pages])) + shape-id (uuid/random) + upd (th/command! {::th/type :update-file + ::rpc/profile-id (:id profile) + :id (:id file) + :session-id (uuid/random) + :revn 0 + :vern 0 + :features cfeat/supported-features + :changes + [{:type :add-obj + :page-id page-id + :id shape-id + :parent-id uuid/zero + :frame-id uuid/zero + :components-v2 true + :obj (cts/setup-shape + {:id shape-id + :name "image" + :frame-id uuid/zero + :parent-id uuid/zero + :type :rect + :fills [{:fill-opacity 1 + :fill-image {:id (:id uploaded) + :width (:width uploaded) + :height (:height uploaded) + :mtype "image/svg+xml"}}]})}]})] + (t/is (nil? (:error upd)) (str "update-file failed: " (pr-str (:error upd)))) + (t/is (uuid? (:media-id uploaded))) + [file uploaded])) + +(defn- export-tamper-import-v3 + "Export `file` to v3, tamper its SVG payload with `evil-text` + (declaring `content-type` when given) and import the bundle. + Returns `[served-bytes stored-content-type]` of the imported + media." + [profile file media-id evil-text content-type] + (let [bundle (tmp/tempfile :prefix "penpot-export-" :suffix ".zip")] + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{(:id file)}) + (assoc ::bfc/embed-assets false) + (assoc ::bfc/include-libraries false)) + (io/output-stream bundle)) + + (let [evil (tmp/tempfile :prefix "penpot-evil-" :suffix ".zip") + _ (tamper-svg-entry! bundle evil media-id (utf8bytes evil-text) :content-type content-type) + result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/input evil) + (v3/import-files!))] + (t/is (= 1 (count result))) + (let [rows (th/db-query :file-media-object {:file-id (first result)}) + storage (:app.storage/storage th/*system*)] + (t/is (= 1 (count rows))) + (let [object (sto/get-object storage (:media-id (first rows)))] + [(bytes-str (sto/get-object-bytes storage object)) + (:content-type (meta object))]))))) + +(t/deftest import-binfile-v3-sanitizes-smuggled-svg + (let [profile (th/create-profile* 1) + [file uploaded] (setup-file-with-svg-media profile 1) + [served _stored] (export-tamper-import-v3 profile file (:media-id uploaded) evil-svg nil)] + (t/is (not (str/includes? served " (sto/content raw) + (sto/wrap-with-hash (sto/calculate-hash (ByteArrayInputStream. raw)))) + sobj (sto/put-object! storage {::sto/content content + :content-type content-type + :bucket "file-media-object"}) + fmo-id (uuid/random) + _ (th/db-insert! :file-media-object + {:id fmo-id + :file-id (:id file) + :is-local true + :name "seed.svg" + :media-id (:id sobj) + :width 64 + :height 64 + :mtype content-type}) + page-id (first (get-in file [:data :pages])) + shape-id (uuid/random) + upd (th/command! {::th/type :update-file + ::rpc/profile-id (:id profile) + :id (:id file) + :session-id (uuid/random) + :revn 0 + :vern 0 + :features cfeat/supported-features + :changes + [{:type :add-obj + :page-id page-id + :id shape-id + :parent-id uuid/zero + :frame-id uuid/zero + :components-v2 true + :obj (cts/setup-shape + {:id shape-id + :name "image" + :frame-id uuid/zero + :parent-id uuid/zero + :type :rect + :fills [{:fill-opacity 1 + :fill-image {:id fmo-id + :width 64 + :height 64 + :mtype "image/svg+xml"}}]})}]})] + (t/is (nil? (:error upd)) (str "update-file failed: " (pr-str (:error upd)))) + [file (:id sobj)])) + +(defn- export-v1 + "Export `file` to v1 and return the bundle path." + [profile file] + (let [bundle (tmp/tempfile :prefix "penpot-export-v1-" :suffix ".bin")] + (v1/export-files! + (-> th/*system* + (assoc ::bfc/ids #{(:id file)}) + (assoc ::bfc/embed-assets false) + (assoc ::bfc/include-libraries false)) + (io/output-stream bundle)) + bundle)) + +(defn- export-import-v1 + "Export `file` to v1, import it back and return the served bytes + of its single media object." + [profile file] + (let [bundle (export-v1 profile file)] + (let [result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/input (io/input-stream bundle)) + (v1/import-files!))] + (t/is (= 1 (count result))) + (let [rows (th/db-query :file-media-object {:file-id (first result)}) + storage (:app.storage/storage th/*system*)] + (t/is (= 1 (count rows))) + (let [object (sto/get-object storage (:media-id (first rows)))] + [(bytes-str (sto/get-object-bytes storage object)) + (:content-type (meta object))]))))) + +(t/deftest import-binfile-v1-sanitizes-seeded-svg + (let [profile (th/create-profile* 4) + [file _seed] (seed-file-with-svg-media profile 4 evil-svg "IMAGE/SVG+XML") + [served stored] (export-import-v1 profile file)] + (t/is (not (str/includes? served " th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/input (io/input-stream bundle)) + (v1/import-files!)) + nil + (catch clojure.lang.ExceptionInfo e + (ex-data e)))] + (t/is (= :validation (:type out))) + (t/is (= :media-type-not-allowed (:code out))) + (t/is (= [(:id file)] + (mapv :id (th/db-query :file {:project-id (:default-project-id profile)})))))) + +(def ^:private large-clean-svg + (let [pad (apply str (repeat 110000 ""))] + (str "" pad ""))) + +(t/deftest import-binfile-v1-roundtrips-large-svg + (t/testing "objects over the tempfile threshold (tempfile branch) import fine" + (t/is (> (alength (utf8bytes large-clean-svg)) bfc/temp-file-threshold)) + (let [profile (th/create-profile* 5) + [file _seed] (seed-file-with-svg-media profile 5 large-clean-svg "image/svg+xml") + [served _stored] (export-import-v1 profile file)] + (t/is (str/includes? served "circle"))))) diff --git a/common/src/app/common/media.cljc b/common/src/app/common/media.cljc index a5a74e6c75..b7ec9b5a71 100644 --- a/common/src/app/common/media.cljc +++ b/common/src/app/common/media.cljc @@ -25,6 +25,23 @@ (def tempfile-types (conj image-types "application/pdf" "application/zip")) +(def storage-object-types + "Every content-type the system stores in storage objects: uploaded + images and fonts, generated thumbnails, temporary files and the + `application/octet-stream` fallback used by file data and font + variants. Sorted for determinism. Every member must have a + producing code path; anticipated but unproduced types (such as + `image/apng`) stay out until some flow actually stores them. + Import boundaries normalize the stored string (lowercase, no + parameters) before checking membership, so legacy spelling + variants keep importing while unknown types are rejected." + (into (sorted-set) + (concat image-types + font-types + ["application/octet-stream" + "application/pdf" + "application/zip"]))) + (defn format->extension [format] (case format diff --git a/common/test/common_tests/media_test.cljc b/common/test/common_tests/media_test.cljc index c6916e3216..c0440331bc 100644 --- a/common/test/common_tests/media_test.cljc +++ b/common/test/common_tests/media_test.cljc @@ -9,6 +9,13 @@ [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")))