Compare commits

...
Author SHA1 Message Date
Andrey Antukh 05e7f2ec2e 🐛 Add cooldown to prevent duplicate invitation emails 2026-08-03 18:26:47 +02:00
Andrey Antukh 6da24aaf52 🐛 Normalize string inputs to prevent unfiltered echo
Add normalize-string helper in app.common.data that trims whitespace
and returns empty string for nil input. Apply to profile, team, and
project string fields (fullname, lang, theme, name) before storage.

AI-assisted-by: qwen3.7-plus
2026-08-03 18:26:47 +02:00
Andrey Antukh 5f0175a641 🐛 Add backend password validation with complexity rules and dictionary check
Enforce minimum 8-character password length, require at least 1 lowercase
letter, 1 uppercase letter, 1 digit, and 1 special character, and reject
common passwords using Passay library with bundled wordlist during
registration and password change flows.

AI-assisted-by: qwen3.7-plus
2026-08-03 18:26:47 +02:00
Andrey Antukh 41afcba297 🐛 Add permission checks to WebSocket subscription handlers
Check file and team read permissions before allowing WebSocket
subscriptions to prevent resource enumeration via presence
notifications.

AI-assisted-by: qwen3.7-plus
2026-08-03 18:26:47 +02:00
Andrey Antukh c4b24743ea 🐛 Normalize error response on duplicate file ID
Capture unique constraint violation in insert-file! and return
generic :not-found error instead of propagating raw PostgreSQL
exception, preventing file existence oracle.

AI-assisted-by: mimo-v2.5
2026-08-03 18:26:47 +02:00
Andrey Antukh 252683eac8 🐛 Sanitize SVG files on upload to prevent XSS
Add sanitize-svg function that removes dangerous elements and attributes:
- script tags
- foreignObject elements
- Event handler attributes (onload, onmouseover, etc.)
- javascript: URLs from href/xlink:href attributes

Apply sanitization in process-main-image before storing SVG files.

AI-assisted-by: mimo-v2.5
2026-08-03 18:26:47 +02:00
Andrey Antukh 66bf6768e0 🐛 Add bounding box dimension limit to prevent export DoS
Add max-export-dimension constant (100000 units) and validate in
calculate-dimensions. Reject exports when bounding box width, height,
or position exceeds the limit to prevent resource exhaustion in the
Chromium export pool.

AI-assisted-by: mimo-v2.5
2026-08-03 18:26:47 +02:00
Andrey Antukh 0655c4510a 🐛 Mock DNS resolution in SSRF tests for environments without public DNS
The validate-url-allows-public-{https,http} tests relied on real DNS
resolution of example.com, which fails in containers without public
DNS access. Mock resolve-host to return a known public IP, consistent
with the pattern used by other tests in the same file.

AI-assisted-by: mimo-v2.5
2026-08-03 18:26:47 +02:00
Andrey Antukh 0064a1afb4 🐛 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
2026-08-03 18:26:47 +02:00
Andrey Antukh aac8a69951 🐛 Require file read permissions for asset endpoints
Add authorization check to generic-handler in assets.clj so that
/assets/by-file-media-id/:id and its /thumbnail variant verify the
requesting profile has read access to the parent file. Return 404
(not 403) when access is denied to avoid confirming existence.

Also switch get-file-media-object from db/get to db/get* so that
non-existent media objects return nil instead of raising.

AI-assisted-by: qwen3.7-plus
2026-08-03 18:26:47 +02:00
Andrey Antukh c329defa5e 🐛 Escape markdown in Mattermost error notifications
Add escape-markdown to common/data.cljc that escapes Markdown
special characters (*, _, ~, `, [, ], >, #, @, etc.) by prefixing
them with backslash. Apply it to user-controlled fields (:hint,
:href) in the Mattermost error reporter before constructing the
notification message.

This is an internal-only feature not accessible to end users.

AI-assisted-by: mimo-v2.5
2026-08-03 18:26:47 +02:00
Andrey Antukh 7ccc52fbdf 🐛 Restrict webhook edit/delete to team members only
Remove the creator-id fallback from get-webhooks-permissions.
Previously, the webhook creator could always edit/delete their
webhook even after being removed from the team. Now can-edit
comes from team role only — removed users get :not-found.

Webhooks are NOT deleted on member removal; the team owns them
and team admins/owners manage them.

AI-assisted-by: mimo-v2.5
2026-08-03 18:26:47 +02:00
Andrey Antukh 2a0b75fe09 🐛 Validate content-type on management upload endpoints
Add media type validation to upload-tempfile and upload-org-logo
management endpoints. Both stored user-supplied mtype without
checking against an allowlist. Only image types and PDF are
permitted. Non-public bucket assets now also carry
Content-Disposition: attachment to prevent inline rendering.

AI-assisted-by: mimo-v2.5
2026-08-03 18:26:47 +02:00
Andrey Antukh 22b6b6df57 🐛 Add concurrency limit to import-binfile RPC handler
Apply climit with 4 global permits and 1 per-profile permit (queue 2)
to prevent connection pool exhaustion from concurrent imports. Each
import holds a DB connection for its entire duration with idle
transaction timeout disabled, so unbounded concurrency could exhaust
the pool (default 60 connections).

AI-assisted-by: mimo-v2.5
2026-08-03 18:26:47 +02:00
Andrey Antukh 1ed13d6d5e 🐛 Add configurable limits for ZIP entry count and object size in v3 import
Add binfile-import-max-zip-entries (default 500,000) and
binfile-import-max-object-size (default 100 MiB) config entries.
Both are configurable via PENPOT_BINFILE_IMPORT_MAX_ZIP_ENTRIES and
PENPOT_BINFILE_IMPORT_MAX_OBJECT_SIZE env vars.

Entry count is checked before processing begins. Per-object size is
checked after each storage object content is resolved.

AI-assisted-by: mimo-v2.5
2026-08-03 18:26:47 +02:00
Andrey Antukh cc68024d43 🐛 Add recursion depth limit to Fressian reader
Bound read depth at 128 levels to prevent StackOverflowError from
crafted deeply-nested payloads. All recursive read handlers go
through read-object!, so a single depth check covers all paths.

AI-assisted-by: mimo-v2.5
2026-08-03 18:26:47 +02:00
Andrey Antukh 5d4d5d0fac 🐛 Add max-object-size guard to read-obj! in v1 parser
Prevent unbounded memory allocation when a crafted binfile specifies
an excessively large object size. Apply the same 100 MiB limit that
read-stream! already enforces.

AI-assisted-by: mimo-v2.5
2026-08-03 18:26:47 +02:00
Andrey Antukh a91502f5e7 🐛 Validate library belongs to same team in link/unlink/sync handlers
Add check-library-team-ownership! helper that verifies both the file
and library share the same team before creating or modifying library
relations. This prevents cross-team library injection where a user
with edit permissions on files in different teams could link them
across team boundaries.

Applied to link-file-to-library, unlink-file-from-library, and
update-file-library-sync-status handlers.

AI-assisted-by: mimo-v2.5
2026-08-03 18:26:47 +02:00
Andrey Antukh 8806f92cd7 🐛 Validate font-id team ownership in create-font-variant
Prevent cross-team font injection by checking that when a font-id
already has variants, they belong to the same team. This closes a
BOLA gap where a user with team edit permissions could create a
font variant referencing a font-id from another team.

AI-assisted-by: mimo-v2.5-pro
2026-08-03 18:26:47 +02:00
Andrey Antukh 7807e34151 🐛 Scope assemble-chunks session lookup to profile-id
Prevent BOLA in chunked upload assembly by verifying session
ownership. The assemble-chunks function now requires a profile-id
parameter and scopes the upload_session lookup accordingly, matching
the pattern already used by upload-chunk.

All three callers (assemble-file-media-object, create-font-variant,
import-binfile) updated to pass the authenticated profile-id.

AI-assisted-by: mimo-v2.5-pro
2026-08-03 18:26:47 +02:00
Andrey Antukh 565b7ea878 🐛 Restrict webhook creation to team editors
Use team role check (check-edition-permissions!) for create-webhook
instead of the custom check that allowed any team member to create
webhooks via creator-id self-match override.

AI-assisted-by: mimo-2.5-pro
2026-08-03 18:26:47 +02:00
Andrey Antukh 461227fe16 🐛 Close import-binfile schema and remove file-id parameter
Add :closed true to schema:import-binfile to reject unknown keys.
Remove file-id from handler destructuring, config binding, and audit
props to prevent specifying a target file on import.

AI-assisted-by: mimo-2.5-pro
2026-08-03 18:26:47 +02:00
48 changed files with 1672 additions and 187 deletions

No files matched your search

+1
View File
@@ -48,6 +48,7 @@
buddy/buddy-hashers {:mvn/version "2.0.167"}
buddy/buddy-sign {:mvn/version "3.6.1-359"}
org.passay/passay {:mvn/version "1.6.6"}
com.github.ben-manes.caffeine/caffeine {:mvn/version "3.2.4"}
+223
View File
@@ -0,0 +1,223 @@
password
123456
12345678
1234
qwerty
12345
dragon
pussy
baseball
football
letmein
monkey
696969
abc123
mustang
michael
shadow
master
jennifer
111111
2000
jordan
superman
harley
1234567
fuckme
hunter
fuckyou
trustno1
ranger
buster
thomas
tigger
robert
soccer
fuck
batman
test
pass
killer
hockey
george
charlie
andrew
michelle
love
sunshine
jessica
asshole
6969
pepper
daniel
access
123456789
654321
joshua
maggie
starwars
silver
william
dallas
yankees
123123
ashley
666666
hello
amanda
orange
biteme
freedom
computer
sexy
thunder
nicole
ginger
heather
hammer
summer
corvette
taylor
fucker
austin
1111
merlin
matthew
121212
golfer
cheese
princess
martin
chelsea
patrick
richard
diamond
yellow
bigdog
secret
asdfgh
sparky
cowboy
camaro
anthony
matrix
falcon
iloveyou
bailey
guitar
jackson
purple
scooter
phoenix
aaaaaa
morgan
tigers
porsche
mickey
maverick
cookie
nascar
peanut
justin
131313
money
horny
samantha
panties
steelers
joseph
snoopy
boomer
whatever
iceman
smokey
gateway
dakota
cowboys
eagles
chicken
dick
black
zxcvbn
please
andrea
ferrari
knight
hardcore
compaq
coffee
booboo
bitch
bulldog
xxxxxx
welcome
james
player
ncc1701
wizard
sbpnb
december
hello123
admin
qwerty123
1q2w3e4r
1q2w3e4r5t
password1
password123
iloveu
letmein1
abc1234
qwertyuiop
asdfghjkl
zxcvbnm
1234567890
0987654321
11223344
admin123
root
toor
pass123
test123
guest
default
changeme
temp
temp123
passwd
passw0rd
p@ssword
p@ssw0rd
qwerty1
abc12345
123321
12345678910
asdf
asdfasdf
qwer
qwerqwer
zxcv
zxcvzxcv
!@#$%^&*
!@#$%
qazwsx
qazwsxedc
1qaz2wsx
1qazxsw2
pass1234
test1234
admin1234
letmein123
welcome1
welcome123
monkey123
dragon123
master123
shadow123
sunshine1
princess1
football1
baseball1
soccer1
hockey1
batman1
superman1
+8 -2
View File
@@ -38,5 +38,11 @@
:create-file-snapshot/global
{:permits 3}
:create-file-snapshot/by-profile
{:permits 1 :queue 2 :timeout 60000}}
:create-file-snapshot/by-profile
{:permits 1 :queue 2 :timeout 60000}
:import-binfile/global
{:permits 4}
:import-binfile/by-profile
{:permits 1 :queue 2}}
+62
View File
@@ -0,0 +1,62 @@
;; 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 app.auth.passwords
"Password strength validation using Passay library."
(:require
[app.common.exceptions :as ex]
[clojure.java.io :as io])
(:import
[org.passay CharacterCharacteristicsRule CharacterRule DictionaryRule EnglishCharacterData PasswordData]
[org.passay.dictionary ArrayWordList WordListDictionary]))
(defonce ^:private dictionary
(let [lines (line-seq (io/reader (io/resource "app/common-passwords.txt")))
words (into-array String (sort lines))
word-list (ArrayWordList. words)]
(WordListDictionary. word-list)))
(defonce ^:private dictionary-rule
(DictionaryRule. dictionary))
(defonce ^:private character-characteristics-rule
(doto (CharacterCharacteristicsRule.)
(.setRules [(CharacterRule. EnglishCharacterData/LowerCase 1)
(CharacterRule. EnglishCharacterData/UpperCase 1)
(CharacterRule. EnglishCharacterData/Digit 1)
(CharacterRule. EnglishCharacterData/Special 1)])
(.setNumberOfCharacteristics 4)))
(defn validate-password
"Validates password strength.
Returns nil if valid, or raises exception if invalid.
Checks:
- Minimum length of 8 characters
- At least 1 lowercase letter
- At least 1 uppercase letter
- At least 1 digit
- At least 1 special character
- Not in common password dictionary"
[password]
(when (< (count password) 8)
(ex/raise :type :validation
:code :weak-password
:hint "password must be at least 8 characters"))
(let [password-data (PasswordData. password)]
(let [char-result (.validate character-characteristics-rule password-data)]
(when-not (.isValid char-result)
(ex/raise :type :validation
:code :weak-password
:hint "password must contain at least 1 lowercase letter, 1 uppercase letter, 1 digit, and 1 special character"
:details (mapv #(.getErrorCode %) (.getDetails char-result)))))
(let [dict-result (.validate dictionary-rule password-data)]
(when-not (.isValid dict-result)
(ex/raise :type :validation
:code :weak-password
:hint "password is too common"
:details (mapv #(.getErrorCode %) (.getDetails dict-result)))))))
+11 -3
View File
@@ -748,9 +748,17 @@
(fmigr/upsert-migrations! conn file))
(let [file (encode-file cfg file)]
(db/insert! conn :file
(file->params file)
(assoc opts ::db/return-keys false))
(try
(db/insert! conn :file
(file->params file)
(assoc opts ::db/return-keys false))
(catch org.postgresql.util.PSQLException cause
(if (db/duplicate-key-error? cause)
(ex/raise :type :not-found
:code :object-not-found
:hint "file already exists"
:cause cause)
(throw cause))))
(->> (file->file-data-params file)
(fdata/upsert! cfg))
+4
View File
@@ -174,6 +174,10 @@
(assert-mark m :obj)
(let [size (read-long! input)]
(assert (pos? size) "incorrect header size found on reading header")
(when (> size bfc/max-object-size)
(ex/raise :type :validation
:code :max-file-size-reached
:hint (dm/str "unable to import object with size " size " bytes")))
(let [buff (byte-array size)]
(read-bytes! input buff)
(fres/decode buff)))))
+18
View File
@@ -856,6 +856,15 @@
:expected-size (:size object)
:found-size (sto/get-size content)))
(when-let [max (::bfc/import-max-object-size cfg)]
(when (> (sto/get-size content) max)
(ex/raise :type :validation
:code :max-file-size-reached
:hint (str "storage object exceeds maximum size: " (sto/get-size content))
:path path
:max max
:found (sto/get-size content))))
(when-let [hash (get object :hash)]
(when (not= hash (sto/get-hash content))
(ex/raise :type :validation
@@ -938,6 +947,15 @@
(let [manifest (-> (read-manifest input)
(validate-manifest))
entries (read-zip-entries input)
_ (when-let [max (::bfc/import-max-zip-entries cfg)]
(when (> (count entries) max)
(ex/raise :type :validation
:code :too-many-zip-entries
:hint (str "zip file has too many entries: " (count entries))
:max max
:found (count entries))))
cfg (-> cfg
(assoc ::entries entries)
(assoc ::manifest manifest)
+10 -1
View File
@@ -94,7 +94,11 @@
;; SSRF protection
:ssrf-allowed-hosts #{}
:ssrf-extra-blocked-cidrs #{}})
:ssrf-extra-blocked-cidrs #{}
;; Binfile import limits
:binfile-import-max-object-size (* 1024 1024 100) ;; 100 MiB
:binfile-import-max-zip-entries (* 500 1000)}) ;; 500,000
(def schema:config
(do #_sm/optional-keys
@@ -147,6 +151,10 @@
[:imagemagick-width-limit {:optional true} :string]
[:imagemagick-height-limit {:optional true} :string]
;; Binfile import limits (PENPOT_BINFILE_IMPORT_*)
[:binfile-import-max-object-size {:optional true} ::sm/int]
[:binfile-import-max-zip-entries {:optional true} ::sm/int]
[:deletion-delay {:optional true} ::ct/duration]
[:file-clean-delay {:optional true} ::ct/duration]
[:telemetry-enabled {:optional true} ::sm/boolean]
@@ -190,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]
+32 -16
View File
@@ -7,6 +7,7 @@
(ns app.http.assets
"Assets related handlers."
(:require
[app.binfile.common :as bfc]
[app.common.data :as d]
[app.common.exceptions :as ex]
[app.common.time :as ct]
@@ -42,18 +43,22 @@
(defn- get-file-media-object
[pool id]
(db/get pool :file-media-object {:id id} {::db/remove-deleted false}))
(db/get* pool :file-media-object {:id id} {::db/remove-deleted false}))
(defn- serve-object-from-s3
[{:keys [::sto/storage ::signature-max-age ::cache-max-age] :as cfg} obj]
(let [sig-max-age (or signature-max-age default-signature-max-age)
cch-max-age (or cache-max-age default-cache-max-age)
{:keys [host port] :as url} (sto/get-object-url storage obj {:max-age sig-max-age})]
{:keys [host port] :as url} (sto/get-object-url storage obj {:max-age sig-max-age})
bucket (-> obj meta :bucket)
headers (cond-> {"location" (str url)
"x-host" (cond-> host port (str ":" port))
"x-mtype" (-> obj meta :content-type)
"cache-control" (str "max-age=" (inst-ms cch-max-age))}
(not (contains? public-buckets bucket))
(assoc "content-disposition" "attachment"))]
{::yres/status 307
::yres/headers {"location" (str url)
"x-host" (cond-> host port (str ":" port))
"x-mtype" (-> obj meta :content-type)
"cache-control" (str "max-age=" (inst-ms cch-max-age))}}))
::yres/headers headers}))
(defn- serve-object-from-fs
[{:keys [::path ::cache-max-age]} obj]
@@ -61,9 +66,12 @@
purl (u/join (u/uri path)
(sto/object->relative-path obj))
mdata (meta obj)
headers {"x-accel-redirect" (:path purl)
"content-type" (:content-type mdata)
"cache-control" (str "max-age=" (inst-ms cch-max-age))}]
bucket (:bucket mdata)
headers (cond-> {"x-accel-redirect" (:path purl)
"content-type" (:content-type mdata)
"cache-control" (str "max-age=" (inst-ms cch-max-age))}
(not (contains? public-buckets bucket))
(assoc "content-disposition" "attachment"))]
{::yres/status 204
::yres/headers headers}))
@@ -109,13 +117,21 @@
(defn- generic-handler
"A generic handler helper/common code for file-media based handlers."
[{:keys [::sto/storage] :as cfg} request kf]
(let [pool (::db/pool storage)
id (get-id request)
mobj (get-file-media-object pool id)
sobj (sto/get-object storage (kf mobj))]
(if sobj
(serve-object cfg sobj)
{::yres/status 404})))
(let [pool (::db/pool storage)
id (get-id request)
mobj (get-file-media-object pool id)]
(if (nil? mobj)
{::yres/status 404}
(let [file-id (:file-id mobj)
profile-id (or (::session/profile-id request)
(::actoken/profile-id request))
perms (bfc/get-file-permissions pool profile-id file-id)]
(if-not (:can-read perms)
{::yres/status 404}
(let [sobj (sto/get-object storage (kf mobj))]
(if sobj
(serve-object cfg sobj)
{::yres/status 404})))))))
(defn file-objects-handler
"Handler that serves storage objects by file media id."
+8 -2
View File
@@ -7,6 +7,7 @@
(ns app.http.websocket
"A penpot notification service for file cooperative edition."
(:require
[app.binfile.common :as bfc]
[app.common.exceptions :as ex]
[app.common.logging :as l]
[app.common.pprint :as pp]
@@ -17,6 +18,8 @@
[app.http.session :as session]
[app.metrics :as mtx]
[app.msgbus :as mbus]
[app.rpc.commands.files :as files]
[app.rpc.commands.teams :as teams]
[app.util.websocket :as ws]
[integrant.core :as ig]
[promesa.exec.csp :as sp]
@@ -131,8 +134,9 @@
(mbus/pub! msgbus :topic topic :message msg))))
(defmethod handle-message :subscribe-team
[{:keys [::mbus/msgbus]} {:keys [::ws/id ::ws/state ::ws/output-ch ::session-id]} {:keys [team-id] :as params}]
[{:keys [::mbus/msgbus ::db/pool]} {:keys [::ws/id ::ws/state ::ws/output-ch ::session-id ::profile-id]} {:keys [team-id] :as params}]
(l/trace :fn "handle-message" :event "subscribe-team" :team-id team-id :conn-id id)
(teams/check-read-permissions! pool profile-id team-id)
(let [prev-subs (get @state ::team-subscription)
channel (sp/chan :buf (sp/dropping-buffer 64)
:xf (remove #(= (:session-id %) session-id)))]
@@ -150,8 +154,10 @@
(defmethod handle-message :subscribe-file
[{:keys [::mbus/msgbus]} {:keys [::ws/id ::ws/state ::ws/output-ch ::session-id ::profile-id]} {:keys [file-id] :as params}]
[{:keys [::mbus/msgbus ::db/pool]} {:keys [::ws/id ::ws/state ::ws/output-ch ::session-id ::profile-id]} {:keys [file-id] :as params}]
(l/trace :fn "handle-message" :event "subscribe-file" :file-id file-id :conn-id id)
(bfc/check-file-exists pool file-id)
(files/check-read-permissions! pool profile-id file-id)
(let [psub (::file-subscription @state)
fch (sp/chan :buf (sp/dropping-buffer 64)
:xf (remove #(= (:session-id %) session-id)))]
+3 -2
View File
@@ -7,6 +7,7 @@
(ns app.loggers.mattermost
"A mattermost integration for error reporting."
(:require
[app.common.data :as d]
[app.common.exceptions :as ex]
[app.common.logging :as l]
[app.common.pprint :as pp]
@@ -25,7 +26,7 @@
(defn- send-mattermost-notification!
[cfg {:keys [id] :as report}]
(let [type (get report :type)
text (str "#" type " | " (get report :hint) "\n"
text (str "#" type " | " (d/escape-markdown (get report :hint)) "\n"
(when id
(str (u/join (cf/get :public-uri) "/dbg/error/" id) " "))
@@ -38,7 +39,7 @@
"- tenant: #" (:tenant report) "\n"
"- origin: #" (:origin report) "\n"
(when-let [href (get report :href)]
(str "- href: `" href "`\n"))
(str "- href: `" (d/escape-markdown href) "`\n"))
(when-let [version (get report :frontend-version)]
(str "- frontend-version: `" version "`\n"))
(when-let [version (get report :backend-version)]
+41
View File
@@ -124,6 +124,47 @@
(dm/with-open [istream (IOUtils/toInputStream ^String text "UTF-8")]
(xml/parse istream secure-parser-factory))))
(defn- sanitize-svg-element
"Recursively sanitize an SVG element by removing dangerous tags and attributes."
[{:keys [tag attrs content] :as element}]
(when (and (map? element) tag)
(let [dangerous-tags #{:script :foreignObject}
dangerous-attrs-pattern #"(?i)^(on\w+|xmlns:.*)$"
javascript-href-pattern #"(?i)^javascript:"]
(when-not (contains? dangerous-tags tag)
(let [clean-attrs (->> attrs
(remove (fn [[k v]]
(or (re-matches dangerous-attrs-pattern (name k))
(and (#{:href :xlink:href} k)
(string? v)
(re-find javascript-href-pattern v)))))
(into {}))
clean-content (when content
(->> content
(filter #(or (string? %) (map? %)))
(map (fn [child]
(if (map? child)
(sanitize-svg-element child)
child)))
(filter some?)
vec))]
(cond-> {:tag tag :attrs clean-attrs}
(seq clean-content) (assoc :content clean-content)))))))
(defn sanitize-svg
"Sanitize SVG content by removing dangerous elements and attributes.
Removes <script> tags, <foreignObject> elements, event handlers (on*),
and javascript: URLs from href attributes."
[svg-text]
(try
(let [parsed (parse-svg svg-text)
sanitized (sanitize-svg-element parsed)]
(if sanitized
(with-out-str (xml/emit sanitized))
svg-text))
(catch Exception _
svg-text)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; IMAGE THUMBNAILS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+10 -2
View File
@@ -8,6 +8,7 @@
(:require
[app.auth :as auth]
[app.auth.oidc :as oidc]
[app.auth.passwords :as passwords]
[app.common.data :as d]
[app.common.exceptions :as ex]
[app.common.features :as cfeat]
@@ -240,6 +241,9 @@
:code :email-as-password
:hint "you can't use your email as password"))
;; Validate password strength against common password dictionary
(passwords/validate-password (:password params))
(when (eml/has-bounce-reports? cfg (:email params))
(ex/raise :type :restriction
:code :email-has-permanent-bounces
@@ -258,7 +262,8 @@
(validate-register-attempt! cfg params)
(let [email (profile/clean-email email)
profile (profile/get-profile-by-email pool email)]
profile (profile/get-profile-by-email pool email)
fullname (d/normalize-string fullname)]
;; SECURITY: refuse to issue a prepared-register token when an active
;; profile already exists for this email.
@@ -359,6 +364,9 @@
is-active (:is-active params false)
theme (:theme params nil)
email (str/lower email)
fullname (d/normalize-string (:fullname params))
locale (d/normalize-string locale)
theme (d/normalize-string theme)
photo-id (some->> (or (:oidc/picture props)
(:google/picture props)
@@ -367,7 +375,7 @@
(import-profile-picture cfg))
params {:id id
:fullname (:fullname params)
:fullname fullname
:email email
:auth-backend backend
:lang locale
+13 -19
View File
@@ -21,6 +21,7 @@
[app.loggers.webhooks :as-alias webhooks]
[app.media :as media]
[app.rpc :as-alias rpc]
[app.rpc.climit :as-alias climit]
[app.rpc.commands.files :as files]
[app.rpc.commands.media :as media-cmd]
[app.rpc.commands.projects :as projects]
@@ -92,7 +93,9 @@
(assoc ::bfc/features (cfeat/get-team-enabled-features cf/flags team))
(assoc ::bfc/project-id project-id)
(assoc ::bfc/profile-id profile-id)
(assoc ::bfc/name name))
(assoc ::bfc/name name)
(assoc ::bfc/import-max-object-size (cf/get :binfile-import-max-object-size))
(assoc ::bfc/import-max-zip-entries (cf/get :binfile-import-max-zip-entries)))
input-path (:path file)
owned? (some? upload-id)
@@ -118,11 +121,10 @@
(def ^:private schema:import-binfile
[:and
[:map {:title "import-binfile"}
[:map {:title "import-binfile" :closed true}
[:name [:or [:string {:max 250}]
[:map-of ::sm/uuid [:string {:max 250}]]]]
[:project-id ::sm/uuid]
[:file-id {:optional true} ::sm/uuid]
[:version {:optional true} ::sm/int]
[:file {:optional true} media/schema:upload]
[:upload-id {:optional true} ::sm/uuid]]
@@ -131,38 +133,31 @@
(or (some? file) (some? upload-id)))]])
(sv/defmethod ::import-binfile
"Import a penpot file in a binary format. If `file-id` is provided,
an in-place import will be performed instead of creating a new file.
The in-place imports are only supported for binfile-v3 and when a
.penpot file only contains one penpot file.
"Import a penpot file in a binary format.
The file content may be provided either as a multipart `file` upload
or as an `upload-id` referencing a completed chunked-upload session,
which allows importing files larger than the multipart size limit.
"
{::doc/added "1.15"
::doc/changes ["1.20" "Add file-id param for in-place import"
"1.20" "Set default version to 3"
"2.15" "Add upload-id param for chunked upload support"]
::doc/changes [["1.20" "Set default version to 3"]
["2.15" "Add upload-id param for chunked upload support"]]
::webhooks/event? true
::sse/stream? true
::sm/params schema:import-binfile}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id project-id version file-id upload-id] :as params}]
::sm/params schema:import-binfile
::climit/id [[:import-binfile/by-profile ::rpc/profile-id]
[:import-binfile/global]]}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id project-id version upload-id] :as params}]
(projects/check-edition-permissions! pool profile-id project-id)
(let [version (or version 3)
params (-> params
(assoc :profile-id profile-id)
(assoc :version version))
cfg (cond-> cfg
(uuid? file-id)
(assoc ::bfc/file-id file-id))
params
(if (some? upload-id)
(let [file (db/tx-run! cfg media-cmd/assemble-chunks upload-id)]
(let [file (db/tx-run! cfg media-cmd/assemble-chunks profile-id upload-id)]
(assoc params :file file))
params)
@@ -174,6 +169,5 @@
(with-meta
(sse/response (partial import-binfile cfg params))
{::audit/props {:file nil
:file-id file-id
:generated-by (:generated-by manifest)
:referer (:referer manifest)}})))
+22
View File
@@ -1069,6 +1069,25 @@
[cfg {:keys [::rpc/profile-id] :as params}]
(db/tx-run! cfg delete-file (assoc params :profile-id profile-id)))
;; --- Library relation helpers
(defn- check-library-team-ownership!
"Verify that file and library belong to the same team.
Prevents cross-team library relation injection."
[conn file-id library-id]
(let [sql "SELECT EXISTS (
SELECT 1 FROM file AS f
JOIN project AS fp ON (fp.id = f.project_id)
JOIN file AS l ON (l.id = ?)
JOIN project AS lp ON (lp.id = l.project_id)
WHERE f.id = ? AND fp.team_id = lp.team_id
) AS ok"
row (db/exec-one! conn [sql library-id file-id])]
(when-not (:ok row)
(ex/raise :type :not-found
:code :object-not-found
:hint "file and library must belong to the same team"))))
;; --- MUTATION COMMAND: link-file-to-library
(def sql:link-file-to-library
@@ -1104,6 +1123,7 @@
(check-edition-permissions! conn profile-id file-id)
(check-edition-permissions! conn profile-id library-id)
(check-library-team-ownership! conn file-id library-id)
(let [transitive-deps (bfc/get-libraries cfg [library-id])]
(when (contains? transitive-deps file-id)
@@ -1135,6 +1155,7 @@
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id file-id library-id] :as params}]
(check-edition-permissions! conn profile-id file-id)
(check-edition-permissions! conn profile-id library-id)
(check-library-team-ownership! conn file-id library-id)
(unlink-file-from-library conn params)
nil)
@@ -1159,6 +1180,7 @@
[{:keys [::db/conn]} {:keys [::rpc/profile-id file-id library-id] :as params}]
(check-edition-permissions! conn profile-id file-id)
(check-edition-permissions! conn profile-id library-id)
(check-library-team-ownership! conn file-id library-id)
(update-sync conn params))
;; --- MUTATION COMMAND: ignore-sync
+16 -3
View File
@@ -95,6 +95,18 @@
(declare create-font-variant)
(defn- check-font-team-ownership!
"When font-id already has variants belonging to a different team,
raises :not-found to prevent cross-team font injection."
[conn team-id font-id]
(let [row (db/get* conn :team-font-variant
{:font-id font-id}
{::db/columns [:team-id]})]
(when (and row (not= (:team-id row) team-id))
(ex/raise :type :not-found
:code :object-not-found
:hint "font does not belong to this team"))))
(def ^:private schema:create-font-variant
[:and
[:map {:title "create-font-variant"}
@@ -113,10 +125,10 @@
"Assembles each chunked-upload session in `uploads` (a `{mtype →
session-id}` map) into a temp file, validates the media type and
size of every entry, and returns a `{mtype → path}` data map."
[cfg {:keys [uploads] :as params}]
[cfg {:keys [::rpc/profile-id uploads] :as params}]
(let [data (reduce-kv
(fn [acc mtype session-id]
(let [assembled (assemble-chunks cfg session-id)]
(let [assembled (assemble-chunks cfg profile-id session-id)]
(-> {:mtype mtype :size (:size assembled)}
(media/validate-media-type! cm/font-types)
(media/validate-font-size!))
@@ -168,8 +180,9 @@
[:process-font/global]]
::webhooks/event? true
::sm/params schema:create-font-variant}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id uploads] :as params}]
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id font-id uploads] :as params}]
(teams/check-edition-permissions! pool profile-id team-id)
(check-font-team-ownership! pool team-id font-id)
(quotes/check! cfg {::quotes/id ::quotes/font-variants-per-team
::quotes/profile-id profile-id
::quotes/team-id team-id})
+29 -6
View File
@@ -24,6 +24,7 @@
[app.storage :as sto]
[app.storage.tmp :as tmp]
[app.util.services :as sv]
[datoteka.fs :as fs]
[datoteka.io :as io])
(:import
java.io.OutputStream))
@@ -38,6 +39,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 +63,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
@@ -113,13 +126,22 @@
(defn- process-main-image
[info]
(let [hash (sto/calculate-hash (:path info))
data (-> (sto/content (:path info))
(let [path (:path info)
mtype (:mtype info)
path (if (= mtype "image/svg+xml")
(let [content (slurp path)
sanitized (media/sanitize-svg content)
temp-path (fs/create-tempfile :prefix "penpot-svg-" :suffix ".svg")]
(spit (str temp-path) sanitized)
temp-path)
path)
hash (sto/calculate-hash path)
data (-> (sto/content path)
(sto/wrap-with-hash hash))]
{::sto/content data
::sto/deduplicate? true
::sto/touched-at (:ts info)
:content-type (:mtype info)
:content-type mtype
:bucket "file-media-object"}))
(defn- process-thumb-image
@@ -391,9 +413,10 @@
Raises a :validation/:missing-chunks error when the number of stored
chunks does not match `:total-chunks` recorded in the session row.
Raises :not-found when the session does not belong to `profile-id`.
Deletes the session row from `upload_session` on success."
[{:keys [::db/conn] :as cfg} session-id]
(let [session (db/get conn :upload-session {:id session-id})
[{:keys [::db/conn] :as cfg} profile-id session-id]
(let [session (db/get conn :upload-session {:id session-id :profile-id profile-id})
chunks (get-upload-chunks conn session-id)]
(when (not= (count chunks) (:total-chunks session))
@@ -436,7 +459,7 @@
(db/tx-run! cfg
(fn [{:keys [::db/conn] :as cfg}]
(let [content (assemble-chunks cfg session-id)
(let [content (assemble-chunks cfg profile-id session-id)
content (-> content
(assoc :filename (str "upload:" name))
(assoc :mtype mtype)
+7
View File
@@ -7,6 +7,7 @@
(ns app.rpc.commands.profile
(:require
[app.auth :as auth]
[app.auth.passwords :as passwords]
[app.common.data :as d]
[app.common.exceptions :as ex]
[app.common.schema :as sm]
@@ -155,6 +156,9 @@
;; it or not for explicit locking and avoid concurrent updates of
;; the same row/object.
(let [profile (get-profile conn profile-id ::db/for-update true)
fullname (d/normalize-string fullname)
lang (d/normalize-string lang)
theme (d/normalize-string theme)
;; Update the profile map with direct params
profile (-> profile
(assoc :fullname fullname)
@@ -200,6 +204,9 @@
:code :email-as-password
:hint "you can't use your email as password"))
;; Validate password strength against common password dictionary
(passwords/validate-password (:password params))
(update-profile-password! cfg (assoc profile :password password))
(->> (rph/get-request params)
+3 -1
View File
@@ -6,6 +6,7 @@
(ns app.rpc.commands.projects
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.exceptions :as ex]
[app.common.schema :as sm]
@@ -259,7 +260,8 @@
::db/transaction true}
[{:keys [::db/conn]} {:keys [::rpc/profile-id id name] :as params}]
(check-edition-permissions! conn profile-id id)
(let [project (db/get-by-id conn :project id ::sql/for-update true)]
(let [project (db/get-by-id conn :project id ::sql/for-update true)
name (d/normalize-string name)]
(db/update! conn :project
{:name name}
{:id id})
+6 -3
View File
@@ -652,6 +652,7 @@
(let [id (or id (uuid/next))
is-default (if (boolean? is-default) is-default false)
features (db/create-array conn "text" features)
name (d/normalize-string name)
team (db/insert! conn :team
{:id id
:name name
@@ -688,6 +689,7 @@
[conn {:keys [id team-id name is-default created-at modified-at]}]
(let [id (or id (uuid/next))
is-default (if (boolean? is-default) is-default false)
name (d/normalize-string name)
params {:id id
:name name
:team-id team-id
@@ -718,9 +720,10 @@
::db/transaction true}
[{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id name]}]
(check-edition-permissions! conn profile-id id)
(db/update! conn :team
{:name name}
{:id id})
(let [name (d/normalize-string name)]
(db/update! conn :team
{:name name}
{:id id}))
nil)
@@ -46,10 +46,29 @@
(def sql:upsert-organization-invitation
"insert into team_invitation(id, team_id, org_id, email_to, created_by, role, valid_until)
values (?, null, ?, ?, ?, ?, ?)
on conflict(org_id, email_to) where team_id is null do
update set role = ?, valid_until = ?, updated_at = now()
returning *")
values (?, null, ?, ?, ?, ?, ?)
on conflict(org_id, email_to) where team_id is null do
update set role = ?, valid_until = ?, updated_at = now()
returning *")
(def ^:private sql:check-recent-invitation
"SELECT 1 FROM team_invitation
WHERE team_id = ? AND email_to = ?
AND updated_at > now() - interval '5 minutes'
LIMIT 1")
(def ^:private sql:check-recent-org-invitation
"SELECT 1 FROM team_invitation
WHERE org_id = ? AND email_to = ?
AND updated_at > now() - interval '5 minutes'
LIMIT 1")
(defn- recently-invited?
[{:keys [::db/conn]} team-id org-id email]
(let [query (if org-id
[sql:check-recent-org-invitation org-id email]
[sql:check-recent-invitation team-id email])]
(some? (db/exec-one! conn query))))
(defn- create-invitation-token
[cfg {:keys [profile-id valid-until organization-id organization-name team-id member-id member-email role]}]
@@ -185,35 +204,36 @@
(teams/check-email-bounce conn email true)
(teams/check-email-spam conn email true)
(let [id (uuid/next)
expire (if organization
(ct/in-future "876000h") ;; Organization invitations doesn't expire
(ct/in-future "168h")) ;; 7 days
invitation (db/exec-one! conn (if organization
[sql:upsert-organization-invitation id
(:id organization)
(str/lower email)
(:id profile)
(name role) expire
(name role) expire]
[sql:upsert-team-invitation id
(:id team)
(str/lower email)
(:id profile)
(name role) expire
(name role) expire]))
updated? (not= id (:id invitation))
profile-id (:id profile)
(let [id (uuid/next)
expire (if organization
(ct/in-future "876000h") ;; Organization invitations doesn't expire
(ct/in-future "168h")) ;; 7 days
recent? (recently-invited? cfg (:id team) (:id organization) email)
invitation (db/exec-one! conn (if organization
[sql:upsert-organization-invitation id
(:id organization)
(str/lower email)
(:id profile)
(name role) expire
(name role) expire]
[sql:upsert-team-invitation id
(:id team)
(str/lower email)
(:id profile)
(name role) expire
(name role) expire]))
updated? (not= id (:id invitation))
profile-id (:id profile)
team-organization-id (get-in team [:organization :id])
tprops {:profile-id profile-id
:invitation-id (:id invitation)
:valid-until expire
:team-id (:id team)
:organization-id (:id organization)
:organization-name (:name organization)
:member-email (:email-to invitation)
:member-id (:id member)
:role role}
tprops {:profile-id profile-id
:invitation-id (:id invitation)
:valid-until expire
:team-id (:id team)
:organization-id (:id organization)
:organization-name (:name organization)
:member-email (:email-to invitation)
:member-id (:id member)
:role role}
audit-props
(cond-> {:invitation-id (:id invitation)
:valid-until expire
@@ -234,8 +254,8 @@
(and team-organization-id
member
(contains? all-organization-member-ids (:id member))))))
itoken (create-invitation-token cfg tprops)
ptoken (create-profile-identity-token cfg profile-id)]
itoken (create-invitation-token cfg tprops)
ptoken (create-profile-identity-token cfg profile-id)]
(when (contains? cf/flags :log-invitation-tokens)
(l/info :hint "invitation token" :token itoken))
@@ -251,7 +271,8 @@
(assoc :props props))]
(audit/submit cfg event))
(when (allow-invitation-emails? member)
(when (and (allow-invitation-emails? member)
(not recent?))
(if organization
(when (contains? cf/flags :admin-console)
(eml/send! {::eml/conn conn
+5 -7
View File
@@ -23,11 +23,9 @@
[cuerdas.core :as str]))
(defn get-webhooks-permissions
[conn profile-id team-id creator-id]
[conn profile-id team-id]
(let [permissions (t/get-permissions conn profile-id team-id)
can-edit (boolean (or (:can-edit permissions)
(= profile-id creator-id)))]
can-edit (boolean (:can-edit permissions))]
(assoc permissions :can-edit can-edit)))
(def has-webhook-edit-permissions?
@@ -120,7 +118,7 @@
{::doc/added "1.17"
::sm/params schema:create-webhook}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id] :as params}]
(check-webhook-edition-permissions! pool profile-id team-id profile-id)
(t/check-edition-permissions! pool profile-id team-id)
(validate-quotes! cfg params)
(validate-webhook! cfg nil params)
(insert-webhook! cfg params))
@@ -137,7 +135,7 @@
::sm/params schema:update-webhook}
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id id] :as params}]
(let [whook (-> (db/get pool :webhook {:id id}) (decode-row))]
(check-webhook-edition-permissions! pool profile-id (:team-id whook) (:profile-id whook))
(check-webhook-edition-permissions! pool profile-id (:team-id whook))
(validate-webhook! cfg whook params)
(update-webhook! cfg whook params)))
@@ -151,7 +149,7 @@
::db/transaction true}
[{:keys [::db/conn]} {:keys [::rpc/profile-id id]}]
(let [whook (-> (db/get conn :webhook {:id id}) decode-row)]
(check-webhook-edition-permissions! conn profile-id (:team-id whook) (:profile-id whook))
(check-webhook-edition-permissions! conn profile-id (:team-id whook))
(db/delete! conn :webhook {:id id})
nil))
+4 -2
View File
@@ -6,11 +6,12 @@
(ns app.rpc.management.exporter
(:require
[app.common.media :as cm]
[app.common.schema :as sm]
[app.common.time :as ct]
[app.common.uri :as u]
[app.config :as cf]
[app.media :refer [schema:upload]]
[app.media :as media]
[app.rpc :as-alias rpc]
[app.rpc.doc :as doc]
[app.storage :as sto]
@@ -21,7 +22,7 @@
(def ^:private
schema:upload-tempfile-params
[:map {:title "upload-templfile-params"}
[:content schema:upload]])
[:content media/schema:upload]])
(def ^:private
schema:upload-tempfile-result
@@ -32,6 +33,7 @@
::sm/params schema:upload-tempfile-params
::sm/result schema:upload-tempfile-result}
[cfg {:keys [::rpc/profile-id content]}]
(media/validate-media-type! content cm/tempfile-types)
(let [storage (sto/resolve cfg)
hash (sto/calculate-hash (:path content))
data (-> (sto/content (:path content))
@@ -12,6 +12,7 @@
[app.auth.oidc :as oidc]
[app.common.data :as d]
[app.common.exceptions :as ex]
[app.common.media :as cm]
[app.common.schema :as sm]
[app.common.time :as ct]
[app.common.types.organization :as cto]
@@ -135,6 +136,7 @@
::sm/result schema:upload-organization-logo-result
::nitrate/sso false}
[{:keys [::sto/storage]} {:keys [content organization-id previous-id]}]
(media/validate-media-type! content cm/image-types)
(when previous-id
(sto/touch-object! storage previous-id))
(let [hash (sto/calculate-hash (:path content))
+70
View File
@@ -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
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+56 -1
View File
@@ -8,6 +8,7 @@
"Internal binfile test, no RPC involved"
(:require
[app.binfile.common :as bfc]
[app.binfile.v1 :as v1]
[app.binfile.v3 :as v3]
[app.common.features :as cfeat]
[app.common.pprint :as pp]
@@ -24,7 +25,10 @@
[clojure.test :as t]
[cuerdas.core :as str]
[datoteka.fs :as fs]
[datoteka.io :as io]))
[datoteka.io :as io])
(:import
java.io.ByteArrayInputStream
java.io.DataInputStream))
(t/use-fixtures :once th/state-init)
(t/use-fixtures :each th/database-reset)
@@ -105,3 +109,54 @@
(v3/import-files!))]
(t/is (= (count result) 1))
(t/is (every? uuid? result)))))
(t/deftest read-obj-rejects-oversized-buffer
;; N1-07: read-obj! must reject objects exceeding max-object-size
;; before attempting to allocate the buffer
(let [size (+ bfc/max-object-size 1)
baos (java.io.ByteArrayOutputStream. 17)
dos (java.io.DataOutputStream. baos)]
(.writeByte dos 5)
(.writeLong dos (long size))
(.flush dos)
(let [input (java.io.DataInputStream.
(ByteArrayInputStream. (.toByteArray baos)))]
(binding [v1/*position* (atom 0)]
(let [out (try
(v1/read-obj! input)
nil
(catch clojure.lang.ExceptionInfo e
(ex-data e)))]
;; Without the guard, read-obj! will either OOM or proceed
;; to read-bytes! on a truncated stream (no :max-file-size-reached).
;; With the guard, it raises :validation :max-file-size-reached.
(t/is (= :validation (:type out)))
(t/is (= :max-file-size-reached (:code out))))))))
(t/deftest import-rejects-too-many-zip-entries
;; N1-09: import must reject ZIP files exceeding max-zip-entries
(let [profile (th/create-profile* 1)
file (prepare-simple-file profile)
output (tmp/tempfile :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 output))
;; Import with max-zip-entries=1 — the exported ZIP has more entries
(let [cfg (-> th/*system*
(assoc ::bfc/project-id (:default-project-id profile))
(assoc ::bfc/profile-id (:id profile))
(assoc ::bfc/input output)
(assoc ::bfc/import-max-zip-entries 1))
out (try
(v3/import-files! cfg)
:no-error
(catch Throwable e
(let [d (or (ex-data e) (some-> (ex-cause e) ex-data))]
d)))]
(t/is (= :validation (:type out)))
(t/is (= :too-many-zip-entries (:code out))))))
+1 -1
View File
@@ -189,7 +189,7 @@
(let [params (merge {:id (mk-uuid "profile" i)
:fullname (str "Profile " i)
:email (str "profile" i ".test@nodomain.com")
:password "123123"
:password "Test123!"
:is-demo false}
params)]
(db/run! system
@@ -459,6 +459,135 @@
;; Tests: objects-handler — expired objects
;; ----------------------------------------------------------------
;; ----------------------------------------------------------------
;; Tests: file-objects-handler — authz required (T2-N1-01)
;; ----------------------------------------------------------------
(t/deftest file-objects-handler-unauthenticated-returns-404
;; Unauthenticated requests to file-media assets must return 404
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
cfg (make-handler-cfg storage)
profile (th/create-profile* 1)
team (th/create-team* 1 {:profile-id (:id profile)})
project (th/create-project* 1 {:profile-id (:id profile)
:team-id (:id team)})
file (th/create-file* 1 {:profile-id (:id profile)
:project-id (:id project)})
media-storage (create-storage-object! storage "file-media-object" "image data")
media-obj (th/create-file-media-object* {:file-id (:id file)
:media-id (:id media-storage)})
request {:path-params {:id (str (:id media-obj))}}
response (assets/file-objects-handler cfg request)]
(t/is (= 404 (::yres/status response)))))
(t/deftest file-objects-handler-no-file-perms-returns-404
;; Authenticated user without file read permissions must get 404
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
cfg (make-handler-cfg storage)
owner (th/create-profile* 1)
team (th/create-team* 1 {:profile-id (:id owner)})
project (th/create-project* 1 {:profile-id (:id owner)
:team-id (:id team)})
file (th/create-file* 1 {:profile-id (:id owner)
:project-id (:id project)})
media-storage (create-storage-object! storage "file-media-object" "image data")
media-obj (th/create-file-media-object* {:file-id (:id file)
:media-id (:id media-storage)})
stranger (th/create-profile* 2)
request {:path-params {:id (str (:id media-obj))}
::session/profile-id (:id stranger)}
response (assets/file-objects-handler cfg request)]
(t/is (= 404 (::yres/status response)))))
(t/deftest file-objects-handler-with-file-perms-succeeds
;; Authenticated user with file read permissions must get the object
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
cfg (make-handler-cfg storage)
owner (th/create-profile* 1)
team (th/create-team* 1 {:profile-id (:id owner)})
project (th/create-project* 1 {:profile-id (:id owner)
:team-id (:id team)})
file (th/create-file* 1 {:profile-id (:id owner)
:project-id (:id project)})
media-storage (create-storage-object! storage "file-media-object" "image data")
media-obj (th/create-file-media-object* {:file-id (:id file)
:media-id (:id media-storage)})
request {:path-params {:id (str (:id media-obj))}
::session/profile-id (:id owner)}
response (assets/file-objects-handler cfg request)]
(t/is (= 204 (::yres/status response)))))
(t/deftest file-thumbnails-handler-unauthenticated-returns-404
;; Unauthenticated requests to file-thumbnail assets must return 404
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
cfg (make-handler-cfg storage)
profile (th/create-profile* 1)
team (th/create-team* 1 {:profile-id (:id profile)})
project (th/create-project* 1 {:profile-id (:id profile)
:team-id (:id team)})
file (th/create-file* 1 {:profile-id (:id profile)
:project-id (:id project)})
media-storage (create-storage-object! storage "file-media-object" "image data")
media-obj (th/create-file-media-object* {:file-id (:id file)
:media-id (:id media-storage)})
request {:path-params {:id (str (:id media-obj))}}
response (assets/file-thumbnails-handler cfg request)]
(t/is (= 404 (::yres/status response)))))
(t/deftest file-thumbnails-handler-with-file-perms-succeeds
;; Authenticated user with file read permissions must get the thumbnail
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
cfg (make-handler-cfg storage)
owner (th/create-profile* 1)
team (th/create-team* 1 {:profile-id (:id owner)})
project (th/create-project* 1 {:profile-id (:id owner)
:team-id (:id team)})
file (th/create-file* 1 {:profile-id (:id owner)
:project-id (:id project)})
thumb-storage (create-storage-object! storage "file-object-thumbnail" "thumb data")
media-obj (th/create-file-media-object* {:file-id (:id file)
:media-id (:id thumb-storage)})
request {:path-params {:id (str (:id media-obj))}
::session/profile-id (:id owner)}
response (assets/file-thumbnails-handler cfg request)]
;; Falls back to media-id since no thumbnail-id, but still serves
(t/is (= 204 (::yres/status response)))))
(t/deftest file-objects-handler-non-existent-media-returns-404
;; Request for non-existent file-media-object returns 404
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
cfg (make-handler-cfg storage)
profile (th/create-profile* 1)
request {:path-params {:id (str (uuid/next))}
::session/profile-id (:id profile)}
response (assets/file-objects-handler cfg request)]
(t/is (= 404 (::yres/status response)))))
(t/deftest file-objects-handler-nil-profile-id-returns-404
;; When profile-id is nil (invalid session), must return 404
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
cfg (make-handler-cfg storage)
profile (th/create-profile* 1)
team (th/create-team* 1 {:profile-id (:id profile)})
project (th/create-project* 1 {:profile-id (:id profile)
:team-id (:id team)})
file (th/create-file* 1 {:profile-id (:id profile)
:project-id (:id project)})
media-storage (create-storage-object! storage "file-media-object" "image data")
media-obj (th/create-file-media-object* {:file-id (:id file)
:media-id (:id media-storage)})
request {:path-params {:id (str (:id media-obj))}
::session/profile-id nil}
response (assets/file-objects-handler cfg request)]
(t/is (= 404 (::yres/status response)))))
(t/deftest objects-handler-expired-object
;; Expired objects should return 404 (get-object filters them out).
(let [storage (-> (:app.storage/storage th/*system*)
+44
View File
@@ -55,6 +55,50 @@
(t/is (pos? (:width info)))
(t/is (pos? (:height info))))))
(t/deftest sanitize-svg-script-tag
(t/testing "sanitize-svg removes script tags"
(let [svg "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"100\" height=\"100\"><script>alert('xss')</script><rect width=\"50\" height=\"50\"/></svg>"
result (media/sanitize-svg svg)]
(t/is (not (clojure.string/includes? result "<script>")))
(t/is (not (clojure.string/includes? result "alert")))
(t/is (clojure.string/includes? result "<rect")))))
(t/deftest sanitize-svg-event-handlers
(t/testing "sanitize-svg removes event handler attributes"
(let [svg "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"100\" height=\"100\" onload=\"alert('xss')\"><rect width=\"50\" height=\"50\" onmouseover=\"alert('xss')\"/></svg>"
result (media/sanitize-svg svg)]
(t/is (not (clojure.string/includes? result "onload")))
(t/is (not (clojure.string/includes? result "onmouseover")))
(t/is (not (clojure.string/includes? result "alert")))
(t/is (clojure.string/includes? result "<rect")))))
(t/deftest sanitize-svg-javascript-href
(t/testing "sanitize-svg removes javascript: URLs from href attributes"
(let [svg "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"100\" height=\"100\"><a xlink:href=\"javascript:alert('xss')\"><rect width=\"50\" height=\"50\"/></a></svg>"
result (media/sanitize-svg svg)]
(t/is (not (clojure.string/includes? result "javascript:")))
(t/is (not (clojure.string/includes? result "alert")))
(t/is (clojure.string/includes? result "<a")))))
(t/deftest sanitize-svg-foreign-object
(t/testing "sanitize-svg removes foreignObject elements"
(let [svg "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"100\" height=\"100\"><foreignObject width=\"100\" height=\"100\"><body xmlns=\"http://www.w3.org/1999/xhtml\"><script>alert('xss')</script></body></foreignObject><rect width=\"50\" height=\"50\"/></svg>"
result (media/sanitize-svg svg)]
(t/is (not (clojure.string/includes? result "foreignObject")))
(t/is (not (clojure.string/includes? result "<script>")))
(t/is (clojure.string/includes? result "<rect")))))
(t/deftest sanitize-svg-clean-content
(t/testing "sanitize-svg preserves clean SVG content"
(let [svg "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"100\" height=\"100\"><rect width=\"50\" height=\"50\" fill=\"red\"/><circle cx=\"75\" cy=\"75\" r=\"20\" fill=\"blue\"/></svg>"
result (media/sanitize-svg svg)]
(t/is (clojure.string/includes? result "<rect"))
(t/is (clojure.string/includes? result "<circle"))
(t/is (or (clojure.string/includes? result "fill=\"red\"")
(clojure.string/includes? result "fill='red'")))
(t/is (or (clojure.string/includes? result "fill=\"blue\"")
(clojure.string/includes? result "fill='blue'"))))))
(t/deftest info-invalid-image
(t/testing "info on invalid image raises error"
(let [path (fs/create-tempfile :prefix "penpot-test-" :suffix ".jpg")]
@@ -0,0 +1,50 @@
;; 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.rpc-binfile-test
(:require
[app.common.schema :as sm]
[app.common.uuid :as uuid]
[app.rpc :as-alias rpc]
[app.rpc.climit :as-alias climit]
[app.rpc.commands.binfile :as binfile]
[backend-tests.helpers :as th]
[clojure.test :as t]
[datoteka.fs :as fs]))
(t/use-fixtures :once th/state-init)
(t/use-fixtures :each th/database-reset)
(t/deftest import-binfile-schema-rejects-file-id
;; N1-06: file-id parameter must be removed from schema for security
;; The schema should not accept file-id as a valid parameter
(let [schema @#'binfile/schema:import-binfile
validator (sm/lazy-validator schema)
;; Valid params without file-id
valid-params {:name "test"
:project-id (uuid/random)
:version 3
:upload-id (uuid/random)}
;; Params with file-id (should be rejected after fix)
params-with-file-id (assoc valid-params :file-id (uuid/random))]
;; Valid params without file-id should pass
(t/is (true? (validator valid-params))
"params without file-id should be valid")
;; Params with file-id should fail validation after fix
;; (Currently this will fail because file-id is still in schema)
(t/is (false? (validator params-with-file-id))
"params with file-id should be rejected")))
(t/deftest import-binfile-has-concurrency-limit
;; N1-10: import-binfile must have a concurrency limit to prevent
;; connection pool exhaustion from concurrent imports
(let [mdata (meta #'binfile/sm$import-binfile)]
(t/is (some? (::climit/id mdata))
"import-binfile must have ::climit/id metadata")))
+78 -2
View File
@@ -141,6 +141,31 @@
(let [result (:result out)]
(t/is (= 0 (count result))))))))
(t/deftest create-file-with-duplicate-id
(let [prof (th/create-profile* 1 {:is-active true})
proj-id (:default-project-id prof)
file-id (uuid/next)]
(t/testing "create file with specific id"
(let [data {::th/type :create-file
::rpc/profile-id (:id prof)
:project-id proj-id
:id file-id
:name "first-file"}
out (th/command! data)]
(t/is (nil? (:error out)))))
(t/testing "create file with duplicate id returns normalized error"
(let [data {::th/type :create-file
::rpc/profile-id (:id prof)
:project-id proj-id
:id file-id
:name "duplicate-file"}
out (th/command! data)
err (:error out)]
(t/is (th/ex-info? err))
(t/is (th/ex-of-type? err :not-found))))))
(t/deftest file-gc-with-fragments
(let [profile (th/create-profile* 1)
file (th/create-file* 1 {:profile-id (:id profile)
@@ -983,6 +1008,38 @@
(t/is (some? sync))
(t/is (some? (:synced-at sync)))))
(t/deftest link-file-to-library-rejects-cross-team
;; N1-08: A file in team2 must not be linked to a library in team1,
;; even when the user has edit permissions on both (BOLA / CWE-639).
(let [prof1 (th/create-profile* 1)
prof2 (th/create-profile* 2)
team1 (th/create-team* 1 {:profile-id (:id prof1)})
team2 (th/create-team* 2 {:profile-id (:id prof2)})
proj1 (th/create-project* 1 {:profile-id (:id prof1)
:team-id (:id team1)})
proj2 (th/create-project* 2 {:profile-id (:id prof2)
:team-id (:id team2)})
lib (th/create-file* 1 {:project-id (:id proj1)
:profile-id (:id prof1)
:is-shared true})
file2 (th/create-file* 2 {:project-id (:id proj2)
:profile-id (:id prof2)})]
;; Add prof2 as editor to team1 so they have edit access to the library
(th/db-insert! :team-profile-rel {:team-id (:id team1)
:profile-id (:id prof2)
:is-owner false
:is-admin false
:can-edit true})
;; prof2 tries to link file2 (team2) to lib (team1) — must fail
(let [data {::th/type :link-file-to-library
::rpc/profile-id (:id prof2)
:file-id (:id file2)
:library-id (:id lib)}
out (th/command! data)]
(t/is (some? (:error out))))))
(t/deftest update-file-library-sync-status-updates-sync-row
(let [profile (th/create-profile* 1)
file1 (th/create-file* 1 {:project-id (:default-project-id profile)
@@ -2320,8 +2377,6 @@
(let [edata (-> out :error ex-data)]
(t/is (= :not-found (:type edata))))))
;; --- Security Fix Tests ---
(t/deftest link-file-to-library-circular-reference
(let [profile (th/create-profile* 1)
file1 (th/create-file* 1 {:profile-id (:id profile)
@@ -2391,3 +2446,24 @@
(t/is (th/ex-info? (:error out)))
(let [edata (-> out :error ex-data)]
(t/is (= :validation (:type edata))))))
(t/deftest get-file-libraries-nonexistent-file
(let [prof (th/create-profile* 1 {:is-active true})
out (th/command! {::th/type :get-file-libraries
::rpc/profile-id (:id prof)
:file-id (uuid/random)})
err (:error out)]
(t/is (th/ex-info? err))
(t/is (th/ex-of-type? err :not-found))))
(t/deftest get-file-libraries-no-permission
(let [owner (th/create-profile* 1 {:is-active true})
other (th/create-profile* 2 {:is-active true})
file (th/create-file* 1 {:profile-id (:id owner)
:project-id (:default-project-id owner)})
out (th/command! {::th/type :get-file-libraries
::rpc/profile-id (:id other)
:file-id (:id file)})
err (:error out)]
(t/is (th/ex-info? err))
(t/is (th/ex-of-type? err :not-found))))
@@ -922,3 +922,64 @@
:name "Valid Font Name"}
out (th/command! params)]
(t/is (th/success? out))))))
(t/deftest create-font-variant-rejects-foreign-font-id
;; N2-07: A user with edit permissions on their own team must not be
;; able to create a font variant using a font-id that already belongs
;; to another team (BOLA / CWE-639).
(with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}]
(let [prof1 (th/create-profile* 1 {:is-active true})
prof2 (th/create-profile* 2 {:is-active true})
team1 (:default-team-id prof1)
team2 (:default-team-id prof2)
font-id (uuid/custom 10 999)
data (-> (io/resource "backend_tests/test_files/font-1.ttf")
(io/read*))]
;; prof1 creates a font variant in team1 with font-id
(let [params {::th/type :create-font-variant
::rpc/profile-id (:id prof1)
:team-id team1
:font-id font-id
:font-family "SharedFont"
:font-weight 400
:font-style "normal"
:data {"font/ttf" data}}
out (th/command! params)]
(t/is (nil? (:error out))))
;; prof2 tries to create a variant using the same font-id but
;; in team2 — must be rejected because font-id belongs to team1
(let [params {::th/type :create-font-variant
::rpc/profile-id (:id prof2)
:team-id team2
:font-id font-id
:font-family "SharedFont"
:font-weight 700
:font-style "normal"
:data {"font/ttf" data}}
out (th/command! params)]
(t/is (some? (:error out)))
(t/is (= :not-found (-> out :error ex-data :type)))
(t/is (= :object-not-found (-> out :error ex-data :code)))))))
(t/deftest get-font-variants-nonexistent-file
(let [prof (th/create-profile* 1 {:is-active true})
out (th/command! {::th/type :get-font-variants
::rpc/profile-id (:id prof)
:file-id (uuid/random)})
err (:error out)]
(t/is (th/ex-info? err))
(t/is (th/ex-of-type? err :not-found))))
(t/deftest get-font-variants-no-permission
(let [owner (th/create-profile* 1 {:is-active true})
other (th/create-profile* 2 {:is-active true})
file (th/create-file* 1 {:profile-id (:id owner)
:project-id (:default-project-id owner)})
out (th/command! {::th/type :get-font-variants
::rpc/profile-id (:id other)
:file-id (:id file)})
err (:error out)]
(t/is (th/ex-info? err))
(t/is (th/ex-of-type? err :not-found))))
@@ -57,6 +57,22 @@
(t/is (not= (get-in out1 [:result :id])
(get-in out2 [:result :id])))))
(t/deftest upload-tempfile-rejects-html-content-type
;; N2-13: upload-tempfile must reject non-allowed content types
(let [profile (th/create-profile* 1 {:is-active true})
path (fs/create-tempfile :dir "/tmp/penpot" :prefix "test-upload-tempfile-")
_ (io/write* path "<script>alert(1)</script>")
params {::th/type :upload-tempfile
::rpc/profile-id (:id profile)
:content {:filename "evil.html"
:path path
:mtype "text/html"
:size 27}}
out (th/management-command! params)]
(t/is (some? (:error out)))
(t/is (= :validation (th/ex-type (:error out))))
(t/is (= :media-type-not-allowed (th/ex-code (:error out))))))
(t/deftest duplicate-file
(let [storage (-> (:app.storage/storage th/*system*)
(configure-storage-backend))
@@ -548,6 +548,41 @@
(t/is (some? (:error out)))
(t/is (= :not-found (-> out :error ex-data :type)))))
(t/deftest chunked-upload-other-profile-cannot-assemble
;; assemble-chunks must scope the session lookup to the requesting
;; profile so that a different profile cannot assemble chunks from
;; a session they do not own (BOLA / CWE-639).
(let [prof1 (th/create-profile* 1)
prof2 (th/create-profile* 2)
session-id (create-session! prof1 1)
source-path (th/tempfile "backend_tests/test_files/sample.jpg")
mfile {:filename "sample.jpg"
:path source-path
:mtype "image/jpeg"
:size 312043}]
;; prof1 uploads a chunk into their own session
(let [out (th/command! {::th/type :upload-chunk
::rpc/profile-id (:id prof1)
:session-id session-id
:index 0
:content mfile})]
(t/is (nil? (:error out))))
;; prof2 tries to assemble prof1's session via create-font-variant
;; (which calls assemble-chunks without ownership check)
(let [out (th/command! {::th/type :create-font-variant
::rpc/profile-id (:id prof2)
:team-id (:default-team-id prof2)
:font-id (uuid/next)
:font-family "TestFont"
:font-weight 400
:font-style "normal"
:uploads {"font/ttf" session-id}})]
(t/is (some? (:error out)))
(t/is (= :not-found (-> out :error ex-data :type)))
(t/is (= :object-not-found (-> out :error ex-data :code))))))
(t/deftest chunked-upload-invalid-media-type
(let [prof (th/create-profile* 1)
_ (th/create-project* 1 {:profile-id (:id prof)
+70 -24
View File
@@ -42,7 +42,7 @@
(let [profile (th/create-profile* 1)
data {::th/type :login-with-password
:email "profile1.test@nodomain.com"
:password "foobar"}
:password "Foobar12!"}
out (th/command! data)]
#_(th/print-result! out)
@@ -56,7 +56,7 @@
(let [profile (th/create-profile* 1)
data {::th/type :login-with-password
:email "profile1.test@nodomain.com"
:password "123123"}
:password "Test123!"}
out (th/command! data)]
;; (th/print-result! out)
(let [error (:error out)]
@@ -69,7 +69,7 @@
(let [profile (th/create-profile* 1 {:is-active true})
data {::th/type :login-with-password
:email "profile1.test@nodomain.com"
:password "123123"}
:password "Test123!"}
out (th/command! data)]
;; (th/print-result! out)
(t/is (nil? (:error out)))
@@ -403,7 +403,7 @@
(let [data {::th/type :prepare-register-profile
:email "user@example.com"
:fullname "foobar"
:password "foobar"
:password "Foobar12!"
:utm_campaign "utma"
:mtm_campaign "mtma"}
out (th/command! data)
@@ -444,7 +444,7 @@
(let [data {::th/type :prepare-register-profile
:email "hello@example.com"
:fullname "foobar"
:password "foobar"}
:password "Foobar12!"}
out (th/command! data)
token (get-in out [:result :token])]
(t/is (th/success? out))
@@ -463,7 +463,7 @@
(let [data {::th/type :prepare-register-profile
:email "hello@example.com"
:fullname "foobar"
:password "foobar"}
:password "Foobar12!"}
out (th/command! data)
token (get-in out [:result :token])]
(t/is (th/success? out))
@@ -498,7 +498,7 @@
(let [data {::th/type :prepare-register-profile
:email "hello@example.com"
:fullname "foobar"
:password "foobar"}
:password "Foobar12!"}
out (th/command! data)
token (get-in out [:result :token])]
(t/is (th/success? out))
@@ -521,7 +521,7 @@
(let [data {::th/type :prepare-register-profile
:email "hello@example.com"
:fullname "foobar"
:password "foobar"}
:password "Foobar12!"}
out (th/command! data)
token (get-in out [:result :token])]
(t/is (th/success? out))
@@ -547,7 +547,7 @@
(let [data {::th/type :prepare-register-profile
:email "hello@example.com"
:fullname "foobar"
:password "foobar"}
:password "Foobar12!"}
out (th/command! data)
token (get-in out [:result :token])]
(t/is (th/success? out))
@@ -576,7 +576,7 @@
(let [data {::th/type :prepare-register-profile
:email "hello@example.com"
:fullname "foobar"
:password "foobar"}
:password "Foobar12!"}
out (th/command! data)
token (get-in out [:result :token])]
(t/is (th/success? out))
@@ -614,7 +614,7 @@
:invitation-token itoken
:fullname "foobar"
:email "user@example.com"
:password "foobar"}
:password "Foobar12!"}
{prep-result :result prep-error :error} (th/command! prep-data)]
(t/is (nil? prep-error))
@@ -659,7 +659,7 @@
:invitation-token itoken
:fullname "foobar"
:email "user@example.com"
:password "foobar"}
:password "Foobar12!"}
{prep-result :result prep-error :error} (th/command! prep-data)]
(t/is (nil? prep-error))
@@ -692,7 +692,7 @@
:invitation-token itoken
:email "user@example.com"
:fullname "foobar"
:password "foobar"}
:password "Foobar12!"}
out (th/command! data)]
(t/is (not (th/success? out)))
@@ -712,7 +712,7 @@
:invitation-token itoken
:fullname "foobar"
:email "user@example.com"
:password "foobar"}
:password "Foobar12!"}
out (th/command! data)]
(t/is (not (th/success? out)))
@@ -733,7 +733,7 @@
:invitation-token itoken
:email "user@example.com"
:fullname "foobar"
:password "foobar"}
:password "Foobar12!"}
out (th/command! data)]
(t/is (not (th/success? out)))
@@ -754,7 +754,7 @@
:invitation-token itoken
:fullname "foobar"
:email "user@example.com"
:password "foobar"}
:password "Foobar12!"}
out (th/command! data)]
(t/is (not (th/success? out)))
@@ -767,7 +767,7 @@
(let [data {::th/type :prepare-register-profile
:fullname "foobar"
:email "user@example.com"
:password "foobar"}
:password "Foobar12!"}
out (th/command! data)]
(t/is (not (th/success? out)))
@@ -780,7 +780,7 @@
data {::th/type :prepare-register-profile
:fullname "foobar"
:email (:email profile)
:password "foobar"}
:password "Foobar12!"}
out (th/command! data)]
;; (th/print-result! out)
(t/is (th/success? out))
@@ -793,7 +793,7 @@
data {::th/type :prepare-register-profile
:fullname "foobar"
:email "user@example.com"
:password "foobar"}]
:password "Foobar12!"}]
(th/create-global-complaint-for pool {:type :bounce :email "user@example.com"})
@@ -808,7 +808,7 @@
data {::th/type :prepare-register-profile
:fullname "foobar"
:email "user@example.com"
:password "foobar"}]
:password "Foobar12!"}]
(th/create-global-complaint-for pool {:type :complaint :email "user@example.com"})
@@ -1131,8 +1131,8 @@
(let [profile (th/create-profile* 1)
data {::th/type :update-profile-password
::rpc/profile-id (:id profile)
:old-password "123123"
:password "foobarfoobar"}
:old-password "Test123!"
:password "Foobar12!"}
out (th/command! data)]
(t/is (nil? (:error out)))
(t/is (nil? (:result out)))))
@@ -1143,7 +1143,7 @@
data {::th/type :update-profile-password
::rpc/profile-id (:id profile)
:old-password "badpassword"
:password "foobarfoobar"}
:password "Foobar12!"}
{:keys [result error] :as out} (th/command! data)]
(t/is (th/ex-info? error))
(t/is (th/ex-of-type? error :validation))
@@ -1154,7 +1154,7 @@
(let [profile (th/create-profile* 1)
data {::th/type :update-profile-password
::rpc/profile-id (:id profile)
:old-password "123123"
:old-password "Test123!"
:password "profile1.test@nodomain.com"}
{:keys [result error] :as out} (th/command! data)]
(t/is (th/ex-info? error))
@@ -1202,3 +1202,49 @@
(t/is (true? (get-in props [:props :onboarding-viewed])))
(t/is (false? (get-in props [:props :newsletter-updates])))
(t/is (= :wasm (get-in props [:props :renderer]))))))
(t/deftest prepare-register-profile-password-too-short
(let [data {::th/type :prepare-register-profile
:email "user@example.com"
:fullname "foobar"
:password "123"}
out (th/command! data)]
(t/is (th/ex-info? (:error out)))
(t/is (th/ex-of-type? (:error out) :validation))
(t/is (th/ex-of-code? (:error out) :weak-password))))
(t/deftest prepare-register-profile-weak-password
(let [data {::th/type :prepare-register-profile
:email "user@example.com"
:fullname "foobar"
:password "password123"}
out (th/command! data)]
(t/is (th/ex-info? (:error out)))
(t/is (th/ex-of-type? (:error out) :validation))
(t/is (th/ex-of-code? (:error out) :weak-password))))
(t/deftest update-profile-password-too-short
(let [profile (th/create-profile* 1)
data {::th/type :update-profile-password
::rpc/profile-id (:id profile)
:old-password "Test123!"
:password "123"}
out (th/command! data)]
(t/is (th/ex-info? (:error out)))
(t/is (th/ex-of-type? (:error out) :validation))
(t/is (th/ex-of-code? (:error out) :weak-password))))
(t/deftest update-profile-password-weak-password
(let [profile (th/create-profile* 1)
data {::th/type :update-profile-password
::rpc/profile-id (:id profile)
:old-password "Test123!"
:password "qwerty"}
out (th/command! data)]
(t/is (th/ex-info? (:error out)))
(t/is (th/ex-of-type? (:error out) :validation))
(t/is (th/ex-of-code? (:error out) :weak-password))))
@@ -241,3 +241,24 @@
error-data (ex-data error)]
(t/is (th/ex-info? error))
(t/is (= (:type error-data) :not-found))))))
(t/deftest get-project-nonexistent
(let [prof (th/create-profile* 1 {:is-active true})
out (th/command! {::th/type :get-project
::rpc/profile-id (:id prof)
:id (uuid/random)})
err (:error out)]
(t/is (th/ex-info? err))
(t/is (th/ex-of-type? err :not-found))))
(t/deftest get-project-no-permission
(let [owner (th/create-profile* 1 {:is-active true})
other (th/create-profile* 2 {:is-active true})
proj (th/create-project* 1 {:profile-id (:id owner)
:team-id (:default-team-id owner)})
out (th/command! {::th/type :get-project
::rpc/profile-id (:id other)
:id (:id proj)})
err (:error out)]
(t/is (th/ex-info? err))
(t/is (th/ex-of-type? err :not-found))))
@@ -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))))))))
@@ -1015,6 +1015,46 @@
out (th/command! data)]
(t/is (th/success? out)))))
(t/deftest create-team-invitations-email-cooldown
(with-mocks [mock {:target 'app.email/send! :return nil}]
(let [profile1 (th/create-profile* 1 {:is-active true})
team (th/create-team* 1 {:profile-id (:id profile1)})
data {::th/type :create-team-invitations
::rpc/profile-id (:id profile1)
:team-id (:id team)
:role :editor
:emails ["cooldown-test@example.com"]}]
;; First invitation sends email
(let [out (th/command! data)]
(t/is (th/success? out))
(t/is (= 1 (:call-count @mock))))
;; Resending immediately should NOT send email (cooldown active)
(th/reset-mock! mock)
(let [out (th/command! data)]
(t/is (th/success? out))
(t/is (= 0 (:call-count @mock))))
;; Resending to a different email should send email
(th/reset-mock! mock)
(let [data (assoc data :emails ["different@example.com"])
out (th/command! data)]
(t/is (th/success? out))
(t/is (= 1 (:call-count @mock))))
;; After cooldown expires, resending should send email
(th/reset-mock! mock)
(th/db-update! :team-invitation
{:updated-at (ct/in-past "10m")}
{:team-id (:id team)
:email-to "cooldown-test@example.com"})
(let [data (assoc data :emails ["cooldown-test@example.com"])
out (th/command! data)]
(t/is (th/success? out))
(t/is (= 1 (:call-count @mock)))))))
(t/deftest update-team-with-invalid-name
(let [profile (th/create-profile* 1 {:is-active true})
team (th/create-team* 1 {:profile-id (:id profile)})]
+115 -45
View File
@@ -155,8 +155,7 @@
:return {:status 200}}]
(let [owner (th/create-profile* 1 {:is-active true})
viewer (th/create-profile* 2 {:is-active true})
team (th/create-team* 1 {:profile-id (:id owner)})
whook (volatile! nil)]
team (th/create-team* 1 {:profile-id (:id owner)})]
(th/create-team-role* {:team-id (:id team)
:profile-id (:id viewer)
:role :viewer})
@@ -164,52 +163,15 @@
(let [roles (th/db-query :team-profile-rel {:team-id (:id team)})]
(t/is (= 2 (count roles))))
(t/testing "viewer creates a webhook"
(t/testing "viewer cannot create a webhook (requires editor role)"
(let [viewers-webhook (create-webhook-params (:id viewer) (:id team))
out (th/command! viewers-webhook)]
(t/is (nil? (:error out)))
(t/is (= 1 (:call-count @http-mock)))
(let [result (:result out)]
(check-webhook-format result)
(t/is (= (:uri viewers-webhook) (:uri result)))
(t/is (= (:team-id viewers-webhook) (:team-id result)))
(t/is (= (::rpc/profile-id viewers-webhook) (:profile-id result)))
(t/is (= (:mtype viewers-webhook) (:mtype result)))
(vreset! whook result))))
(th/reset-mock! http-mock)
(t/testing "viewer updates it's own webhook (success)"
(let [params {::th/type :update-webhook
::rpc/profile-id (:id viewer)
:id (:id @whook)
:uri (:uri @whook)
:mtype "application/transit+json"
:is-active false}
out (th/command! params)
result (:result out)]
(t/is (nil? (:error out)))
(t/is (= 0 (:call-count @http-mock)))
(check-webhook-format result)
(t/is (= (:is-active params) (:is-active result)))
(t/is (= (:team-id @whook) (:team-id result)))
(t/is (= (:mtype params) (:mtype result)))
(vreset! whook result)))
(th/reset-mock! http-mock)
(t/testing "viewer deletes it's own webhook (success)"
(let [params {::th/type :delete-webhook
::rpc/profile-id (:id viewer)
:id (:id @whook)}
out (th/command! params)]
(t/is (= 0 (:call-count @http-mock)))
(t/is (nil? (:error out)))
(t/is (nil? (:result out)))
(let [rows (th/db-exec! ["select * from webhook"])]
(t/is (= 0 (count rows))))))
(let [error (:error out)
error-data (ex-data error)]
(t/is (th/ex-info? error))
(t/is (= (:type error-data) :not-found))
(t/is (= (:code error-data) :object-not-found)))))
(th/reset-mock! http-mock))))
@@ -268,6 +230,26 @@
(t/is (= (:type error-data) :not-found))
(t/is (= (:code error-data) :object-not-found)))))))
(t/deftest webhooks-viewer-cannot-create
(with-mocks [http-mock {:target 'app.http.client/req
:return {:status 200}}]
(let [owner (th/create-profile* 1 {:is-active true})
viewer (th/create-profile* 2 {:is-active true})
team (th/create-team* 1 {:profile-id (:id owner)})]
(th/create-team-role* {:team-id (:id team)
:profile-id (:id viewer)
:role :viewer})
(t/testing "viewer cannot create a webhook on the team"
(let [params (create-webhook-params (:id viewer) (:id team))
out (th/command! params)]
(t/is (= 0 (:call-count @http-mock)))
(let [error (:error out)
error-data (ex-data error)]
(t/is (th/ex-info? error))
(t/is (= (:type error-data) :not-found))
(t/is (= (:code error-data) :object-not-found))))))))
(t/deftest webhooks-quotes
(with-mocks [http-mock {:target 'app.http.client/req
:return {:status 200}}]
@@ -304,3 +286,91 @@
(t/is (th/ex-info? error))
(t/is (= (:type error-data) :restriction))
(t/is (= (:code error-data) :webhooks-quote-reached))))))
(t/deftest removed-user-cannot-edit-webhook
(with-mocks [http-mock {:target 'app.http.client/req
:return {:status 200}}]
(let [owner (th/create-profile* 1 {:is-active true})
editor (th/create-profile* 2 {:is-active true})
team (th/create-team* 1 {:profile-id (:id owner)})]
(th/create-team-role* {:team-id (:id team)
:profile-id (:id editor)
:role :editor})
(let [params {::th/type :create-webhook
::rpc/profile-id (:id editor)
:team-id (:id team)
:uri (u/uri "http://example.com")
:mtype "application/json"}
out (th/command! params)]
(t/is (nil? (:error out)))
(let [whook (:result out)]
(th/reset-mock! http-mock)
(t/testing "owner can edit editor's webhook (team owns it)"
(let [params {::th/type :update-webhook
::rpc/profile-id (:id owner)
:id (:id whook)
:uri (u/uri "http://example.com/updated")
:mtype "application/transit+json"
:is-active true}
out (th/command! params)]
(t/is (nil? (:error out)))
(t/is (= 1 (:call-count @http-mock)))))
(th/reset-mock! http-mock)
(t/testing "remove editor from team"
(let [params {::th/type :delete-team-member
::rpc/profile-id (:id owner)
:team-id (:id team)
:member-id (:id editor)}
out (th/command! params)]
(t/is (nil? (:error out)))))
(th/reset-mock! http-mock)
(t/testing "removed editor cannot update webhook"
(let [params {::th/type :update-webhook
::rpc/profile-id (:id editor)
:id (:id whook)
:uri (u/uri "http://example.com/evil")
:mtype "application/transit+json"
:is-active true}
out (th/command! params)]
(t/is (= 0 (:call-count @http-mock)))
(let [error (:error out)
error-data (ex-data error)]
(t/is (th/ex-info? error))
(t/is (= (:type error-data) :not-found))
(t/is (= (:code error-data) :object-not-found)))))
(th/reset-mock! http-mock)
(t/testing "removed editor cannot delete webhook"
(let [params {::th/type :delete-webhook
::rpc/profile-id (:id editor)
:id (:id whook)}
out (th/command! params)]
(t/is (= 0 (:call-count @http-mock)))
(let [error (:error out)
error-data (ex-data error)]
(t/is (th/ex-info? error))
(t/is (= (:type error-data) :not-found))
(t/is (= (:code error-data) :object-not-found)))))
(th/reset-mock! http-mock)
(t/testing "owner can still delete editor's webhook"
(let [params {::th/type :delete-webhook
::rpc/profile-id (:id owner)
:id (:id whook)}
out (th/command! params)]
(t/is (nil? (:error out)))
(t/is (nil? (:result out)))
(let [rows (th/db-exec! ["select * from webhook"])]
(t/is (= 0 (count rows)))))))))))
+17 -3
View File
@@ -13,11 +13,25 @@
[clojure.test :as t]))
(t/deftest validate-url-allows-public-https
(t/is (true? (ssrf/safe-url? "https://example.com/foo")))
(t/is (true? (ssrf/safe-url? "https://example.com:8080/path?q=1"))))
(let [original ssrf/resolve-host]
(with-redefs [ssrf/resolve-host
(fn [hostname]
(if (= hostname "example.com")
(into-array java.net.InetAddress
[(java.net.InetAddress/getByName "93.184.216.34")])
(original hostname)))]
(t/is (true? (ssrf/safe-url? "https://example.com/foo")))
(t/is (true? (ssrf/safe-url? "https://example.com:8080/path?q=1"))))))
(t/deftest validate-url-allows-public-http
(t/is (true? (ssrf/safe-url? "http://example.com/foo"))))
(let [original ssrf/resolve-host]
(with-redefs [ssrf/resolve-host
(fn [hostname]
(if (= hostname "example.com")
(into-array java.net.InetAddress
[(java.net.InetAddress/getByName "93.184.216.34")])
(original hostname)))]
(t/is (true? (ssrf/safe-url? "http://example.com/foo"))))))
(t/deftest validate-url-blocks-disallowed-schemes
(t/is (false? (ssrf/safe-url? "file:///etc/passwd")))
+18
View File
@@ -1173,6 +1173,15 @@
[key coll]
(sort-by key natural-compare coll))
(defn normalize-string
"Normalizes a string by trimming leading/trailing whitespace.
Returns empty string for nil input. Non-string input is returned unchanged."
[s]
(cond
(nil? s) ""
(string? s) (str/trim s)
:else s))
(defn sanitize-string [s]
(if s
(-> s
@@ -1183,6 +1192,15 @@
str/trim)
""))
(defn escape-markdown
"Escapes Markdown special characters by prefixing them with backslash.
Intended for user-controlled values embedded in Markdown messages
(e.g. Mattermost notifications)."
[s]
(if s
(str/replace (str s) #"([*_~`\[\]()>#+=\-|{}.!@\\])" "\\\\$1")
""))
(defn get-initials
"Returns up to two uppercase initials extracted from a string.
Non-letter prefixes in each token are ignored."
+12 -1
View File
@@ -31,6 +31,11 @@
([^String s, ^String encoding]
(.getBytes s encoding)))
;; --- DEPTH TRACKING
(def ^:dynamic *read-depth* 0)
(def ^:const max-read-depth 128)
;; --- LOW LEVEL FRESSIAN API
(defn write-object!
@@ -41,7 +46,13 @@
(defn read-object!
[^Reader r]
(.readObject r))
(when (>= *read-depth* max-read-depth)
(throw (ex-info "maximum Fressian read depth exceeded"
{:type :validation
:code :max-read-depth-reached
:hint "maximum Fressian read depth exceeded"})))
(binding [*read-depth* (inc *read-depth*)]
(.readObject r)))
(defn write-tag!
([^Writer w ^String n]
+3
View File
@@ -22,6 +22,9 @@
"image/gif"
"image/svg+xml"})
(def tempfile-types
(conj image-types "application/pdf"))
(defn format->extension
[format]
(case format
+37
View File
@@ -36,6 +36,43 @@
(t/is (= "" (d/get-initials nil)))
(t/is (= "" (d/get-initials "!!! ???"))))
(t/deftest escape-markdown-test
(t/is (= "hello" (d/escape-markdown "hello")))
(t/is (= "" (d/escape-markdown nil)))
(t/is (= "" (d/escape-markdown "")))
(t/is (= "\\*bold\\*" (d/escape-markdown "*bold*")))
(t/is (= "\\_italic\\_" (d/escape-markdown "_italic_")))
(t/is (= "\\~strikethrough\\~" (d/escape-markdown "~strikethrough~")))
(t/is (= "\\`code\\`" (d/escape-markdown "`code`")))
(t/is (= "\\[link\\]\\(http://evil\\.com\\)" (d/escape-markdown "[link](http://evil.com)")))
(t/is (= "\\> quote" (d/escape-markdown "> quote")))
(t/is (= "\\# heading" (d/escape-markdown "# heading")))
(t/is (= "\\@channel" (d/escape-markdown "@channel")))
(t/is (= "\\!bang" (d/escape-markdown "!bang")))
(t/is (= "normal\\-text" (d/escape-markdown "normal-text")))
(t/is (= "a\\+b\\=c" (d/escape-markdown "a+b=c")))
(t/is (= "pipe\\|separated" (d/escape-markdown "pipe|separated")))
(t/is (= "curly\\{\\}braces" (d/escape-markdown "curly{}braces")))
(t/is (= "backslash\\\\slash" (d/escape-markdown "backslash\\slash"))))
(t/deftest normalize-string-test
;; nil input returns empty string
(t/is (= "" (d/normalize-string nil)))
;; empty string returns empty string
(t/is (= "" (d/normalize-string "")))
;; leading whitespace is trimmed
(t/is (= "hello" (d/normalize-string " hello")))
;; trailing whitespace is trimmed
(t/is (= "hello" (d/normalize-string "hello ")))
;; both leading and trailing whitespace are trimmed
(t/is (= "hello" (d/normalize-string " hello ")))
;; internal whitespace is preserved
(t/is (= "hello world" (d/normalize-string " hello world ")))
;; non-string input is returned unchanged
(t/is (= 42 (d/normalize-string 42)))
(t/is (= :keyword (d/normalize-string :keyword)))
(t/is (= true (d/normalize-string true))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Ordered Data Structures
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+17 -1
View File
@@ -21,7 +21,8 @@
(:import
java.time.Instant
java.time.OffsetDateTime
java.time.ZoneOffset))
java.time.ZoneOffset
java.util.UUID))
;; ---------------------------------------------------------------------------
;; Helpers
@@ -524,3 +525,18 @@
(t/is (d/ordered-map? rt))
(t/is (= om rt))
(t/is (= (keys om) (keys rt)))))
(t/deftest decode-rejects-excessive-recursion-depth
;; N2-01: deeply nested structures must be rejected before stack overflow
(let [depth (+ fres/max-read-depth 50)
data (reduce (fn [acc _i] [acc])
:leaf
(range depth))
encoded (fres/encode data)]
(try
(fres/decode encoded)
(t/is false "expected exception for excessive recursion depth")
(catch clojure.lang.ExceptionInfo e
(let [d (ex-data e)]
(t/is (= :validation (:type d)))
(t/is (= :max-read-depth-reached (:code d))))))))
+15 -5
View File
@@ -15,6 +15,7 @@
["react-dom/server" :as rds]
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.exceptions :as ex]
[app.common.files.helpers :as cfh]
[app.common.geom.point :as gpt]
[app.common.geom.rect :as grc]
@@ -59,6 +60,7 @@
[rumext.v2 :as mf]))
(def ^:const viewbox-decimal-precision 3)
(def ^:const max-export-dimension 100000)
(def ^:private default-color clr/canvas)
(mf/defc background
@@ -82,12 +84,20 @@
(let [bounds
(->> root-objects
(map (partial gsb/get-object-bounds objects))
(grc/join-rects))]
(grc/join-rects))
bounds (-> bounds
(update :x mth/finite 0)
(update :y mth/finite 0)
(update :width mth/finite 100000)
(update :height mth/finite 100000))]
(when (or (> (:width bounds) max-export-dimension)
(> (:height bounds) max-export-dimension)
(> (+ (:x bounds) (:width bounds)) max-export-dimension)
(> (+ (:y bounds) (:height bounds)) max-export-dimension))
(ex/raise :type :validation
:code :export-area-too-large
:hint "export area exceeds maximum allowed dimensions"))
(-> bounds
(update :x mth/finite 0)
(update :y mth/finite 0)
(update :width mth/finite 100000)
(update :height mth/finite 100000)
(grc/update-rect :position)
(grc/fix-aspect-ratio aspect-ratio))))))
@@ -0,0 +1,78 @@
;; 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 frontend-tests.render-dimensions-test
(:require
[app.common.geom.rect :as grc]
[app.common.geom.shapes.bounds :as gsb]
[app.common.test-helpers.files :as cthf]
[app.common.test-helpers.ids-map :as cthi]
[app.common.test-helpers.shapes :as cths]
[app.common.types.shape :as cts]
[app.common.uuid :as uuid]
[app.main.render :as render]
[cljs.test :as t :include-macros true]))
(defn- make-objects
"Create a proper objects map with a root frame and the given shapes."
[& shapes]
(let [root-frame (cts/setup-shape {:id uuid/zero
:type :frame
:parent-id uuid/zero
:frame-id uuid/zero
:name "Root Frame"
:shapes (mapv :id shapes)})
objects {uuid/zero root-frame}]
(reduce (fn [objs shape]
(assoc objs (:id shape) (assoc shape :frame-id uuid/zero)))
objects
shapes)))
(t/deftest calculate-dimensions-normal-bounds
(t/testing "Normal bounding box should pass"
(let [shape1 (cts/setup-shape {:type :rect :x 100 :y 100 :width 200 :height 150})
shape2 (cts/setup-shape {:type :rect :x 400 :y 300 :width 100 :height 100})
objects (make-objects shape1 shape2)
result (render/calculate-dimensions objects nil)]
(t/is (some? result))
(t/is (<= (:width result) render/max-export-dimension))
(t/is (<= (:height result) render/max-export-dimension)))))
(t/deftest calculate-dimensions-extreme-width
(t/testing "Extreme width should throw export-area-too-large"
(let [shape (cts/setup-shape {:type :rect :x 0 :y 0 :width 200000 :height 100})
objects (make-objects shape)]
(t/is (thrown-with-msg?
js/Error
#"export area exceeds maximum allowed dimensions"
(render/calculate-dimensions objects nil))))))
(t/deftest calculate-dimensions-extreme-height
(t/testing "Extreme height should throw export-area-too-large"
(let [shape (cts/setup-shape {:type :rect :x 0 :y 0 :width 100 :height 200000})
objects (make-objects shape)]
(t/is (thrown-with-msg?
js/Error
#"export area exceeds maximum allowed dimensions"
(render/calculate-dimensions objects nil))))))
(t/deftest calculate-dimensions-extreme-position
(t/testing "Shape at extreme position should throw export-area-too-large"
(let [shape (cts/setup-shape {:type :rect :x 500000 :y 500000 :width 100 :height 100})
objects (make-objects shape)]
(t/is (thrown-with-msg?
js/Error
#"export area exceeds maximum allowed dimensions"
(render/calculate-dimensions objects nil))))))
(t/deftest calculate-dimensions-exactly-at-limit
(t/testing "Bounding box exactly at limit should pass"
(let [shape (cts/setup-shape {:type :rect :x 0 :y 0 :width render/max-export-dimension :height render/max-export-dimension})
objects (make-objects shape)
result (render/calculate-dimensions objects nil)]
(t/is (some? result))
(t/is (<= (:width result) render/max-export-dimension))
(t/is (<= (:height result) render/max-export-dimension)))))
+2
View File
@@ -51,6 +51,7 @@
[frontend-tests.plugins.tokens-test]
[frontend-tests.plugins.utils-test]
[frontend-tests.plugins.value-objects-test]
[frontend-tests.render-dimensions-test]
[frontend-tests.render-wasm.process-objects-test]
[frontend-tests.render-wasm.text-editor-caret-color-test]
[frontend-tests.svg-fills-test]
@@ -160,6 +161,7 @@
'frontend-tests.ui.gradient-handlers-test
'frontend-tests.ui.layout-container-multiple-test
'frontend-tests.ui.measures-menu-props-test
'frontend-tests.render-dimensions-test
'frontend-tests.text-editor-paste-guard-test
'frontend-tests.ui.settings-password-schema-test
'frontend-tests.ui.settings-shortcuts-test