mirror of
https://github.com/penpot/penpot.git
synced 2026-10-29 05:50:35 +00:00
Merge remote-tracking branch 'origin/staging' into develop
This commit is contained in:
commit
b633093336
@ -269,7 +269,7 @@ Nexus dispatches on `:type`: `"telemetry-events"` batches are coerced and insert
|
||||
|
||||
- **Anonymization happens in Penpot, before sending.** By the time events leave, times are day-truncated, IPs are already `0.0.0.0`, props/context are filtered (see above), and emails are gone (only derived `email-domain` survives). Nexus additionally forces the outcome: it prefixes any non-`telemetry:` source, sets `received-at = tracked-at` (already truncated), pins IP to `0.0.0.0`, and stamps the sending `instance-id` into every event context.
|
||||
- **No personal data is expected.** Profile ids are random UUIDs with no PII, so the telemetry consumer needs no login guard; grouping is by `instance`, not by person (see posthog-telemetry below).
|
||||
- **The endpoint always answers 200.** Failures are swallowed and only counted (`nexus_telemetry_errors_total`), so a bad batch never breaks the sending instance; successes are counted (`nexus_telemetry_events_total`, `nexus_telemetry_reports_total`). Old instances may send a fressian+zstd base64 blob instead of a plain vector; Nexus decodes both (50 MiB cap against decompression bombs).
|
||||
- **The endpoint always answers 200.** Failures are swallowed and only counted (`nexus_telemetry_errors_total`), so a bad batch never breaks the sending instance; successes are counted (`nexus_telemetry_events_total`, `nexus_telemetry_reports_total`). A body the parser cannot read never reaches the handler: it answers 400 and counts in `nexus_http_malformed_requests_total`. Old instances may send a fressian+zstd base64 blob instead of a plain vector; Nexus decodes both (50 MiB cap against decompression bombs).
|
||||
|
||||
## 12. Archive to Nexus (today) and to Nitrate (future)
|
||||
|
||||
|
||||
@ -52,7 +52,7 @@
|
||||
- Duration config values are parsed as ISO-8601 with the `PT` prefix, so a day is written in hours (`:jobs-retention` and `:jobs-user-ttl` default to a week, and an operator writes `168h`): `7d` does not parse, and the value fails validation at boot instead of being silently ignored.
|
||||
- `parse-flags` automatically adds `:disable-secure-session-cookies` when `public-uri` is plain HTTP and not localhost. This changes cookie defaults without an explicit env flag.
|
||||
- The backend sets Clojure `*assert*` globally from the `:backend-asserts` feature flag. Assertion-dependent checks can therefore differ by runtime flags.
|
||||
- Request body parsing is mostly POST-oriented and supports Transit JSON plus plain JSON. Plain JSON request keys are kebab-decoded before being merged into `:params`.
|
||||
- Request body parsing is mostly POST-oriented and supports Transit JSON plus plain JSON. Plain JSON request keys are kebab-decoded before being merged into `:params`. A body the parser cannot read is a client error, classified at the parse site and never by exception class (`clojure.data.json` signals its syntax errors with a bare `java.lang.Exception`): 400 `:validation`/`:malformed-json`, one `warn` line (`malformed request body`) carrying path, peer address and content-type instead of a stack trace, and `penpot_http_malformed_requests_total`. `RequestTooBigException` keeps its 413.
|
||||
- Response formatting negotiates with `Accept` or `_fmt=json`. Transit is the default for collection/boolean bodies; JSON encoding has special pointer-map handling.
|
||||
- Auth prefers the session cookie token before the `Authorization` header. Headers may be `Token` or `Bearer`. Only `kid=1`/`ver=1` tokens are decoded as session tokens; anything else is left unauthenticated (legacy v1 tokens were removed).
|
||||
- Shared-key auth requires `x-shared-key` as `<key-id> <key>` and stores the lowercased key id on the request. If no shared keys are configured it always rejects.
|
||||
@ -72,6 +72,7 @@
|
||||
- `app.metrics/run!` is safe by default: a recording failure never throws (a metrics bug must not change the behavior of the measured operation). The first failure per metric id logs at `warn`, later ones at `debug`. The `instance` precondition is a plain assert (the backend enables `:backend-asserts`), and the collector lookup sits outside the recording guard, so a missing instance fails hard even when asserts are disabled. `::mtx/metrics` is required by the storage, s3-backend, db-pool and jobs APIs; jobs helpers never silently skip a missing metrics instance. The resolver is `app.metrics/instance` and it raises, unlike `run!`.
|
||||
- `app.msgbus` (`pub!`, `sub!`, `purge!`) keeps taking the **instance** on purpose: msgbus is what produces it, and `app.rpc.notifications` already shows the caller passing a cfg. `app.jobs` resolves `::mbus/msgbus` from the cfg internally, and `insert-event` keeps its explicit pre-insert check so a profile job never loses its notification.
|
||||
- `app.db/after-commit` is the boundary for SQL-backed metrics: callbacks are drained by the commit of the transaction that owns the connection they were registered on, and discarded when it rolls back. Because nesting follows the connection, that transaction is the outermost one only for a real nested call.
|
||||
- `penpot_http_malformed_requests_total` (counter, no labels) counts requests whose body the parser could not read. `app.http.middleware/wrap-parse-request` emits it where it classifies the error, so the instance travels as a middleware argument (`[mw/parse-request (::mtx/metrics cfg)]`) and each service declares the definition with its own prefix (nexus: `nexus_http_malformed_requests_total`). The per-endpoint request counters of nexus do not count these requests: they sit inside the parse middleware, so the failure happens before them.
|
||||
|
||||
## Log-derived metrics
|
||||
|
||||
|
||||
@ -244,6 +244,7 @@
|
||||
[::mgmt/routes schema:routes]
|
||||
::session/manager
|
||||
::setup/props
|
||||
::mtx/metrics
|
||||
::db/pool])
|
||||
|
||||
(defmethod ig/assert-key ::router
|
||||
@ -260,7 +261,7 @@
|
||||
[mw/auth {:bearer (partial session/decode-token cfg)
|
||||
:cookie (partial session/decode-token cfg)
|
||||
:token (partial actoken/decode-token cfg)}]
|
||||
[mw/parse-request]
|
||||
[mw/parse-request (::mtx/metrics cfg)]
|
||||
[mw/errors errors/handle]
|
||||
[mw/restrict-methods]]}
|
||||
|
||||
|
||||
@ -122,7 +122,11 @@
|
||||
{::yres/status 400 ::yres/body data}))
|
||||
|
||||
:else
|
||||
{::yres/status 400 ::yres/body data})))
|
||||
(let [explain (ex/explain data)]
|
||||
{::yres/status 400
|
||||
::yres/body (-> data
|
||||
(dissoc ::sm/explain)
|
||||
(cond-> explain (assoc :explain explain)))}))))
|
||||
|
||||
(defmethod handle-error :assertion
|
||||
[error request parent-cause]
|
||||
|
||||
@ -14,7 +14,9 @@
|
||||
[app.config :as cf]
|
||||
[app.http :as-alias http]
|
||||
[app.http.errors :as errors]
|
||||
[app.metrics :as mtx]
|
||||
[app.tokens :as tokens]
|
||||
[app.util.inet :as inet]
|
||||
[app.util.pointer-map :as pmap]
|
||||
[cuerdas.core :as str]
|
||||
[yetti.adapter :as yt]
|
||||
@ -44,27 +46,81 @@
|
||||
(java.io.BufferedReader.
|
||||
(java.io.InputStreamReader. body))))
|
||||
|
||||
(def ^:private not-a-body
|
||||
"Marker returned by `read-body-params` when the `content-type` header names
|
||||
neither transit nor json; the request is left untouched.
|
||||
|
||||
A fresh object and not a keyword: a keyword is a legal value for a decoded
|
||||
body, so identity is the only safe comparison."
|
||||
(Object.))
|
||||
|
||||
(defn- raise-malformed-body
|
||||
"Signals a body that the parser could not read.
|
||||
|
||||
A failure while reading the body means the body does not match what the
|
||||
client declared, so it is a client error: the exception class says nothing
|
||||
here, because `clojure.data.json` signals its syntax errors with a bare
|
||||
`java.lang.Exception`."
|
||||
[request metrics content-type cause]
|
||||
;; A single line, without the stack trace: the trace of a parse failure
|
||||
;; hides the fields (path, peer address, content-type) that identify the
|
||||
;; client sending the broken body.
|
||||
(l/wrn :hint "malformed request body"
|
||||
:path (yreq/path request)
|
||||
:remote-addr (inet/parse-request request)
|
||||
:content-type content-type
|
||||
:error (ex/get-hint cause))
|
||||
|
||||
(mtx/run! metrics
|
||||
:id :http-malformed-requests-total
|
||||
:inc 1)
|
||||
|
||||
(ex/raise :type :validation
|
||||
:code :malformed-json
|
||||
:hint "invalid JSON in request body"
|
||||
:cause cause))
|
||||
|
||||
(defn- read-body-params
|
||||
"Reads the request body according to the `content-type` header.
|
||||
|
||||
`RequestTooBigException` keeps its own code (413) and a structured error
|
||||
raised from deeper (an `ex-info`) travels untouched; anything else is a
|
||||
malformed body."
|
||||
[request metrics content-type]
|
||||
(try
|
||||
(cond
|
||||
(str/starts-with? content-type "application/transit+json")
|
||||
(with-open [^InputStream is (yreq/body request)]
|
||||
(t/read! (t/reader is)))
|
||||
|
||||
(str/starts-with? content-type "application/json")
|
||||
(with-open [reader (get-reader request)]
|
||||
(json/read reader :key-fn json/read-kebab-key))
|
||||
|
||||
:else
|
||||
not-a-body)
|
||||
|
||||
(catch RequestTooBigException cause
|
||||
(ex/raise :type :validation
|
||||
:code :request-body-too-large
|
||||
:hint "request body exceeds size limit"
|
||||
:cause cause))
|
||||
|
||||
(catch Throwable cause
|
||||
(if (ex/error? cause)
|
||||
(throw cause)
|
||||
(raise-malformed-body request metrics content-type cause)))))
|
||||
|
||||
(defn wrap-parse-request
|
||||
[handler]
|
||||
[handler metrics]
|
||||
(letfn [(process-request [request]
|
||||
(let [header (yreq/get-header request "content-type")]
|
||||
(cond
|
||||
(str/starts-with? header "application/transit+json")
|
||||
(with-open [^InputStream is (yreq/body request)]
|
||||
(let [params (t/read! (t/reader is))]
|
||||
(-> request
|
||||
(assoc :body-params params)
|
||||
(update :params merge params))))
|
||||
|
||||
(str/starts-with? header "application/json")
|
||||
(with-open [reader (get-reader request)]
|
||||
(let [params (json/read reader :key-fn json/read-kebab-key)]
|
||||
(-> request
|
||||
(assoc :body-params params)
|
||||
(update :params merge params))))
|
||||
|
||||
:else
|
||||
request)))
|
||||
(let [header (yreq/get-header request "content-type")
|
||||
params (read-body-params request metrics header)]
|
||||
(if (identical? params not-a-body)
|
||||
request
|
||||
(-> request
|
||||
(assoc :body-params params)
|
||||
(update :params merge params)))))
|
||||
|
||||
;; The specific-exception branches below (IAE,
|
||||
;; RequestTooBigException, EOFException) raise with
|
||||
@ -107,10 +163,14 @@
|
||||
|
||||
(fn [request]
|
||||
(if (= (yreq/method request) :post)
|
||||
(try
|
||||
(-> request process-request handler)
|
||||
(catch Throwable cause
|
||||
(handle-error cause request)))
|
||||
;; The body is read outside the `try`: a failure there is always
|
||||
;; raised already classified by `read-body-params`, and goes out as a
|
||||
;; throw like the rest of the specific-exception branches above.
|
||||
(let [request (process-request request)]
|
||||
(try
|
||||
(handler request)
|
||||
(catch Throwable cause
|
||||
(handle-error cause request))))
|
||||
(handler request)))))
|
||||
|
||||
(def parse-request
|
||||
|
||||
@ -346,7 +346,12 @@
|
||||
::mdef/help "Time spent waiting for an ephemeral job reply (milliseconds). Its _count is the request total per outcome."
|
||||
::mdef/labels ["outcome"]
|
||||
::mdef/type :histogram
|
||||
::mdef/buckets [1 10 50 100 500 1000 5000 10000 30000 60000 300000]}})
|
||||
::mdef/buckets [1 10 50 100 500 1000 5000 10000 30000 60000 300000]}
|
||||
|
||||
:http-malformed-requests-total
|
||||
{::mdef/name "penpot_http_malformed_requests_total"
|
||||
::mdef/help "Total number of requests whose body the parser could not read."
|
||||
::mdef/type :counter}})
|
||||
|
||||
(def system-config
|
||||
{::db/pool
|
||||
@ -466,6 +471,7 @@
|
||||
::rpc/routes (ig/ref ::rpc/routes)
|
||||
::setup/props (ig/ref ::setup/props)
|
||||
::mtx/routes (ig/ref ::mtx/routes)
|
||||
::mtx/metrics (ig/ref ::mtx/metrics)
|
||||
::oidc/routes (ig/ref ::oidc/routes)
|
||||
::mgmt/routes (ig/ref ::mgmt/routes)
|
||||
::http.debug/routes (ig/ref ::http.debug/routes)
|
||||
|
||||
@ -126,7 +126,15 @@
|
||||
(-> (assoc ::auth-type :token)
|
||||
(assoc ::token-perms (set (::actoken/perms request #{})))))
|
||||
(cond-> key-id
|
||||
(assoc ::auth-key-id key-id)))
|
||||
(assoc ::auth-key-id key-id))
|
||||
;; Server-side only: request bodies cannot forge
|
||||
;; it (qualified keys are stripped before the
|
||||
;; merge). Upload maps are only trusted when they
|
||||
;; arrive over multipart.
|
||||
(assoc ::is-multipart
|
||||
(str/starts-with?
|
||||
(str (yreq/get-header request "content-type"))
|
||||
"multipart/")))
|
||||
|
||||
data (with-meta data
|
||||
{::http/request request})
|
||||
@ -227,10 +235,23 @@
|
||||
(fn [cfg params]
|
||||
(let [request-params (-> params meta ::http/request :params decode)]
|
||||
(if (validate request-params)
|
||||
(let [result (f cfg (merge params (d/without-qualified request-params)))]
|
||||
(if (instance? clojure.lang.IObj result)
|
||||
(vary-meta result assoc :encode/json encode)
|
||||
result))
|
||||
(let [merged (merge params (d/without-qualified request-params))]
|
||||
;; Upload temp files are created server-side by the multipart
|
||||
;; parser, so an upload map smuggled in a transit/JSON body can
|
||||
;; point :path at any internal server file. Methods declare
|
||||
;; their upload keys with ::multipart-only-params and they are
|
||||
;; only accepted when ::is-multipart arrived server-side with
|
||||
;; the request (request bodies cannot forge qualified keys).
|
||||
(when (some #(and (contains? merged %)
|
||||
(not (::is-multipart merged)))
|
||||
(::multipart-only-params mdata))
|
||||
(ex/raise :type :validation
|
||||
:code :params-validation
|
||||
:hint "upload requires multipart"))
|
||||
(let [result (f cfg merged)]
|
||||
(if (instance? clojure.lang.IObj result)
|
||||
(vary-meta result assoc :encode/json encode)
|
||||
result)))
|
||||
(ex/raise :type :validation
|
||||
:code :params-validation
|
||||
::sm/explain (explain request-params))))))
|
||||
|
||||
@ -153,6 +153,7 @@
|
||||
::webhooks/event? true
|
||||
::sse/stream? true
|
||||
::sm/params schema:import-binfile
|
||||
::rpc/multipart-only-params [:file]
|
||||
::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}]
|
||||
|
||||
@ -298,7 +298,8 @@
|
||||
::rtry/enabled true
|
||||
::rtry/when rtry/conflict-exception?
|
||||
::audit/skip true
|
||||
::sm/params schema:create-file-object-thumbnail}
|
||||
::sm/params schema:create-file-object-thumbnail
|
||||
::rpc/multipart-only-params [:media]}
|
||||
|
||||
[cfg {:keys [::rpc/profile-id file-id object-id media tag]}]
|
||||
(media.v/validate-media-type! media)
|
||||
@ -399,7 +400,8 @@
|
||||
[:file-thumbnail-ops/global]]
|
||||
::rtry/enabled true
|
||||
::rtry/when rtry/conflict-exception?
|
||||
::sm/params schema:create-file-thumbnail}
|
||||
::sm/params schema:create-file-thumbnail
|
||||
::rpc/multipart-only-params [:media]}
|
||||
|
||||
[cfg {:keys [::rpc/profile-id file-id] :as params}]
|
||||
(media.v/validate-media-type! (:media params))
|
||||
|
||||
@ -57,6 +57,7 @@
|
||||
{::doc/added "1.17"
|
||||
::doc/changes [["2.19" "The optional :id param is rejected with a params-validation error; the server always generates the identifier"]]
|
||||
::sm/params schema:upload-file-media-object
|
||||
::rpc/multipart-only-params [:content]
|
||||
::climit/id [[:process-image/by-profile ::rpc/profile-id]
|
||||
[:process-image/global]]}
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id content] :as params}]
|
||||
@ -371,6 +372,7 @@
|
||||
(sv/defmethod ::upload-chunk
|
||||
{::doc/added "2.17"
|
||||
::sm/params schema:upload-chunk
|
||||
::rpc/multipart-only-params [:content]
|
||||
::sm/result schema:upload-chunk-result}
|
||||
[{:keys [::db/pool] :as cfg}
|
||||
{:keys [::rpc/profile-id session-id index content] :as _params}]
|
||||
|
||||
@ -307,6 +307,7 @@
|
||||
(sv/defmethod ::update-profile-photo
|
||||
{:doc/added "1.1"
|
||||
::sm/params schema:update-profile-photo
|
||||
::rpc/multipart-only-params [:file]
|
||||
::sm/result :nil}
|
||||
[cfg {:keys [::rpc/profile-id file] :as params}]
|
||||
;; Validate incoming mime type
|
||||
|
||||
@ -1094,7 +1094,8 @@
|
||||
|
||||
(sv/defmethod ::update-team-photo
|
||||
{::doc/added "1.17"
|
||||
::sm/params schema:update-team-photo}
|
||||
::sm/params schema:update-team-photo
|
||||
::rpc/multipart-only-params [:file]}
|
||||
[cfg {:keys [::rpc/profile-id file] :as params}]
|
||||
;; Validate incoming mime type
|
||||
|
||||
|
||||
@ -31,6 +31,7 @@
|
||||
(sv/defmethod ::upload-tempfile
|
||||
{::doc/added "2.12"
|
||||
::sm/params schema:upload-tempfile-params
|
||||
::rpc/multipart-only-params [:content]
|
||||
::sm/result schema:upload-tempfile-result}
|
||||
[cfg {:keys [::rpc/profile-id content]}]
|
||||
(media.v/validate-media-type! content cm/tempfile-types)
|
||||
|
||||
@ -194,6 +194,7 @@
|
||||
collection when replacing an existing one."
|
||||
{::doc/added "2.18"
|
||||
::sm/params schema:upload-organization-logo
|
||||
::rpc/multipart-only-params [:content]
|
||||
::sm/result schema:upload-organization-logo-result
|
||||
::nitrate/sso false}
|
||||
[{:keys [::sto/storage]} {:keys [content organization-id previous-id]}]
|
||||
|
||||
@ -195,15 +195,15 @@
|
||||
:project-id (:default-project-id profile)
|
||||
:is-shared false})
|
||||
[svg-path svg-size] (write-temp-svg clean-svg)
|
||||
out (th/command! {::th/type :upload-file-media-object
|
||||
::rpc/profile-id (:id profile)
|
||||
:file-id (:id file)
|
||||
:is-local true
|
||||
:name "benign.svg"
|
||||
:content {:filename "benign.svg"
|
||||
:path svg-path
|
||||
:mtype "image/svg+xml"
|
||||
:size svg-size}})
|
||||
out (th/multipart-command! {::th/type :upload-file-media-object
|
||||
::rpc/profile-id (:id profile)
|
||||
:file-id (:id file)
|
||||
:is-local true
|
||||
:name "benign.svg"
|
||||
:content {:filename "benign.svg"
|
||||
:path svg-path
|
||||
:mtype "image/svg+xml"
|
||||
:size svg-size}})
|
||||
_ (t/is (nil? (:error out)) (str "upload failed: " (pr-str (:error out))))
|
||||
uploaded (:result out)
|
||||
page-id (first (get-in file [:data :pages]))
|
||||
|
||||
@ -512,6 +512,13 @@
|
||||
|
||||
(-> params
|
||||
(assoc :app.rpc/request-at (ct/now))
|
||||
;; Mirror of the production flag in `app.rpc/make-rpc-handler`:
|
||||
;; upload maps are only trusted over multipart. Tests declare it
|
||||
;; with a content-type header on the supplied request map.
|
||||
(assoc ::rpc/is-multipart
|
||||
(str/starts-with?
|
||||
(str (yreq/get-header request "content-type"))
|
||||
"multipart/"))
|
||||
(with-meta {:app.http/request request}))))
|
||||
|
||||
(defn command!
|
||||
@ -549,6 +556,33 @@
|
||||
(let [params (prepare-rpc-params data)]
|
||||
(try-on! (method-fn params)))))
|
||||
|
||||
(def multipart-headers
|
||||
{"content-type" "multipart/form-data; boundary=test"})
|
||||
|
||||
(defn with-multipart
|
||||
"Attach a multipart content-type to RPC test params, declaring that the
|
||||
upload maps they carry arrived over multipart (as the production
|
||||
multipart parser would deliver them). Preserves any request metadata
|
||||
already present on the data."
|
||||
[data]
|
||||
(let [request (-> data meta :app.http/request)]
|
||||
(vary-meta data assoc :app.http/request
|
||||
(assoc (if (map? request) request {})
|
||||
:headers (merge multipart-headers
|
||||
(when (map? request)
|
||||
(:headers request)))))))
|
||||
|
||||
(defn multipart-command!
|
||||
"Like `command!` but declares the request as multipart, for calls that
|
||||
carry upload maps (`:file`, `:content`, `:media`)."
|
||||
[data]
|
||||
(command! (with-multipart data)))
|
||||
|
||||
(defn multipart-management-command!
|
||||
"Like `management-command!` but declares the request as multipart."
|
||||
[data]
|
||||
(management-command! (with-multipart data)))
|
||||
|
||||
(defn run-task!
|
||||
"Execute a job handler directly (in-process, no row): the handler gets a
|
||||
nil context, because there is no job to describe."
|
||||
|
||||
@ -91,6 +91,35 @@
|
||||
(t/deftest make-dummy-request-missing-cookie-returns-nil-value
|
||||
(t/is (= {:value nil} (yreq/get-cookie (th/make-dummy-request) "missing"))))
|
||||
|
||||
;; --- RPC PARAMS: MULTIPART FLAG
|
||||
|
||||
(t/deftest prepare-rpc-params-marks-multipart-request
|
||||
(let [params (#'th/prepare-rpc-params
|
||||
(with-meta {::th/type :dummy
|
||||
:events [{:name "event"}]}
|
||||
{:app.http/request {:headers {"content-type" "multipart/form-data; boundary=xxx"}}}))]
|
||||
(t/is (true? (:app.rpc/is-multipart params)))))
|
||||
|
||||
(t/deftest prepare-rpc-params-leaves-non-multipart-request-unmarked
|
||||
(let [params (#'th/prepare-rpc-params
|
||||
(with-meta {::th/type :dummy
|
||||
:events [{:name "event"}]}
|
||||
{:app.http/request {:headers {"content-type" "application/transit+json"}}}))]
|
||||
(t/is (false? (:app.rpc/is-multipart params)))))
|
||||
|
||||
(t/deftest prepare-rpc-params-leaves-missing-content-type-unmarked
|
||||
(let [params (#'th/prepare-rpc-params {::th/type :dummy
|
||||
:events [{:name "event"}]})]
|
||||
(t/is (false? (:app.rpc/is-multipart params)))))
|
||||
|
||||
(t/deftest prepare-rpc-params-body-cannot-forge-multipart-flag
|
||||
(let [params (#'th/prepare-rpc-params
|
||||
(with-meta {::th/type :dummy
|
||||
:app.rpc/is-multipart true
|
||||
:events [{:name "event"}]}
|
||||
{:app.http/request {:headers {"content-type" "application/json"}}}))]
|
||||
(t/is (false? (:app.rpc/is-multipart params)))))
|
||||
|
||||
;; --- RPC PARAMS: REQUEST METADATA
|
||||
|
||||
(t/deftest prepare-rpc-params-preserves-supplied-request
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
(ns backend-tests.http-middleware-test
|
||||
(:require
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.json :as json]
|
||||
[app.common.time :as ct]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.config :as cf]
|
||||
@ -16,9 +17,11 @@
|
||||
[app.http.errors :as http-errors]
|
||||
[app.http.middleware :as mw]
|
||||
[app.http.session :as session]
|
||||
[app.main :as-alias main]
|
||||
[app.main :as main]
|
||||
[app.metrics :as mtx]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.commands.access-token]
|
||||
[app.storage.schema :as stsch]
|
||||
[app.tokens :as tokens]
|
||||
[backend-tests.helpers :as th]
|
||||
[clojure.string :as str]
|
||||
@ -28,6 +31,7 @@
|
||||
[yetti.request :as yreq]
|
||||
[yetti.response :as yres])
|
||||
(:import
|
||||
io.prometheus.client.CollectorRegistry
|
||||
io.undertow.server.RequestTooBigException))
|
||||
|
||||
(t/use-fixtures :once th/state-init)
|
||||
@ -515,7 +519,8 @@
|
||||
;; handler in app.http/router-handler), this test asserts on
|
||||
;; the ex-info thrown by wrap-parse-request directly.
|
||||
(let [handler (#'app.http.middleware/wrap-parse-request
|
||||
(fn [_] {::yres/status 200 ::yres/body :ok}))
|
||||
(fn [_] {::yres/status 200 ::yres/body :ok})
|
||||
(th/metrics-stub))
|
||||
;; Body contains the bytes for: {"x": "\}"} -- a string
|
||||
;; value with a backslash followed by '}', which
|
||||
;; clojure.data.json v0.5.x cannot handle.
|
||||
@ -532,13 +537,44 @@
|
||||
(t/is (= :malformed-json (-> ex ex-data :code)))
|
||||
(t/is (= "invalid JSON in request body" (-> ex ex-data :hint)))))
|
||||
|
||||
(defn- malformed-requests-count
|
||||
"Value of the malformed-body counter of a metrics instance."
|
||||
[metrics]
|
||||
(.getSampleValue ^CollectorRegistry (mtx/get-registry metrics)
|
||||
"penpot_http_malformed_requests_total"
|
||||
(make-array String 0)
|
||||
(make-array String 0)))
|
||||
|
||||
(t/deftest parse-request-malformed-json-body
|
||||
;; The body shape seen in production: an object where the parser expects a
|
||||
;; key. clojure.data.json signals it with a bare java.lang.Exception, which
|
||||
;; the class-based classification did not recognize, so it surfaced as a 500
|
||||
;; :unexpected with the whole stack trace in the log. It is a client error:
|
||||
;; 400 :malformed-json, and it is counted.
|
||||
(let [metrics (ig/init-key :app.metrics/metrics {:default main/default-metrics})
|
||||
handler (#'app.http.middleware/wrap-parse-request
|
||||
(fn [_] {::yres/status 200 ::yres/body :ok})
|
||||
metrics)
|
||||
request (th/make-dummy-request
|
||||
{:method :post
|
||||
:headers {"content-type" "application/json"}
|
||||
:body-bytes (.getBytes "{\"events\":[{{\"a\":1}:\"b\"}]}" "UTF-8")})
|
||||
ex (try
|
||||
(handler request)
|
||||
(catch clojure.lang.ExceptionInfo e e))]
|
||||
(t/is (instance? clojure.lang.ExceptionInfo ex))
|
||||
(t/is (= :validation (-> ex ex-data :type)))
|
||||
(t/is (= :malformed-json (-> ex ex-data :code)))
|
||||
(t/is (= 1.0 (malformed-requests-count metrics)))))
|
||||
|
||||
(t/deftest parse-request-request-too-big-exception
|
||||
;; When RequestTooBigException is raised (e.g. the request body
|
||||
;; exceeded the configured size limit), the middleware should
|
||||
;; convert it to a 413 :request-body-too-large validation
|
||||
;; error.
|
||||
(let [handler (#'app.http.middleware/wrap-parse-request
|
||||
(fn [_] (throw (RequestTooBigException. "too large"))))
|
||||
(fn [_] (throw (RequestTooBigException. "too large")))
|
||||
(th/metrics-stub))
|
||||
request (th/make-dummy-request
|
||||
{:method :post
|
||||
:headers {"content-type" "application/json"}
|
||||
@ -556,7 +592,8 @@
|
||||
;; was closed before the parser could read it), the middleware
|
||||
;; should convert it to a 400 :malformed-json validation error.
|
||||
(let [handler (#'app.http.middleware/wrap-parse-request
|
||||
(fn [_] (throw (java.io.EOFException. "stream closed"))))
|
||||
(fn [_] (throw (java.io.EOFException. "stream closed")))
|
||||
(th/metrics-stub))
|
||||
request (th/make-dummy-request
|
||||
{:method :post
|
||||
:headers {"content-type" "application/json"}
|
||||
@ -579,7 +616,8 @@
|
||||
wrapped (doto (RuntimeException. "wrapped")
|
||||
(.initCause iae))
|
||||
handler (#'app.http.middleware/wrap-parse-request
|
||||
(fn [_] (throw wrapped)))
|
||||
(fn [_] (throw wrapped))
|
||||
(th/metrics-stub))
|
||||
request (th/make-dummy-request
|
||||
{:method :post
|
||||
:headers {"content-type" "application/json"}
|
||||
@ -597,7 +635,8 @@
|
||||
;; path and return a 500 with :type :server-error :code
|
||||
;; :unexpected. This is the "true internal error" path.
|
||||
(let [handler (#'app.http.middleware/wrap-parse-request
|
||||
(fn [_] (throw (RuntimeException. "boom"))))
|
||||
(fn [_] (throw (RuntimeException. "boom")))
|
||||
(th/metrics-stub))
|
||||
request (th/make-dummy-request
|
||||
{:method :post
|
||||
:headers {"content-type" "application/json"}
|
||||
@ -617,7 +656,8 @@
|
||||
;; has a dedicated handle-exception method that returns 500
|
||||
;; with :code :io-exception.
|
||||
(let [handler (#'app.http.middleware/wrap-parse-request
|
||||
(fn [_] (throw (java.io.IOException. "network gone"))))
|
||||
(fn [_] (throw (java.io.IOException. "network gone")))
|
||||
(th/metrics-stub))
|
||||
request (th/make-dummy-request
|
||||
{:method :post
|
||||
:headers {"content-type" "application/json"}
|
||||
@ -669,3 +709,23 @@
|
||||
(t/is (= "safe user-facing hint" (:hint body)))
|
||||
(t/is (nil? (:state body)))
|
||||
(t/is (nil? (:path body)))))
|
||||
|
||||
(t/deftest validation-error-body-with-raw-explain-is-json-serializable
|
||||
;; A :validation error whose ex-data carries the raw malli explain
|
||||
;; (storage invalid-storage-metadata) must produce a response body
|
||||
;; that survives JSON encoding. Before the fix the live schema
|
||||
;; object inside the explain broke the writer mid-stream and the
|
||||
;; client received a truncated body.
|
||||
(let [cause (try
|
||||
(stsch/encode-metadata {})
|
||||
(catch clojure.lang.ExceptionInfo cause cause))
|
||||
response (http-errors/handle cause {})
|
||||
body (::yres/body response)]
|
||||
(t/is (= 400 (::yres/status response)))
|
||||
(t/is (= :validation (:type body)))
|
||||
(t/is (= :invalid-storage-metadata (:code body)))
|
||||
(t/is (nil? (:app.common.schema/explain body)))
|
||||
(let [encoded (json/encode body :key-fn json/write-camel-key)
|
||||
decoded (json/decode encoded :key-fn json/read-kebab-key)]
|
||||
(t/is (str/includes? encoded "invalid-storage-metadata"))
|
||||
(t/is (string? (:explain decoded))))))
|
||||
|
||||
@ -45,6 +45,50 @@
|
||||
(t/is (false? (validator params-with-file-id))
|
||||
"params with file-id should be rejected")))
|
||||
|
||||
(t/deftest import-binfile-schema-accepts-path-object
|
||||
;; Documents the schema hole: a server-side Path object in :file passes
|
||||
;; validation on shape alone. The multipart guard in the handler (not the
|
||||
;; schema) is what rejects client-forged paths.
|
||||
(let [schema @#'binfile/schema:import-binfile
|
||||
validator (sm/lazy-validator schema)]
|
||||
(t/is (true? (validator {:name "pwned"
|
||||
:project-id (uuid/random)
|
||||
:version 3
|
||||
:file {:filename "evil.zip"
|
||||
:size 123
|
||||
:path (fs/path "/etc/passwd")}})))))
|
||||
|
||||
(t/deftest import-binfile-rejects-forged-file-without-multipart
|
||||
;; Path-traversal PoC at command level: a transit body like
|
||||
;; {"~:file": {"~:path": ["~#path", "/etc/passwd"]}} decodes to this exact
|
||||
;; map. Without a multipart request it must fail at params validation,
|
||||
;; before any file is read.
|
||||
(let [prof (th/create-profile* 1)
|
||||
out (th/command! {::th/type :import-binfile
|
||||
::rpc/profile-id (:id prof)
|
||||
:name "pwned"
|
||||
:version 3
|
||||
:project-id (:default-project-id prof)
|
||||
:file {:filename "evil.zip"
|
||||
:size 123
|
||||
:path (fs/path "/etc/passwd")}})]
|
||||
(t/is (th/ex-of-code? (:error out) :params-validation))))
|
||||
|
||||
(t/deftest import-binfile-rejects-forged-file-with-forged-flag
|
||||
;; The multipart flag is server-side: smuggling it in the body must not
|
||||
;; bypass the guard (prepare-rpc-params overwrites it from the request).
|
||||
(let [prof (th/create-profile* 1)
|
||||
out (th/command! {::th/type :import-binfile
|
||||
::rpc/profile-id (:id prof)
|
||||
:app.rpc/is-multipart true
|
||||
:name "pwned"
|
||||
:version 3
|
||||
:project-id (:default-project-id prof)
|
||||
:file {:filename "evil.zip"
|
||||
:size 123
|
||||
:path (fs/path "/etc/passwd")}})]
|
||||
(t/is (th/ex-of-code? (:error out) :params-validation))))
|
||||
|
||||
(t/deftest import-binfile-schema-rejects-unsupported-version
|
||||
;; T1-N2-03: version parameter should be restricted to supported values (1 or 3)
|
||||
(let [schema @#'binfile/schema:import-binfile
|
||||
|
||||
@ -265,7 +265,7 @@
|
||||
:is-local true
|
||||
:name "testfile"
|
||||
:content mfile}
|
||||
out (th/command! params)]
|
||||
out (th/multipart-command! params)]
|
||||
|
||||
;; (th/print-result! out)
|
||||
(t/is (nil? (:error out)))
|
||||
@ -414,7 +414,7 @@
|
||||
:is-local true
|
||||
:name "testfile"
|
||||
:content mfile}
|
||||
out (th/command! params)]
|
||||
out (th/multipart-command! params)]
|
||||
|
||||
;; (th/print-result! out)
|
||||
(t/is (nil? (:error out)))
|
||||
@ -605,7 +605,7 @@
|
||||
:object-id object-id
|
||||
:tag "frame"
|
||||
:media mfile}
|
||||
out (th/command! params)]
|
||||
out (th/multipart-command! params)]
|
||||
|
||||
;; (th/print-result! out)
|
||||
(t/is (nil? (:error out)))
|
||||
@ -1279,7 +1279,7 @@
|
||||
:size 7923
|
||||
:path (th/tempfile "backend_tests/test_files/sample2.jpg")
|
||||
:mtype "image/jpeg"}}
|
||||
{:keys [error result] :as out} (th/command! data)]
|
||||
{:keys [error result] :as out} (th/multipart-command! data)]
|
||||
(t/is (nil? error))
|
||||
(t/is (map? result)))
|
||||
|
||||
@ -1336,7 +1336,7 @@
|
||||
:size 7923
|
||||
:path (th/tempfile "backend_tests/test_files/sample2.jpg")
|
||||
:mtype "image/jpeg"}}
|
||||
{:keys [error result] :as out} (th/command! data)]
|
||||
{:keys [error result] :as out} (th/multipart-command! data)]
|
||||
(t/is (nil? error))
|
||||
(t/is (map? result)))
|
||||
|
||||
@ -1360,7 +1360,7 @@
|
||||
:size 7923
|
||||
:path (th/tempfile "backend_tests/test_files/sample2.jpg")
|
||||
:mtype "image/jpeg"}}
|
||||
{:keys [error result] :as out} (th/command! data)]
|
||||
{:keys [error result] :as out} (th/multipart-command! data)]
|
||||
(t/is (nil? error))
|
||||
(t/is (map? result)))
|
||||
|
||||
@ -1478,7 +1478,7 @@
|
||||
:size 7923
|
||||
:path (th/tempfile "backend_tests/test_files/sample2.jpg")
|
||||
:mtype "image/jpeg"}}
|
||||
{:keys [error result] :as out} (th/command! data)]
|
||||
{:keys [error result] :as out} (th/multipart-command! data)]
|
||||
|
||||
;; (th/print-result! out)
|
||||
(t/is (nil? error))
|
||||
@ -1493,7 +1493,7 @@
|
||||
:size 7923
|
||||
:path (th/tempfile "backend_tests/test_files/sample2.jpg")
|
||||
:mtype "image/jpeg"}}
|
||||
{:keys [error result] :as out} (th/command! data)]
|
||||
{:keys [error result] :as out} (th/multipart-command! data)]
|
||||
|
||||
;; (th/print-result! out)
|
||||
(t/is (nil? error))
|
||||
@ -1991,7 +1991,7 @@
|
||||
:is-local true
|
||||
:name "testfile"
|
||||
:content mfile}
|
||||
out (th/command! params)]
|
||||
out (th/multipart-command! params)]
|
||||
|
||||
;; (th/print-result! out)
|
||||
(t/is (nil? (:error out)))
|
||||
|
||||
@ -76,11 +76,11 @@
|
||||
:path (th/tempfile "backend_tests/test_files/sample2.jpg")
|
||||
:mtype "image/jpeg"}}]
|
||||
|
||||
(let [out (th/command! data1)]
|
||||
(let [out (th/multipart-command! data1)]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (map? (:result out))))
|
||||
|
||||
(let [out (th/command! data2)]
|
||||
(let [out (th/multipart-command! data2)]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (map? (:result out))))
|
||||
|
||||
@ -184,12 +184,12 @@
|
||||
:path (th/tempfile "backend_tests/test_files/sample.jpg")
|
||||
:mtype "image/jpeg"}}]
|
||||
|
||||
(let [out (th/command! data1)]
|
||||
(let [out (th/multipart-command! data1)]
|
||||
;; (th/print-result! out)
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (contains? (:result out) :uri)))
|
||||
|
||||
(let [out (th/command! data2)]
|
||||
(let [out (th/multipart-command! data2)]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (contains? (:result out) :uri)))
|
||||
|
||||
@ -272,7 +272,7 @@
|
||||
:size 7923
|
||||
:path (th/tempfile "backend_tests/test_files/sample2.jpg")
|
||||
:mtype "image/jpeg"}}
|
||||
out (th/command! data)
|
||||
out (th/multipart-command! data)
|
||||
error (:error out)]
|
||||
|
||||
(t/is (nil? (:result out)))
|
||||
@ -298,7 +298,7 @@
|
||||
:path (th/tempfile "backend_tests/test_files/sample2.jpg")
|
||||
:mtype "image/jpeg"}}]
|
||||
|
||||
(let [out (th/command! data1)]
|
||||
(let [out (th/multipart-command! data1)]
|
||||
;; (th/print-result! out)
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (contains? (:result out) :uri)))
|
||||
@ -325,7 +325,7 @@
|
||||
:path (th/tempfile "backend_tests/test_files/sample2.jpg")
|
||||
:mtype "image/jpeg"}}]
|
||||
|
||||
(let [out (th/command! data)]
|
||||
(let [out (th/multipart-command! data)]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (map? (:result out))))
|
||||
|
||||
@ -341,7 +341,7 @@
|
||||
(let [params {::th/type :get-file-object-thumbnails
|
||||
::rpc/profile-id (:id profile)
|
||||
:file-id (:id file)}
|
||||
out (th/command! params)]
|
||||
out (th/multipart-command! params)]
|
||||
|
||||
;; (th/print-result! out)
|
||||
|
||||
@ -367,7 +367,7 @@
|
||||
(-> media
|
||||
(assoc :path (th/tempfile "backend_tests/test_files/sample2.jpg"))
|
||||
(assoc :size 7923))))
|
||||
out (th/command! data)]
|
||||
out (th/multipart-command! data)]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (map? (:result out))))
|
||||
|
||||
@ -376,7 +376,7 @@
|
||||
(-> media
|
||||
(assoc :path (th/tempfile "backend_tests/test_files/sample.jpg"))
|
||||
(assoc :size 312043))))
|
||||
out (th/command! data)]
|
||||
out (th/multipart-command! data)]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (map? (:result out))))))
|
||||
|
||||
@ -402,7 +402,7 @@
|
||||
:size 7923
|
||||
:path (th/tempfile "backend_tests/test_files/sample2.jpg")
|
||||
:mtype "image/jpeg"}}
|
||||
out (th/command! data)]
|
||||
out (th/multipart-command! data)]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (map? (:result out)))))
|
||||
|
||||
@ -419,7 +419,7 @@
|
||||
(let [data {::th/type :delete-file-object-thumbnails
|
||||
::rpc/profile-id (:id profile)
|
||||
:object-ids [oid1 oid2 oid3]}
|
||||
out (th/command! data)]
|
||||
out (th/multipart-command! data)]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (nil? (:result out))))
|
||||
|
||||
@ -440,7 +440,7 @@
|
||||
data {::th/type :delete-file-object-thumbnails
|
||||
::rpc/profile-id (:id profile)
|
||||
:object-ids []}
|
||||
out (th/command! data)]
|
||||
out (th/multipart-command! data)]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (nil? (:result out)))))
|
||||
|
||||
@ -457,7 +457,7 @@
|
||||
(let [data {::th/type :delete-file-object-thumbnails
|
||||
::rpc/profile-id (:id profile)
|
||||
:object-ids [oid1 oid2]}
|
||||
out (th/command! data)]
|
||||
out (th/multipart-command! data)]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (nil? (:result out))))))
|
||||
|
||||
@ -480,7 +480,7 @@
|
||||
:size 7923
|
||||
:path (th/tempfile "backend_tests/test_files/sample2.jpg")
|
||||
:mtype "image/jpeg"}}
|
||||
out (th/command! data)]
|
||||
out (th/multipart-command! data)]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (map? (:result out))))
|
||||
|
||||
@ -488,7 +488,7 @@
|
||||
(let [data {::th/type :delete-file-object-thumbnails
|
||||
::rpc/profile-id (:id profile)
|
||||
:object-ids [oid1 oid2 oid3]}
|
||||
out (th/command! data)]
|
||||
out (th/multipart-command! data)]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (nil? (:result out))))
|
||||
|
||||
@ -517,7 +517,7 @@
|
||||
:size 7923
|
||||
:path (th/tempfile "backend_tests/test_files/sample2.jpg")
|
||||
:mtype "image/jpeg"}}
|
||||
out (th/command! data)]
|
||||
out (th/multipart-command! data)]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (map? (:result out))))
|
||||
|
||||
@ -525,7 +525,7 @@
|
||||
(let [data {::th/type :delete-file-object-thumbnails
|
||||
::rpc/profile-id (:id profile)
|
||||
:object-ids [oid]}
|
||||
out (th/command! data)]
|
||||
out (th/multipart-command! data)]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (nil? (:result out))))
|
||||
|
||||
@ -533,7 +533,7 @@
|
||||
(let [data {::th/type :delete-file-object-thumbnails
|
||||
::rpc/profile-id (:id profile)
|
||||
:object-ids [oid]}
|
||||
out (th/command! data)]
|
||||
out (th/multipart-command! data)]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (nil? (:result out))))
|
||||
|
||||
@ -563,7 +563,7 @@
|
||||
:size 7923
|
||||
:path (th/tempfile "backend_tests/test_files/sample2.jpg")
|
||||
:mtype "image/jpeg"}}
|
||||
out (th/command! data)]
|
||||
out (th/multipart-command! data)]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (map? (:result out))))
|
||||
|
||||
@ -571,7 +571,7 @@
|
||||
(let [data {::th/type :delete-file-object-thumbnails
|
||||
::rpc/profile-id (:id profile2)
|
||||
:object-ids [oid]}
|
||||
out (th/command! data)]
|
||||
out (th/multipart-command! data)]
|
||||
(t/is (some? (:error out)))
|
||||
(t/is (th/ex-info? (:error out)))
|
||||
(t/is (= :not-found (th/ex-type (:error out)))))
|
||||
@ -606,7 +606,7 @@
|
||||
:size 7923
|
||||
:path (th/tempfile "backend_tests/test_files/sample2.jpg")
|
||||
:mtype "image/jpeg"}}
|
||||
out (th/command! data)]
|
||||
out (th/multipart-command! data)]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (map? (:result out)))))
|
||||
|
||||
@ -614,7 +614,7 @@
|
||||
(let [data {::th/type :delete-file-object-thumbnails
|
||||
::rpc/profile-id (:id profile)
|
||||
:object-ids [oid1 oid2]}
|
||||
out (th/command! data)]
|
||||
out (th/multipart-command! data)]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (nil? (:result out))))
|
||||
|
||||
@ -653,7 +653,7 @@
|
||||
:size 7923
|
||||
:path (th/tempfile "backend_tests/test_files/sample2.jpg")
|
||||
:mtype "image/jpeg"}}
|
||||
out (th/command! data)]
|
||||
out (th/multipart-command! data)]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (map? (:result out))))
|
||||
|
||||
@ -665,7 +665,7 @@
|
||||
:size 7923
|
||||
:path (th/tempfile "backend_tests/test_files/sample2.jpg")
|
||||
:mtype "image/jpeg"}}
|
||||
out (th/command! data)]
|
||||
out (th/multipart-command! data)]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (map? (:result out))))
|
||||
|
||||
@ -674,7 +674,7 @@
|
||||
(let [data {::th/type :delete-file-object-thumbnails
|
||||
::rpc/profile-id (:id profile1)
|
||||
:object-ids [oid1 oid2]}
|
||||
out (th/command! data)]
|
||||
out (th/multipart-command! data)]
|
||||
(t/is (some? (:error out)))
|
||||
(t/is (th/ex-info? (:error out)))
|
||||
(t/is (= :not-found (th/ex-type (:error out)))))
|
||||
@ -709,7 +709,7 @@
|
||||
:size 7923
|
||||
:path (th/tempfile "backend_tests/test_files/sample2.jpg")
|
||||
:mtype "image/jpeg"}}
|
||||
out (th/command! data)]
|
||||
out (th/multipart-command! data)]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (map? (:result out))))
|
||||
|
||||
@ -721,7 +721,7 @@
|
||||
:size 312043
|
||||
:path (th/tempfile "backend_tests/test_files/sample.jpg")
|
||||
:mtype "image/jpeg"}}
|
||||
out (th/command! data)]
|
||||
out (th/multipart-command! data)]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (map? (:result out))))
|
||||
|
||||
@ -740,7 +740,7 @@
|
||||
(let [data {::th/type :delete-file-object-thumbnails
|
||||
::rpc/profile-id (:id profile)
|
||||
:object-ids [oid1 oid2]}
|
||||
out (th/command! data)]
|
||||
out (th/multipart-command! data)]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (nil? (:result out))))
|
||||
|
||||
@ -770,7 +770,7 @@
|
||||
:size 7923
|
||||
:path (th/tempfile "backend_tests/test_files/sample2.jpg")
|
||||
:mtype "image/jpeg"}}
|
||||
out (th/command! data)]
|
||||
out (th/multipart-command! data)]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (map? (:result out)))))
|
||||
|
||||
@ -784,7 +784,7 @@
|
||||
(let [data {::th/type :delete-file-object-thumbnails
|
||||
::rpc/profile-id (:id profile)
|
||||
:object-ids oids}
|
||||
out (th/command! data)]
|
||||
out (th/multipart-command! data)]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (nil? (:result out))))
|
||||
|
||||
@ -813,7 +813,7 @@
|
||||
:size 7923
|
||||
:path (th/tempfile "backend_tests/test_files/sample2.jpg")
|
||||
:mtype "image/jpeg"}}
|
||||
out (th/command! data)]
|
||||
out (th/multipart-command! data)]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (map? (:result out))))
|
||||
|
||||
@ -821,7 +821,7 @@
|
||||
(let [data {::th/type :delete-file-object-thumbnails
|
||||
::rpc/profile-id (:id profile)
|
||||
:object-ids [oid]}
|
||||
out (th/command! data)]
|
||||
out (th/multipart-command! data)]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (nil? (:result out))))
|
||||
|
||||
@ -849,7 +849,7 @@
|
||||
:size 7923
|
||||
:path (th/tempfile "backend_tests/test_files/sample2.jpg")
|
||||
:mtype "image/jpeg"}}
|
||||
out (th/command! data)]
|
||||
out (th/multipart-command! data)]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (map? (:result out))))
|
||||
|
||||
@ -857,7 +857,7 @@
|
||||
(let [data {::th/type :delete-file-object-thumbnails
|
||||
::rpc/profile-id (:id profile)
|
||||
:object-ids [oid oid]}
|
||||
out (th/command! data)]
|
||||
out (th/multipart-command! data)]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (nil? (:result out))))
|
||||
|
||||
@ -891,7 +891,7 @@
|
||||
:size 7923
|
||||
:path (th/tempfile "backend_tests/test_files/sample2.jpg")
|
||||
:mtype "image/jpeg"}}
|
||||
out (th/command! data)]
|
||||
out (th/multipart-command! data)]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (map? (:result out)))))
|
||||
|
||||
@ -899,7 +899,7 @@
|
||||
(let [data {::th/type :delete-file-object-thumbnails
|
||||
::rpc/profile-id (:id profile)
|
||||
:object-ids [oid1]}
|
||||
out (th/command! data)]
|
||||
out (th/multipart-command! data)]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (nil? (:result out))))
|
||||
|
||||
@ -915,3 +915,39 @@
|
||||
(t/is (= 1 (count rows2)))
|
||||
(t/is (nil? (:deleted-at (first rows2)))))))
|
||||
|
||||
|
||||
(t/deftest create-file-thumbnail-rejects-forged-media
|
||||
;; A transit body like {"~:media": {"~:path": ["~#path", "/etc/passwd"]}}
|
||||
;; decodes to this exact map. Without a multipart request it must fail at
|
||||
;; params validation, before any file is read. NOTE: plain command! on
|
||||
;; purpose, a multipart header would make it legitimate.
|
||||
(let [profile (th/create-profile* 1)
|
||||
file (th/create-file* 1 {:profile-id (:id profile)
|
||||
:project-id (:default-project-id profile)
|
||||
:is-shared false})
|
||||
out (th/command! {::th/type :create-file-thumbnail
|
||||
::rpc/profile-id (:id profile)
|
||||
:file-id (:id file)
|
||||
:revn 0
|
||||
:media {:filename "evil.jpg"
|
||||
:size 123
|
||||
:path (fs/path "/etc/passwd")
|
||||
:mtype "image/jpeg"}})]
|
||||
(t/is (th/ex-of-code? (:error out) :params-validation))))
|
||||
|
||||
(t/deftest create-file-object-thumbnail-rejects-forged-media
|
||||
;; Same transit-forgery vector through object thumbnails. NOTE: plain
|
||||
;; command! on purpose, a multipart header would make it legitimate.
|
||||
(let [profile (th/create-profile* 1)
|
||||
file (th/create-file* 1 {:profile-id (:id profile)
|
||||
:project-id (:default-project-id profile)
|
||||
:is-shared false})
|
||||
out (th/command! {::th/type :create-file-object-thumbnail
|
||||
::rpc/profile-id (:id profile)
|
||||
:file-id (:id file)
|
||||
:object-id "obj"
|
||||
:media {:filename "evil.jpg"
|
||||
:size 123
|
||||
:path (fs/path "/etc/passwd")
|
||||
:mtype "image/jpeg"}})]
|
||||
(t/is (th/ex-of-code? (:error out) :params-validation))))
|
||||
|
||||
@ -76,11 +76,11 @@
|
||||
(when (uuid? session-id)
|
||||
(doseq [[idx chunk-data] (map-indexed vector chunks)]
|
||||
(let [mfile (make-chunk-mfile chunk-data mtype)
|
||||
out (th/command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index idx
|
||||
:content mfile})]
|
||||
out (th/multipart-command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index idx
|
||||
:content mfile})]
|
||||
(t/is (nil? (:error out))))))
|
||||
session-id))
|
||||
|
||||
@ -438,11 +438,11 @@
|
||||
;; Upload all real chunks except the last one (omit it so the session is incomplete)
|
||||
(doseq [[idx chunk-data] (map-indexed vector (butlast chunks))]
|
||||
(let [mfile (make-chunk-mfile chunk-data "font/ttf")
|
||||
out (th/command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index idx
|
||||
:content mfile})]
|
||||
out (th/multipart-command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index idx
|
||||
:content mfile})]
|
||||
(t/is (nil? (:error out)))))
|
||||
|
||||
(let [out (th/command! {::th/type :create-font-variant
|
||||
|
||||
@ -24,6 +24,7 @@
|
||||
[clojure.set :as set]
|
||||
[clojure.test :as t]
|
||||
[cuerdas.core :as str]
|
||||
[datoteka.fs :as fs]
|
||||
[mockery.core :refer [with-mocks]]))
|
||||
|
||||
(t/use-fixtures :once (t/compose-fixtures
|
||||
@ -2143,3 +2144,18 @@
|
||||
(t/is (th/success? out))
|
||||
(let [[_cfg params] (:call-args @email-mock)]
|
||||
(t/is (= "" (:user-name params)))))))
|
||||
|
||||
(t/deftest upload-organization-logo-rejects-forged-content
|
||||
;; Same transit-forgery vector through the management API: without a
|
||||
;; multipart request the wrapper must reject it at params validation.
|
||||
(with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil}]
|
||||
(let [profile (th/create-profile* 1 {:is-active true})
|
||||
params {::th/type :upload-organization-logo
|
||||
::rpc/profile-id (:id profile)
|
||||
:organization-id (uuid/random)
|
||||
:content {:filename "evil.png"
|
||||
:path (fs/path "/etc/passwd")
|
||||
:mtype "image/png"
|
||||
:size 123}}
|
||||
out (th/management-command! params)]
|
||||
(t/is (th/ex-of-code? (:error out) :params-validation)))))
|
||||
|
||||
@ -56,9 +56,9 @@
|
||||
:size 7}}
|
||||
config (assoc cf/config :public-uri "https://example.com/penpot")
|
||||
out1 (binding [cf/config config]
|
||||
(th/management-command! params))
|
||||
(th/multipart-management-command! params))
|
||||
out2 (binding [cf/config config]
|
||||
(th/management-command! params))]
|
||||
(th/multipart-management-command! params))]
|
||||
(t/is (nil? (:error out1)))
|
||||
(t/is (nil? (:error out2)))
|
||||
(t/is (str/starts-with? (str (get-in out1 [:result :uri]))
|
||||
@ -77,7 +77,7 @@
|
||||
:path path
|
||||
:mtype "text/html"
|
||||
:size 27}}
|
||||
out (th/management-command! params)]
|
||||
out (th/multipart-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))))))
|
||||
@ -720,3 +720,16 @@
|
||||
(ct/inst? (get-in % [:props :modified-at])))
|
||||
events))
|
||||
(t/is (every? #(= (:id team) (get-in % [:props :team-id])) events))))))
|
||||
|
||||
(t/deftest upload-tempfile-rejects-forged-content
|
||||
;; Same transit-forgery vector through the management API: without a
|
||||
;; multipart request the wrapper must reject it at params validation.
|
||||
(let [profile (th/create-profile* 1 {:is-active true})
|
||||
params {::th/type :upload-tempfile
|
||||
::rpc/profile-id (:id profile)
|
||||
:content {:filename "evil.png"
|
||||
:path (fs/path "/etc/passwd")
|
||||
:mtype "image/png"
|
||||
:size 123}}
|
||||
out (th/management-command! params)]
|
||||
(t/is (th/ex-of-code? (:error out) :params-validation))))
|
||||
|
||||
@ -73,7 +73,7 @@
|
||||
:is-local true
|
||||
:name "testfile"
|
||||
:content mfile}
|
||||
out (th/command! params)]
|
||||
out (th/multipart-command! params)]
|
||||
|
||||
;; (th/print-result! out)
|
||||
(t/is (nil? (:error out)))
|
||||
@ -144,7 +144,7 @@
|
||||
:is-local true
|
||||
:name "testfile"
|
||||
:content mfile}
|
||||
out (th/command! params)]
|
||||
out (th/multipart-command! params)]
|
||||
|
||||
;; (th/print-result! out)
|
||||
(t/is (nil? (:error out)))
|
||||
@ -189,7 +189,7 @@
|
||||
:name "testfile"
|
||||
:content mfile}
|
||||
|
||||
out (th/command! params)]
|
||||
out (th/multipart-command! params)]
|
||||
|
||||
(let [error (:error out)
|
||||
error-data (ex-data error)]
|
||||
@ -391,11 +391,11 @@
|
||||
;; --- 1. Upload chunks ---
|
||||
(doseq [[idx chunk-data] (map-indexed vector chunks)]
|
||||
(let [mfile (make-chunk-mfile chunk-data mtype)
|
||||
out (th/command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index idx
|
||||
:content mfile})]
|
||||
out (th/multipart-command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index idx
|
||||
:content mfile})]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (= session-id (:session-id (:result out))))
|
||||
(t/is (= idx (:index (:result out))))))
|
||||
@ -438,11 +438,11 @@
|
||||
mfile (make-chunk-mfile (first chunks) mtype)
|
||||
session-id (create-session! prof 1)]
|
||||
|
||||
(th/command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index 0
|
||||
:content mfile})
|
||||
(th/multipart-command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index 0
|
||||
:content mfile})
|
||||
|
||||
;; First assemble succeeds; session row is marked as consumed afterwards
|
||||
(let [out1 (th/command! {::th/type :assemble-file-media-object
|
||||
@ -482,11 +482,11 @@
|
||||
:size 312043}
|
||||
|
||||
;; prof2 tries to upload a chunk into prof1's session
|
||||
out (th/command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof2)
|
||||
:session-id session-id
|
||||
:index 0
|
||||
:content mfile})]
|
||||
out (th/multipart-command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof2)
|
||||
:session-id session-id
|
||||
:index 0
|
||||
:content mfile})]
|
||||
|
||||
(t/is (some? (:error out)))
|
||||
(t/is (= :not-found (-> out :error ex-data :type)))))
|
||||
@ -505,11 +505,11 @@
|
||||
: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})]
|
||||
(let [out (th/multipart-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
|
||||
@ -540,11 +540,11 @@
|
||||
:mtype "image/jpeg"
|
||||
:size 312043}]
|
||||
|
||||
(th/command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index 0
|
||||
:content mfile})
|
||||
(th/multipart-command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index 0
|
||||
:content mfile})
|
||||
|
||||
;; Assemble with a wrong mtype should fail validation
|
||||
(let [out (th/command! {::th/type :assemble-file-media-object
|
||||
@ -573,11 +573,11 @@
|
||||
:size 312043}]
|
||||
|
||||
;; Upload only 1 chunk
|
||||
(th/command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index 0
|
||||
:content mfile})
|
||||
(th/multipart-command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index 0
|
||||
:content mfile})
|
||||
|
||||
;; Assemble: session says 3 expected, only 1 stored → :missing-chunks
|
||||
(let [out (th/command! {::th/type :assemble-file-media-object
|
||||
@ -606,18 +606,18 @@
|
||||
mtype "image/jpeg"
|
||||
size (alength (first chunks))]
|
||||
|
||||
(let [out (th/command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index 0
|
||||
:content (make-chunk-mfile (first chunks) mtype)})]
|
||||
(let [out (th/multipart-command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index 0
|
||||
:content (make-chunk-mfile (first chunks) mtype)})]
|
||||
(t/is (nil? (:error out))))
|
||||
|
||||
(let [out (th/command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index 0
|
||||
:content (make-chunk-mfile (first chunks) mtype)})]
|
||||
(let [out (th/multipart-command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index 0
|
||||
:content (make-chunk-mfile (first chunks) mtype)})]
|
||||
(t/is (some? (:error out)))
|
||||
(t/is (= :validation (-> out :error ex-data :type)))
|
||||
(t/is (= :chunk-already-exists (-> out :error ex-data :code))))
|
||||
@ -650,27 +650,27 @@
|
||||
|
||||
(t/is (= 3 (count chunks)))
|
||||
|
||||
(let [out (th/command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index 0
|
||||
:content (make-chunk-mfile (nth chunks 0) mtype)})]
|
||||
(let [out (th/multipart-command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index 0
|
||||
:content (make-chunk-mfile (nth chunks 0) mtype)})]
|
||||
(t/is (nil? (:error out))))
|
||||
|
||||
(let [out (th/command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index 0
|
||||
:content (make-chunk-mfile (nth chunks 0) mtype)})]
|
||||
(let [out (th/multipart-command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index 0
|
||||
:content (make-chunk-mfile (nth chunks 0) mtype)})]
|
||||
(t/is (some? (:error out)))
|
||||
(t/is (= :validation (-> out :error ex-data :type)))
|
||||
(t/is (= :chunk-already-exists (-> out :error ex-data :code))))
|
||||
|
||||
(let [out (th/command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index 1
|
||||
:content (make-chunk-mfile (nth chunks 1) mtype)})]
|
||||
(let [out (th/multipart-command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index 1
|
||||
:content (make-chunk-mfile (nth chunks 1) mtype)})]
|
||||
(t/is (nil? (:error out))))
|
||||
|
||||
;; The mapping table holds exactly the two distinct indices: the
|
||||
@ -735,11 +735,11 @@
|
||||
:mtype "image/jpeg"
|
||||
:size 312043}
|
||||
session-id (create-session! prof 1)
|
||||
upload-out (th/command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index 0
|
||||
:content mfile})]
|
||||
upload-out (th/multipart-command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index 0
|
||||
:content mfile})]
|
||||
(t/is (nil? (:error upload-out)))
|
||||
|
||||
(let [assemble-out (th/command! {::th/type :assemble-file-media-object
|
||||
@ -788,21 +788,21 @@
|
||||
:size 312043}]
|
||||
|
||||
;; index == total-chunks (out of range)
|
||||
(let [out (th/command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index 2
|
||||
:content mfile})]
|
||||
(let [out (th/multipart-command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index 2
|
||||
:content mfile})]
|
||||
(t/is (some? (:error out)))
|
||||
(t/is (= :validation (-> out :error ex-data :type)))
|
||||
(t/is (= :invalid-chunk-index (-> out :error ex-data :code))))
|
||||
|
||||
;; negative index
|
||||
(let [out (th/command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index -1
|
||||
:content mfile})]
|
||||
(let [out (th/multipart-command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index -1
|
||||
:content mfile})]
|
||||
(t/is (some? (:error out)))
|
||||
(t/is (= :validation (-> out :error ex-data :type)))
|
||||
(t/is (= :invalid-chunk-index (-> out :error ex-data :code))))))
|
||||
@ -821,11 +821,11 @@
|
||||
mtype "image/jpeg"]
|
||||
|
||||
;; 312043 bytes exceeds the mocked 1024-byte cap: rejected
|
||||
(let [out (th/command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index 0
|
||||
:content (make-chunk-mfile (first chunks) mtype)})]
|
||||
(let [out (th/multipart-command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index 0
|
||||
:content (make-chunk-mfile (first chunks) mtype)})]
|
||||
(t/is (some? (:error out)))
|
||||
(t/is (= :validation (-> out :error ex-data :type)))
|
||||
(t/is (= :chunk-too-large (-> out :error ex-data :code))))
|
||||
@ -835,11 +835,11 @@
|
||||
session-id]))))
|
||||
|
||||
;; A chunk exactly at the cap still uploads fine
|
||||
(let [out (th/command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index 0
|
||||
:content (make-chunk-mfile (byte-array 1024 (byte 1)) mtype)})]
|
||||
(let [out (th/multipart-command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index 0
|
||||
:content (make-chunk-mfile (byte-array 1024 (byte 1)) mtype)})]
|
||||
(t/is (nil? (:error out)))))))
|
||||
|
||||
(t/deftest chunked-upload-sessions-per-profile-quota
|
||||
@ -875,11 +875,11 @@
|
||||
:path source-path
|
||||
:mtype "image/jpeg"
|
||||
:size 312043}
|
||||
out (th/command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index 0
|
||||
:content mfile})]
|
||||
out (th/multipart-command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index 0
|
||||
:content mfile})]
|
||||
(t/is (nil? (:error out)))
|
||||
|
||||
(let [row (th/db-exec-one! ["select session_id, object_id, chunk_index from upload_session_chunk where session_id = ?"
|
||||
@ -904,19 +904,19 @@
|
||||
:path source-path
|
||||
:mtype "image/jpeg"
|
||||
:size 312043}
|
||||
out1 (th/command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index 0
|
||||
:content mfile})]
|
||||
out1 (th/multipart-command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index 0
|
||||
:content mfile})]
|
||||
(t/is (nil? (:error out1)))
|
||||
|
||||
(let [before (:count (th/db-exec-one! ["select count(*) from storage_object"]))
|
||||
out2 (th/command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index 0
|
||||
:content mfile})]
|
||||
out2 (th/multipart-command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index 0
|
||||
:content mfile})]
|
||||
(t/is (some? (:error out2)))
|
||||
(t/is (= :validation (-> out2 :error ex-data :type)))
|
||||
(t/is (= :chunk-already-exists (-> out2 :error ex-data :code)))
|
||||
@ -937,11 +937,11 @@
|
||||
(th/db-exec! ["insert into upload_session_chunk (session_id, chunk_index, object_id) values (?, ?, null)"
|
||||
session-id 0])
|
||||
(let [before (:count (th/db-exec-one! ["select count(*) from storage_object"]))
|
||||
out (th/command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index 0
|
||||
:content mfile})]
|
||||
out (th/multipart-command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index 0
|
||||
:content mfile})]
|
||||
(t/is (some? (:error out)))
|
||||
(t/is (= :validation (-> out :error ex-data :type)))
|
||||
(t/is (= :chunk-already-exists (-> out :error ex-data :code)))
|
||||
@ -965,21 +965,21 @@
|
||||
(if (compare-and-set! failed? false true)
|
||||
(throw (ex-info "link boom" {}))
|
||||
(orig pool object-id session-id index)))}]
|
||||
(let [out (th/command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index 0
|
||||
:content mfile})]
|
||||
(let [out (th/multipart-command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index 0
|
||||
:content mfile})]
|
||||
(t/is (some? (:error out))))
|
||||
;; the failed link left no reservation behind
|
||||
(t/is (= 0 (:count (th/db-exec-one! ["select count(*) from upload_session_chunk where session_id = ?"
|
||||
session-id]))))
|
||||
;; retrying the same index in the same session succeeds
|
||||
(let [out (th/command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index 0
|
||||
:content mfile})]
|
||||
(let [out (th/multipart-command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index 0
|
||||
:content mfile})]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (= 1 (:count (th/db-exec-one! ["select count(*) from upload_session_chunk where session_id = ?"
|
||||
session-id]))))))))
|
||||
@ -999,11 +999,11 @@
|
||||
:path source-path
|
||||
:mtype "image/jpeg"
|
||||
:size 312043}
|
||||
out1 (th/command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index 0
|
||||
:content mfile})]
|
||||
out1 (th/multipart-command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index 0
|
||||
:content mfile})]
|
||||
(t/is (nil? (:error out1)))
|
||||
|
||||
(let [assemble-out (th/command! {::th/type :assemble-file-media-object
|
||||
@ -1024,11 +1024,11 @@
|
||||
|
||||
;; uploading to the consumed session fails without creating an object
|
||||
(let [before (:count (th/db-exec-one! ["select count(*) from storage_object"]))
|
||||
out (th/command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index 0
|
||||
:content mfile})]
|
||||
out (th/multipart-command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index 0
|
||||
:content mfile})]
|
||||
(t/is (some? (:error out)))
|
||||
(t/is (= :not-found (-> out :error ex-data :type)))
|
||||
(t/is (= :object-not-found (-> out :error ex-data :code)))
|
||||
@ -1054,11 +1054,11 @@
|
||||
:path source-path
|
||||
:mtype "image/jpeg"
|
||||
:size 312043}
|
||||
out (th/command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index 0
|
||||
:content mfile})]
|
||||
out (th/multipart-command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index 0
|
||||
:content mfile})]
|
||||
(t/is (nil? (:error out)))
|
||||
|
||||
(let [object-id (:object-id (th/db-exec-one! ["select object_id from upload_session_chunk where session_id = ?"
|
||||
@ -1182,13 +1182,13 @@
|
||||
:mtype "image/jpeg"
|
||||
:size 312043}
|
||||
sent-id (uuid/next)
|
||||
out (th/command! {::th/type :upload-file-media-object
|
||||
::rpc/profile-id (:id prof)
|
||||
:file-id (:id file)
|
||||
:is-local true
|
||||
:name "testfile"
|
||||
:content mfile
|
||||
:id sent-id})]
|
||||
out (th/multipart-command! {::th/type :upload-file-media-object
|
||||
::rpc/profile-id (:id prof)
|
||||
:file-id (:id file)
|
||||
:is-local true
|
||||
:name "testfile"
|
||||
:content mfile
|
||||
:id sent-id})]
|
||||
(t/is (th/ex-info? (:error out)))
|
||||
(t/is (th/ex-of-type? (:error out) :validation))
|
||||
(t/is (th/ex-of-code? (:error out) :params-validation))))
|
||||
@ -1241,12 +1241,45 @@
|
||||
:path (th/tempfile "backend_tests/test_files/sample.jpg")
|
||||
:mtype "image/jpeg"
|
||||
:size 312043}
|
||||
out (th/command! {::th/type :upload-file-media-object
|
||||
::rpc/profile-id (:id prof)
|
||||
:file-id (:id file)
|
||||
:is-local true
|
||||
:name "testfile"
|
||||
:content mfile})]
|
||||
out (th/multipart-command! {::th/type :upload-file-media-object
|
||||
::rpc/profile-id (:id prof)
|
||||
:file-id (:id file)
|
||||
:is-local true
|
||||
:name "testfile"
|
||||
:content mfile})]
|
||||
(t/is (th/success? out))
|
||||
(t/is (= (:id team)
|
||||
(get-in (meta (:result out)) [:app.loggers.audit/replace-props :team-id])))))
|
||||
|
||||
(t/deftest upload-file-media-object-rejects-forged-content
|
||||
;; A transit body like {"~:content": {"~:path": ["~#path", "/etc/passwd"]}}
|
||||
;; decodes to this exact map. Without a multipart request it must fail at
|
||||
;; params validation, before any file is read.
|
||||
(let [prof (th/create-profile* 1)
|
||||
file (th/create-file* 1 {:profile-id (:id prof)
|
||||
:project-id (:default-project-id prof)
|
||||
:is-shared false})
|
||||
out (th/command! {::th/type :upload-file-media-object
|
||||
::rpc/profile-id (:id prof)
|
||||
:file-id (:id file)
|
||||
:is-local true
|
||||
:name "testfile"
|
||||
:content {:filename "evil.jpg"
|
||||
:size 123
|
||||
:path (fs/path "/etc/passwd")
|
||||
:mtype "image/jpeg"}})]
|
||||
(t/is (th/ex-of-code? (:error out) :params-validation))))
|
||||
|
||||
(t/deftest upload-chunk-rejects-forged-content
|
||||
;; Same transit-forgery vector through the chunk primitive: without a
|
||||
;; multipart request the wrapper must reject it at params validation.
|
||||
(let [prof (th/create-profile* 1)
|
||||
out (th/command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id (uuid/random)
|
||||
:index 0
|
||||
:content {:filename "chunk"
|
||||
:size 123
|
||||
:path (fs/path "/etc/passwd")
|
||||
:mtype "image/jpeg"}})]
|
||||
(t/is (th/ex-of-code? (:error out) :params-validation))))
|
||||
|
||||
@ -140,12 +140,12 @@
|
||||
(t/is (= "dark" (get-in out [:result :theme])))))
|
||||
|
||||
(t/testing "update photo"
|
||||
(let [data {::th/type :update-profile-photo
|
||||
::rpc/profile-id (:id profile)
|
||||
:file {:filename "sample.jpg"
|
||||
:size 123123
|
||||
:path (th/tempfile "backend_tests/test_files/sample.jpg")
|
||||
:mtype "image/jpeg"}}
|
||||
(let [data (th/with-multipart {::th/type :update-profile-photo
|
||||
::rpc/profile-id (:id profile)
|
||||
:file {:filename "sample.jpg"
|
||||
:size 123123
|
||||
:path (th/tempfile "backend_tests/test_files/sample.jpg")
|
||||
:mtype "image/jpeg"}})
|
||||
out (th/command! data)]
|
||||
|
||||
;; (th/print-result! out)
|
||||
@ -1829,3 +1829,16 @@
|
||||
(t/is (th/success? second-out))
|
||||
(t/is (= (:id profile)
|
||||
(get-in (meta second-out) [:app.loggers.audit/props :id]))))))
|
||||
|
||||
(t/deftest update-profile-photo-rejects-forged-file
|
||||
;; A transit body like {"~:file": {"~:path": ["~#path", "/etc/passwd"]}}
|
||||
;; decodes to this exact map. Without a multipart request it must fail at
|
||||
;; params validation, before any file is read.
|
||||
(let [prof (th/create-profile* 1)
|
||||
out (th/command! {::th/type :update-profile-photo
|
||||
::rpc/profile-id (:id prof)
|
||||
:file {:filename "evil.jpg"
|
||||
:size 123
|
||||
:path (fs/path "/etc/passwd")
|
||||
:mtype "image/jpeg"}})]
|
||||
(t/is (th/ex-of-code? (:error out) :params-validation))))
|
||||
|
||||
@ -467,7 +467,7 @@
|
||||
:name "testfile"
|
||||
:content mfile}
|
||||
|
||||
out (th/command! params)]
|
||||
out (th/multipart-command! params)]
|
||||
|
||||
;; 312043 bytes > 100 byte limit → should be rejected
|
||||
(t/is (not (th/success? out)))
|
||||
|
||||
@ -912,7 +912,7 @@
|
||||
:name "testfile"
|
||||
:content mfile}
|
||||
|
||||
out (th/command! params)]
|
||||
out (th/multipart-command! params)]
|
||||
(t/is (nil? (:error out))))
|
||||
|
||||
(let [params {::th/type :delete-team
|
||||
@ -1476,6 +1476,20 @@
|
||||
(t/is (th/ex-of-type? (:error out) :validation))
|
||||
(t/is (th/ex-of-code? (:error out) :params-validation))))
|
||||
|
||||
(t/deftest update-team-photo-rejects-forged-file
|
||||
;; Same transit-forgery vector as profile photos: without a multipart
|
||||
;; request the wrapper must reject it at params validation.
|
||||
(let [prof (th/create-profile* 1)
|
||||
team (th/create-team* 1 {:profile-id (:id prof)})
|
||||
out (th/command! {::th/type :update-team-photo
|
||||
::rpc/profile-id (:id prof)
|
||||
:team-id (:id team)
|
||||
:file {:filename "evil.jpg"
|
||||
:size 123
|
||||
:path (fs/path "/etc/passwd")
|
||||
:mtype "image/jpeg"}})]
|
||||
(t/is (th/ex-of-code? (:error out) :params-validation))))
|
||||
|
||||
(t/deftest leaver-loses-file-access
|
||||
;; GHSA-v9r9-h77c-55m2: leaving the team must revoke the file and
|
||||
;; project roles granted in that team.
|
||||
|
||||
@ -250,8 +250,8 @@
|
||||
:name "testfile"
|
||||
:content mfile}
|
||||
|
||||
out1 (th/command! params)
|
||||
out2 (th/command! params)]
|
||||
out1 (th/multipart-command! params)
|
||||
out2 (th/multipart-command! params)]
|
||||
|
||||
(t/is (nil? (:error out1)))
|
||||
(t/is (nil? (:error out2)))
|
||||
@ -308,11 +308,11 @@
|
||||
::rpc/profile-id (:id prof)
|
||||
:total-chunks 1})
|
||||
:result :session-id)
|
||||
out (th/command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index 0
|
||||
:content mfile})]
|
||||
out (th/multipart-command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index 0
|
||||
:content mfile})]
|
||||
(assert (nil? (:error out)))
|
||||
session-id))
|
||||
|
||||
@ -357,7 +357,7 @@
|
||||
:font-style "normal"
|
||||
:uploads {"font/ttf" session-id}}
|
||||
|
||||
out1 (th/command! params1)
|
||||
out1 (th/multipart-command! params1)
|
||||
out2 (th/command! params2)]
|
||||
|
||||
;; (th/print-result! out)
|
||||
@ -424,8 +424,8 @@
|
||||
:name "testfile"
|
||||
:content mfile}
|
||||
|
||||
out1 (th/command! params)
|
||||
out2 (th/command! params)]
|
||||
out1 (th/multipart-command! params)
|
||||
out2 (th/multipart-command! params)]
|
||||
|
||||
(t/is (nil? (:error out1)))
|
||||
(t/is (nil? (:error out2)))
|
||||
@ -729,8 +729,8 @@
|
||||
:is-local true
|
||||
:name "testfile"
|
||||
:content mfile}
|
||||
out1 (th/command! params)
|
||||
out2 (th/command! params)]
|
||||
out1 (th/multipart-command! params)
|
||||
out2 (th/multipart-command! params)]
|
||||
|
||||
(t/is (nil? (:error out1)))
|
||||
(t/is (nil? (:error out2)))
|
||||
@ -1104,11 +1104,11 @@
|
||||
::rpc/profile-id (:id prof)
|
||||
:total-chunks 1})
|
||||
:result :session-id)
|
||||
out (th/command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index 0
|
||||
:content mfile})]
|
||||
out (th/multipart-command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index 0
|
||||
:content mfile})]
|
||||
|
||||
(t/is (nil? (:error out)))
|
||||
|
||||
@ -1145,11 +1145,11 @@
|
||||
::rpc/profile-id (:id prof)
|
||||
:total-chunks 1})
|
||||
:result :session-id)
|
||||
out (th/command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index 0
|
||||
:content mfile})]
|
||||
out (th/multipart-command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index 0
|
||||
:content mfile})]
|
||||
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (= 1 (:count (th/db-exec-one! ["select count(*) from upload_session_chunk where session_id = ?"
|
||||
@ -1197,11 +1197,11 @@
|
||||
::rpc/profile-id (:id prof)
|
||||
:total-chunks 1})
|
||||
:result :session-id)
|
||||
out (th/command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index 0
|
||||
:content mfile})]
|
||||
out (th/multipart-command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index 0
|
||||
:content mfile})]
|
||||
(t/is (nil? (:error out)))
|
||||
(t/is (= 1 (:count (th/db-exec-one! ["select count(*) from upload_session_chunk where session_id = ?"
|
||||
session-id]))))
|
||||
@ -1236,11 +1236,11 @@
|
||||
::rpc/profile-id (:id prof)
|
||||
:total-chunks 1})
|
||||
:result :session-id)
|
||||
out (th/command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index 0
|
||||
:content mfile})
|
||||
out (th/multipart-command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index 0
|
||||
:content mfile})
|
||||
object-id (:object-id (th/db-exec-one! ["select object_id from upload_session_chunk where session_id = ?"
|
||||
session-id]))
|
||||
check @#'gc-touched/has-upload-session-chunk-refs?
|
||||
@ -1281,11 +1281,11 @@
|
||||
::rpc/profile-id (:id prof)
|
||||
:total-chunks 1})
|
||||
:result :session-id)
|
||||
out (th/command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index 0
|
||||
:content mfile})
|
||||
out (th/multipart-command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index 0
|
||||
:content mfile})
|
||||
object-id (:object-id (th/db-exec-one! ["select object_id from upload_session_chunk where session_id = ?"
|
||||
session-id]))]
|
||||
(t/is (nil? (:error out)))
|
||||
@ -1319,11 +1319,11 @@
|
||||
::rpc/profile-id (:id prof)
|
||||
:total-chunks 1})
|
||||
:result :session-id)
|
||||
out (th/command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index 0
|
||||
:content mfile})
|
||||
out (th/multipart-command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index 0
|
||||
:content mfile})
|
||||
object-id (:object-id (th/db-exec-one! ["select object_id from upload_session_chunk where session_id = ?"
|
||||
session-id]))]
|
||||
(t/is (nil? (:error out)))
|
||||
@ -1359,11 +1359,11 @@
|
||||
::rpc/profile-id (:id prof)
|
||||
:total-chunks 1})
|
||||
:result :session-id)
|
||||
out (th/command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index 0
|
||||
:content mfile})]
|
||||
out (th/multipart-command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index 0
|
||||
:content mfile})]
|
||||
|
||||
(t/is (nil? (:error out)))
|
||||
|
||||
@ -1404,11 +1404,11 @@
|
||||
::rpc/profile-id (:id prof)
|
||||
:total-chunks 1})
|
||||
:result :session-id)
|
||||
out (th/command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index 0
|
||||
:content mfile})]
|
||||
out (th/multipart-command! {::th/type :upload-chunk
|
||||
::rpc/profile-id (:id prof)
|
||||
:session-id session-id
|
||||
:index 0
|
||||
:content mfile})]
|
||||
(t/is (nil? (:error out)))
|
||||
|
||||
;; soft-delete the profile; the live session is neither consumed nor stalled
|
||||
|
||||
110
backend/test/e2e/import-binfile-path-traversal.test.mjs
Normal file
110
backend/test/e2e/import-binfile-path-traversal.test.mjs
Normal file
@ -0,0 +1,110 @@
|
||||
import { describe, it, before } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { setupTestProfile } from "./helpers/auth.mjs";
|
||||
import { rpcPost } from "./helpers/client.mjs";
|
||||
|
||||
function errorCode(body) {
|
||||
if (body == null) return undefined;
|
||||
if (typeof body === "string") {
|
||||
try {
|
||||
return errorCode(JSON.parse(body));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return body.code ?? body["~:code"];
|
||||
}
|
||||
|
||||
function errorType(body) {
|
||||
if (body == null) return undefined;
|
||||
if (typeof body === "string") {
|
||||
try {
|
||||
return errorType(JSON.parse(body));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return body.type ?? body["~:type"];
|
||||
}
|
||||
|
||||
describe("import-binfile path traversal", () => {
|
||||
let profile;
|
||||
let cookie;
|
||||
|
||||
before(async () => {
|
||||
const setup = await setupTestProfile();
|
||||
profile = setup.profile;
|
||||
cookie = setup.cookie;
|
||||
});
|
||||
|
||||
it("rejects a hand-forged :file path in a transit body", async () => {
|
||||
// ATTACK SCENARIO:
|
||||
// 1. The attacker has a valid session and a valid project-id.
|
||||
// 2. Instead of a real multipart upload, they hand-write a transit
|
||||
// body (plain JSON, no transit library needed) with a forged
|
||||
// :file map that points to an internal server file.
|
||||
// The transit tag ["~#path", "/etc/passwd"] becomes a real
|
||||
// java.nio.file.Path on the server (see app.common.transit
|
||||
// read handlers) and passes media.v/schema:upload, which only
|
||||
// checks the shape, not where the path came from.
|
||||
// 3. import-binfile then uses (:path file) directly as input-path
|
||||
// (parse-file-format / get-manifest / import-files!).
|
||||
//
|
||||
// EXPECTED BEHAVIOR AFTER FIX: 400 validation/params-validation,
|
||||
// without touching the file. While vulnerable, the request passes
|
||||
// validation and fails later (zip/manifest error) or hangs in SSE,
|
||||
// proving the file was opened.
|
||||
const transitBody = JSON.stringify({
|
||||
"~:project-id": `~u${profile.defaultProjectId}`,
|
||||
"~:name": "pwned",
|
||||
"~:version": 3,
|
||||
"~:file": {
|
||||
"~:filename": "evil.zip",
|
||||
"~:size": 123,
|
||||
"~:path": ["~#path", "/etc/passwd"],
|
||||
},
|
||||
});
|
||||
|
||||
const res = await rpcPost("import-binfile", transitBody, {
|
||||
cookieToken: cookie,
|
||||
contentType: "application/transit+json",
|
||||
accept: "application/json",
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
res.status,
|
||||
400,
|
||||
`forged :file path must be rejected with 400, got ${res.status}: ${JSON.stringify(res.body).slice(0, 500)}`
|
||||
);
|
||||
assert.equal(
|
||||
errorType(res.body),
|
||||
"validation",
|
||||
`expected validation error, got: ${JSON.stringify(res.body).slice(0, 500)}`
|
||||
);
|
||||
assert.equal(
|
||||
errorCode(res.body),
|
||||
"params-validation",
|
||||
`forged :file path must fail at params validation, got: ${JSON.stringify(res.body).slice(0, 500)}`
|
||||
);
|
||||
});
|
||||
|
||||
it("plain JSON string path stays rejected", async () => {
|
||||
// Control case: without the transit "~#path" tag the path arrives
|
||||
// as a plain string and ::fs/path already rejects it. This test
|
||||
// documents that only the transit vector bypasses the check.
|
||||
const res = await rpcPost(
|
||||
"import-binfile",
|
||||
{
|
||||
projectId: profile.defaultProjectId,
|
||||
name: "pwned",
|
||||
version: 3,
|
||||
file: { filename: "evil.zip", size: 123, path: "/etc/passwd" },
|
||||
},
|
||||
{ cookieToken: cookie }
|
||||
);
|
||||
|
||||
assert.equal(res.status, 400);
|
||||
assert.equal(errorType(res.body), "validation");
|
||||
assert.equal(errorCode(res.body), "params-validation");
|
||||
});
|
||||
});
|
||||
189
backend/test/e2e/upload-file-media-path-traversal.test.mjs
Normal file
189
backend/test/e2e/upload-file-media-path-traversal.test.mjs
Normal file
@ -0,0 +1,189 @@
|
||||
import { describe, it, before } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { setupTestProfile } from "./helpers/auth.mjs";
|
||||
import { rpcPost } from "./helpers/client.mjs";
|
||||
|
||||
function errorCode(body) {
|
||||
if (body == null) return undefined;
|
||||
if (typeof body === "string") {
|
||||
try {
|
||||
return errorCode(JSON.parse(body));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return body.code ?? body["~:code"];
|
||||
}
|
||||
|
||||
function errorType(body) {
|
||||
if (body == null) return undefined;
|
||||
if (typeof body === "string") {
|
||||
try {
|
||||
return errorType(JSON.parse(body));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return body.type ?? body["~:type"];
|
||||
}
|
||||
|
||||
async function createFile(cookie, projectId, name = "E2E Media Test File") {
|
||||
const res = await rpcPost(
|
||||
"create-file",
|
||||
{ name, projectId },
|
||||
{ cookieToken: cookie }
|
||||
);
|
||||
assert.equal(res.status, 200, `create-file failed: ${JSON.stringify(res.body)}`);
|
||||
return res.body;
|
||||
}
|
||||
|
||||
async function createUploadSession(cookie) {
|
||||
const res = await rpcPost(
|
||||
"create-upload-session",
|
||||
{ totalChunks: 1 },
|
||||
{ cookieToken: cookie }
|
||||
);
|
||||
assert.equal(
|
||||
res.status,
|
||||
200,
|
||||
`create-upload-session failed: ${JSON.stringify(res.body)}`
|
||||
);
|
||||
return res.body.sessionId ?? res.body["~:session-id"];
|
||||
}
|
||||
|
||||
describe("upload-file-media path traversal", () => {
|
||||
let profile;
|
||||
let cookie;
|
||||
let fileId;
|
||||
|
||||
before(async () => {
|
||||
const setup = await setupTestProfile();
|
||||
profile = setup.profile;
|
||||
cookie = setup.cookie;
|
||||
const file = await createFile(cookie, profile.defaultProjectId);
|
||||
fileId = file.id;
|
||||
});
|
||||
|
||||
it("rejects a hand-forged :content path in a transit body", async () => {
|
||||
// ATTACK SCENARIO:
|
||||
// 1. The attacker has a valid session and a file they can edit.
|
||||
// 2. Instead of a real multipart upload, they hand-write a transit
|
||||
// body (plain JSON, no transit library needed) with a forged
|
||||
// :content map that points to an internal server file.
|
||||
// The transit tag ["~#path", "/etc/passwd"] becomes a real
|
||||
// java.nio.file.Path on the server (see app.common.transit
|
||||
// read handlers) and passes media.v/schema:upload, which only
|
||||
// checks the shape, not where the path came from. The attacker
|
||||
// also controls :size and :mtype, so validate-media-type! and
|
||||
// validate-media-size! pass with "image/png" and a small size.
|
||||
// 3. upload-file-media-object then reads (:path content) in
|
||||
// process-image (media/run :info) and stores the bytes as a
|
||||
// media object, which is later downloadable by URL.
|
||||
//
|
||||
// EXPECTED BEHAVIOR AFTER FIX: 400 validation/params-validation,
|
||||
// without touching the file. While vulnerable, the request passes
|
||||
// validation and fails later in media processing, proving the
|
||||
// file was opened.
|
||||
const transitBody = JSON.stringify({
|
||||
"~:file-id": `~u${fileId}`,
|
||||
"~:is-local": true,
|
||||
"~:name": "pwned",
|
||||
"~:content": {
|
||||
"~:filename": "evil.png",
|
||||
"~:size": 123,
|
||||
"~:mtype": "image/png",
|
||||
"~:path": ["~#path", "/etc/passwd"],
|
||||
},
|
||||
});
|
||||
|
||||
const res = await rpcPost("upload-file-media-object", transitBody, {
|
||||
cookieToken: cookie,
|
||||
contentType: "application/transit+json",
|
||||
accept: "application/json",
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
res.status,
|
||||
400,
|
||||
`forged :content path must be rejected with 400, got ${res.status}: ${JSON.stringify(res.body).slice(0, 500)}`
|
||||
);
|
||||
assert.equal(
|
||||
errorType(res.body),
|
||||
"validation",
|
||||
`expected validation error, got: ${JSON.stringify(res.body).slice(0, 500)}`
|
||||
);
|
||||
assert.equal(
|
||||
errorCode(res.body),
|
||||
"params-validation",
|
||||
`forged :content path must fail at params validation, got: ${JSON.stringify(res.body).slice(0, 500)}`
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a hand-forged chunk :content path in a transit body", async () => {
|
||||
// Same vector through upload-chunk: the forged path is read with
|
||||
// sto/content and stored as a chunk blob. While vulnerable this
|
||||
// returns 200 (the internal file bytes are stored under the
|
||||
// attacker session, retrievable via assemble) — proving the file
|
||||
// was read and kept. After the fix it must be 400
|
||||
// validation/params-validation.
|
||||
const sessionId = await createUploadSession(cookie);
|
||||
|
||||
const transitBody = JSON.stringify({
|
||||
"~:session-id": `~u${sessionId}`,
|
||||
"~:index": 0,
|
||||
"~:content": {
|
||||
"~:filename": "c",
|
||||
"~:size": 123,
|
||||
"~:mtype": "application/octet-stream",
|
||||
"~:path": ["~#path", "/etc/passwd"],
|
||||
},
|
||||
});
|
||||
|
||||
const res = await rpcPost("upload-chunk", transitBody, {
|
||||
cookieToken: cookie,
|
||||
contentType: "application/transit+json",
|
||||
accept: "application/json",
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
res.status,
|
||||
400,
|
||||
`forged chunk :content path must be rejected with 400, got ${res.status}: ${JSON.stringify(res.body).slice(0, 500)}`
|
||||
);
|
||||
assert.equal(
|
||||
errorType(res.body),
|
||||
"validation",
|
||||
`expected validation error, got: ${JSON.stringify(res.body).slice(0, 500)}`
|
||||
);
|
||||
assert.equal(
|
||||
errorCode(res.body),
|
||||
"params-validation",
|
||||
`forged chunk :content path must fail at params validation, got: ${JSON.stringify(res.body).slice(0, 500)}`
|
||||
);
|
||||
});
|
||||
|
||||
it("plain JSON string path stays rejected", async () => {
|
||||
// Control case: without the transit "~#path" tag the path arrives
|
||||
// as a plain string and ::fs/path already rejects it. This test
|
||||
// documents that only the transit vector bypasses the check.
|
||||
const res = await rpcPost(
|
||||
"upload-file-media-object",
|
||||
{
|
||||
fileId,
|
||||
isLocal: true,
|
||||
name: "pwned",
|
||||
content: {
|
||||
filename: "evil.png",
|
||||
size: 123,
|
||||
mtype: "image/png",
|
||||
path: "/etc/passwd",
|
||||
},
|
||||
},
|
||||
{ cookieToken: cookie }
|
||||
);
|
||||
|
||||
assert.equal(res.status, 400);
|
||||
assert.equal(errorType(res.body), "validation");
|
||||
assert.equal(errorCode(res.body), "params-validation");
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user