mirror of
https://github.com/penpot/penpot.git
synced 2026-08-07 05:18:36 +00:00
🐛 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:
parent
aac8a69951
commit
0064a1afb4
@ -198,6 +198,7 @@
|
||||
[:quotes-team-access-requests-per-requester {:optional true} ::sm/int]
|
||||
[:quotes-upload-sessions-per-profile {: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-max-age {:optional true} ::ct/duration]
|
||||
|
||||
@ -38,6 +38,12 @@
|
||||
|
||||
(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
|
||||
[:map {:title "upload-file-media-object"}
|
||||
[:id {:optional true} ::sm/uuid]
|
||||
@ -56,6 +62,12 @@
|
||||
(media/validate-media-type! 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}]
|
||||
;; We get the minimal file for proper checking if
|
||||
;; file is not already deleted
|
||||
|
||||
@ -546,6 +546,76 @@
|
||||
(assoc ::count-sql [sql:get-upload-sessions-per-profile profile-id])
|
||||
(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
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
@ -338,3 +338,96 @@
|
||||
|
||||
(check-ok! 4)
|
||||
(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))))))))
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user