🐛 Add accumulated storage byte quota for media uploads

Add media-storage-bytes-per-team quote to prevent persistent DoS via
repeated uploads. The quota sums storage_object sizes from both
file_media_object (media + thumbnails) and team_font_variant
(otf/ttf/woff1/woff2). Default limit is 20 GiB per team, configurable
via PENPOT_QUOTES_MEDIA_STORAGE_BYTES_PER_TEAM.

The check is invoked in upload-file-media-object before processing,
looking up the team-id via file -> project -> team_id join.

AI-assisted-by: mimo-v2.5
This commit is contained in:
Andrey Antukh 2026-07-29 08:36:40 +00:00
parent aac8a69951
commit 0064a1afb4
4 changed files with 176 additions and 0 deletions

View File

@ -198,6 +198,7 @@
[:quotes-team-access-requests-per-requester {:optional true} ::sm/int] [:quotes-team-access-requests-per-requester {:optional true} ::sm/int]
[:quotes-upload-sessions-per-profile {:optional true} ::sm/int] [:quotes-upload-sessions-per-profile {:optional true} ::sm/int]
[:quotes-upload-chunks-per-session {:optional true} ::sm/int] [:quotes-upload-chunks-per-session {:optional true} ::sm/int]
[:quotes-media-storage-bytes-per-team {:optional true} ::sm/int]
[:auth-token-cookie-name {:optional true} :string] [:auth-token-cookie-name {:optional true} :string]
[:auth-token-cookie-max-age {:optional true} ::ct/duration] [:auth-token-cookie-max-age {:optional true} ::ct/duration]

View File

@ -38,6 +38,12 @@
(declare create-file-media-object) (declare create-file-media-object)
(def ^:private sql:get-team-id-for-file
"SELECT p.team_id
FROM file AS f
JOIN project AS p ON (p.id = f.project_id)
WHERE f.id = ?")
(def ^:private schema:upload-file-media-object (def ^:private schema:upload-file-media-object
[:map {:title "upload-file-media-object"} [:map {:title "upload-file-media-object"}
[:id {:optional true} ::sm/uuid] [:id {:optional true} ::sm/uuid]
@ -56,6 +62,12 @@
(media/validate-media-type! content) (media/validate-media-type! content)
(media/validate-media-size! content) (media/validate-media-size! content)
(let [team-id (:team-id (db/exec-one! pool [sql:get-team-id-for-file file-id]))]
(quotes/check! cfg {::quotes/id ::quotes/media-storage-bytes-per-team
::quotes/profile-id profile-id
::quotes/team-id team-id
::quotes/incr (:size content)}))
(db/run! cfg (fn [{:keys [::db/conn] :as cfg}] (db/run! cfg (fn [{:keys [::db/conn] :as cfg}]
;; We get the minimal file for proper checking if ;; We get the minimal file for proper checking if
;; file is not already deleted ;; file is not already deleted

View File

@ -546,6 +546,76 @@
(assoc ::count-sql [sql:get-upload-sessions-per-profile profile-id]) (assoc ::count-sql [sql:get-upload-sessions-per-profile profile-id])
(generic-check!))) (generic-check!)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; QUOTE: MEDIA-STORAGE-BYTES-PER-TEAM
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def ^:private schema:media-storage-bytes-per-team
[:map
[::profile-id ::sm/uuid]
[::team-id ::sm/uuid]])
(def ^:private valid-media-storage-bytes-per-team-quote?
(sm/lazy-validator schema:media-storage-bytes-per-team))
(def ^:private sql:get-media-storage-bytes-per-team
"SELECT COALESCE(SUM(so.size), 0) AS total
FROM (
SELECT fmo.media_id AS so_id
FROM file_media_object AS fmo
JOIN file AS f ON (f.id = fmo.file_id)
JOIN project AS p ON (p.id = f.project_id)
WHERE p.team_id = ?
AND fmo.deleted_at IS NULL
AND f.deleted_at IS NULL
UNION ALL
SELECT fmo.thumbnail_id AS so_id
FROM file_media_object AS fmo
JOIN file AS f ON (f.id = fmo.file_id)
JOIN project AS p ON (p.id = f.project_id)
WHERE p.team_id = ?
AND fmo.thumbnail_id IS NOT NULL
AND fmo.deleted_at IS NULL
AND f.deleted_at IS NULL
UNION ALL
SELECT v.otf_file_id AS so_id
FROM team_font_variant AS v
WHERE v.team_id = ?
AND v.otf_file_id IS NOT NULL
AND v.deleted_at IS NULL
UNION ALL
SELECT v.ttf_file_id AS so_id
FROM team_font_variant AS v
WHERE v.team_id = ?
AND v.ttf_file_id IS NOT NULL
AND v.deleted_at IS NULL
UNION ALL
SELECT v.woff1_file_id AS so_id
FROM team_font_variant AS v
WHERE v.team_id = ?
AND v.woff1_file_id IS NOT NULL
AND v.deleted_at IS NULL
UNION ALL
SELECT v.woff2_file_id AS so_id
FROM team_font_variant AS v
WHERE v.team_id = ?
AND v.woff2_file_id IS NOT NULL
AND v.deleted_at IS NULL
) AS refs
JOIN storage_object AS so ON (so.id = refs.so_id)
WHERE so.deleted_at IS NULL")
(defmethod check-quote ::media-storage-bytes-per-team
[{:keys [::profile-id ::team-id ::target] :as quote}]
(assert (valid-media-storage-bytes-per-team-quote? quote) "invalid quote parameters")
(-> quote
(assoc ::default (cf/get :quotes-media-storage-bytes-per-team
(* 20 1024 1024 1024)))
(assoc ::quote-sql [sql:get-quotes-2 target team-id profile-id profile-id])
(assoc ::count-sql [sql:get-media-storage-bytes-per-team
team-id team-id team-id team-id team-id team-id])
(generic-check!)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; QUOTE: DEFAULT ;; QUOTE: DEFAULT
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

View File

@ -338,3 +338,96 @@
(check-ok! 4) (check-ok! 4)
(check-ko! 5)))) (check-ko! 5))))
(t/deftest media-storage-bytes-per-team-quote
(with-mocks [mock {:target 'app.config/get
:return (th/config-get-mock
{:quotes-media-storage-bytes-per-team 1000})}]
(let [profile-1 (th/create-profile* 1)
profile-2 (th/create-profile* 2)
team-id (:default-team-id profile-1)
data {::quotes/id ::quotes/media-storage-bytes-per-team
::quotes/profile-id (:id profile-1)
::quotes/team-id team-id
::quotes/incr 500}
check-ok! (fn [msg]
(quotes/check! th/*system* data)
(t/is (true? true) msg))
check-ko! (fn [msg]
(try
(quotes/check! th/*system* data)
(t/is false (str msg " — expected exception but none thrown"))
(catch Exception e
(let [ed (ex-data e)]
(t/is (= :restriction (:type ed)))
(t/is (= :max-quote-reached (:code ed)))
(t/is (= "media-storage-bytes-per-team" (:target ed)))))))]
;; Under default limit (1000) with incr=500 and no existing storage — ok
(check-ok! "first check under limit")
;; Insert a quote row for another profile on the same team — does not help
(th/db-insert! :usage-quote
{:profile-id (:id profile-2)
:target "media-storage-bytes-per-team"
:quote 100})
;; Insert a team+profile quote that is still too low
(th/db-insert! :usage-quote
{:team-id team-id
:profile-id (:id profile-2)
:target "media-storage-bytes-per-team"
:quote 200})
;; Insert a team-level quote (no profile) that is still too low
(th/db-insert! :usage-quote
{:team-id team-id
:target "media-storage-bytes-per-team"
:quote 400})
;; total=0, incr=500, best quote=400 → 0+500 > 400 → blocked
(check-ko! "blocked by team-level quote")
;; Insert a team+profile quote that allows it
(th/db-insert! :usage-quote
{:team-id team-id
:profile-id (:id profile-1)
:target "media-storage-bytes-per-team"
:quote 1000})
;; total=0, incr=500, best quote=1000 → 0+500 <= 1000 → ok
(check-ok! "allowed by team+profile quote"))))
(t/deftest media-upload-enforces-storage-quote
(with-mocks [mock {:target 'app.config/get
:return (th/config-get-mock
{:quotes-media-storage-bytes-per-team 100})}]
(let [prof (th/create-profile* 1)
proj (th/create-project* 1 {:profile-id (:id prof)
:team-id (:default-team-id prof)})
file (th/create-file* 1 {:profile-id (:id prof)
:project-id (:id proj)
:is-shared false})
mfile {:filename "sample.jpg"
:path (th/tempfile "backend_tests/test_files/sample.jpg")
:mtype "image/jpeg"
:size 312043}
params {::th/type :upload-file-media-object
::rpc/profile-id (:id prof)
:file-id (:id file)
:is-local true
:name "testfile"
:content mfile}
out (th/command! params)]
;; 312043 bytes > 100 byte limit → should be rejected
(t/is (not (th/success? out)))
(let [error (:error out)]
(t/is (= :restriction (th/ex-type error)))
(t/is (= :max-quote-reached (th/ex-code error)))
(t/is (= "media-storage-bytes-per-team" (:target (ex-data error))))))))