mirror of
https://github.com/penpot/penpot.git
synced 2026-08-07 13:29:07 +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-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]
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|||||||
@ -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))))))))
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user