mirror of
https://github.com/penpot/penpot.git
synced 2026-09-09 13:39:02 +00:00
🐛 Harden outbound HTTP requests against SSRF and restrict assets handlers (#9390)
* ⬆️ Update root deps * 🐛 Harden outbound HTTP requests against SSRF and restrict unauthenticated asset access - Add app.util.ssrf URL/host validator that resolves hostnames and blocks loopback, link-local, site-local, cloud metadata, and operator-supplied CIDRs - Add app.media.sanitize image EOF truncator that strips trailing data after PNG IEND, JPEG EOI, GIF trailer, and WebP RIFF markers - Disable HTTP client auto-redirect; add req-with-redirects! helper that revalidates every redirect hop against the SSRF blocklist - Wire SSRF validation and EOF sanitization into media/download-image - Validate webhook URLs and OIDC profile picture URLs against SSRF - Restrict /assets/by-id to require authentication for non-public buckets (profile) while keeping public access for file-media-object, file-object-thumbnail, team-font-variant, and file-data-fragment - Add config knobs: ssrf-protection-enabled, ssrf-allowed-hosts, ssrf-extra-blocked-cidrs Signed-off-by: Andrey Antukh <niwi@niwi.nz> --------- Signed-off-by: Andrey Antukh <niwi@niwi.nz>
This commit is contained in:
parent
3496435e69
commit
279231240d
@ -18,6 +18,7 @@
|
|||||||
- Fix swapped analytics event names on MCP tab-switch dialog (by @Dexterity104) [Github #9322](https://github.com/penpot/penpot/pull/9322)
|
- Fix swapped analytics event names on MCP tab-switch dialog (by @Dexterity104) [Github #9322](https://github.com/penpot/penpot/pull/9322)
|
||||||
- Fix MCP ReplServer binding to all interfaces (0.0.0.0) instead of localhost, allowing unauthenticated RCE
|
- Fix MCP ReplServer binding to all interfaces (0.0.0.0) instead of localhost, allowing unauthenticated RCE
|
||||||
- Fix incorrect handling of version restore operation [Github #9041](https://github.com/penpot/penpot/pull/9041)
|
- Fix incorrect handling of version restore operation [Github #9041](https://github.com/penpot/penpot/pull/9041)
|
||||||
|
- Fix SSRF in media URL import and restrict unauthenticated asset access to public buckets only [Github #9390](https://github.com/penpot/penpot/pull/9390)
|
||||||
- Fix false “text editing” warning when applying tokens [Github #9355](https://github.com/penpot/penpot/pull/9355)
|
- Fix false “text editing” warning when applying tokens [Github #9355](https://github.com/penpot/penpot/pull/9355)
|
||||||
- Use base64 envelope for Uint8Array task results to avoid JSON expansion (by @opcode81) [Github #9431](https://github.com/penpot/penpot/pull/9431)
|
- Use base64 envelope for Uint8Array task results to avoid JSON expansion (by @opcode81) [Github #9431](https://github.com/penpot/penpot/pull/9431)
|
||||||
- Fix empty warning on login [Github #9056](https://github.com/penpot/penpot/pull/9056)
|
- Fix empty warning on login [Github #9056](https://github.com/penpot/penpot/pull/9056)
|
||||||
|
|||||||
@ -43,7 +43,7 @@
|
|||||||
(defn- discover-oidc-config
|
(defn- discover-oidc-config
|
||||||
[cfg {:keys [base-uri] :as provider}]
|
[cfg {:keys [base-uri] :as provider}]
|
||||||
(let [uri (u/join base-uri ".well-known/openid-configuration")
|
(let [uri (u/join base-uri ".well-known/openid-configuration")
|
||||||
rsp (http/req! cfg {:method :get :uri (dm/str uri)})]
|
rsp (http/req cfg {:method :get :uri (dm/str uri)})]
|
||||||
|
|
||||||
(if (= 200 (:status rsp))
|
(if (= 200 (:status rsp))
|
||||||
(let [data (-> rsp :body json/decode)
|
(let [data (-> rsp :body json/decode)
|
||||||
@ -105,7 +105,7 @@
|
|||||||
|
|
||||||
(defn- fetch-oidc-jwks
|
(defn- fetch-oidc-jwks
|
||||||
[cfg jwks-uri]
|
[cfg jwks-uri]
|
||||||
(let [{:keys [status body]} (http/req! cfg {:method :get :uri jwks-uri})]
|
(let [{:keys [status body]} (http/req cfg {:method :get :uri jwks-uri})]
|
||||||
(if (= 200 status)
|
(if (= 200 status)
|
||||||
(-> body json/decode :keys process-oidc-jwks)
|
(-> body json/decode :keys process-oidc-jwks)
|
||||||
(ex/raise :type ::internal
|
(ex/raise :type ::internal
|
||||||
@ -235,7 +235,7 @@
|
|||||||
:timeout 6000
|
:timeout 6000
|
||||||
:method :get}
|
:method :get}
|
||||||
|
|
||||||
{:keys [status body]} (http/req! cfg params)]
|
{:keys [status body]} (http/req cfg params)]
|
||||||
|
|
||||||
(when-not (int-in-range? status 200 300)
|
(when-not (int-in-range? status 200 300)
|
||||||
(ex/raise :type :internal
|
(ex/raise :type :internal
|
||||||
@ -452,7 +452,7 @@
|
|||||||
:grant-type (:grant_type params)
|
:grant-type (:grant_type params)
|
||||||
:redirect-uri (:redirect_uri params))
|
:redirect-uri (:redirect_uri params))
|
||||||
|
|
||||||
(let [{:keys [status body]} (http/req! cfg req)]
|
(let [{:keys [status body]} (http/req cfg req)]
|
||||||
(if (= status 200)
|
(if (= status 200)
|
||||||
(let [data (json/decode body)
|
(let [data (json/decode body)
|
||||||
data {:token/access (get data :access_token)
|
data {:token/access (get data :access_token)
|
||||||
@ -507,7 +507,7 @@
|
|||||||
:headers {"Authorization" (str (:token/type tdata) " " (:token/access tdata))}
|
:headers {"Authorization" (str (:token/type tdata) " " (:token/access tdata))}
|
||||||
:timeout 6000
|
:timeout 6000
|
||||||
:method :get}
|
:method :get}
|
||||||
response (http/req! cfg params)]
|
response (http/req cfg params)]
|
||||||
|
|
||||||
(l/trc :hint "user info response"
|
(l/trc :hint "user info response"
|
||||||
:status (:status response)
|
:status (:status response)
|
||||||
|
|||||||
@ -85,7 +85,11 @@
|
|||||||
:email-verify-threshold "15m"
|
:email-verify-threshold "15m"
|
||||||
|
|
||||||
:quotes-upload-sessions-per-profile 5
|
:quotes-upload-sessions-per-profile 5
|
||||||
:quotes-upload-chunks-per-session 20})
|
:quotes-upload-chunks-per-session 20
|
||||||
|
|
||||||
|
;; SSRF protection
|
||||||
|
:ssrf-allowed-hosts #{}
|
||||||
|
:ssrf-extra-blocked-cidrs #{}})
|
||||||
|
|
||||||
(def schema:config
|
(def schema:config
|
||||||
(do #_sm/optional-keys
|
(do #_sm/optional-keys
|
||||||
@ -245,7 +249,11 @@
|
|||||||
[:objects-storage-fs-directory {:optional true} :string]
|
[:objects-storage-fs-directory {:optional true} :string]
|
||||||
[:objects-storage-s3-bucket {:optional true} :string]
|
[:objects-storage-s3-bucket {:optional true} :string]
|
||||||
[:objects-storage-s3-region {:optional true} :keyword]
|
[:objects-storage-s3-region {:optional true} :keyword]
|
||||||
[:objects-storage-s3-endpoint {:optional true} ::sm/uri]]))
|
[:objects-storage-s3-endpoint {:optional true} ::sm/uri]
|
||||||
|
|
||||||
|
;; SSRF protection
|
||||||
|
[:ssrf-allowed-hosts {:optional true} [::sm/set :string]]
|
||||||
|
[:ssrf-extra-blocked-cidrs {:optional true} [::sm/set :string]]]))
|
||||||
|
|
||||||
(defn- parse-flags
|
(defn- parse-flags
|
||||||
[config]
|
[config]
|
||||||
|
|||||||
@ -12,43 +12,56 @@
|
|||||||
[app.common.time :as ct]
|
[app.common.time :as ct]
|
||||||
[app.common.uri :as u]
|
[app.common.uri :as u]
|
||||||
[app.db :as db]
|
[app.db :as db]
|
||||||
|
[app.http.session :as session]
|
||||||
[app.storage :as sto]
|
[app.storage :as sto]
|
||||||
[integrant.core :as ig]
|
[integrant.core :as ig]
|
||||||
[yetti.response :as-alias yres]))
|
[yetti.response :as-alias yres]))
|
||||||
|
|
||||||
(def ^:private cache-max-age
|
(def ^:private default-cache-max-age
|
||||||
(ct/duration {:hours 24}))
|
(ct/duration {:hours 24}))
|
||||||
|
|
||||||
(def ^:private signature-max-age
|
(def ^:private default-signature-max-age
|
||||||
(ct/duration {:hours 24 :minutes 15}))
|
(ct/duration {:hours 24 :minutes 15}))
|
||||||
|
|
||||||
|
;; Buckets that are legitimately public and do not require authentication.
|
||||||
|
;; These are used by public shared board viewing, profile photos in UI,
|
||||||
|
;; and embedded export/binfile flows.
|
||||||
|
(def ^:private public-buckets
|
||||||
|
#{"file-media-object"
|
||||||
|
"file-object-thumbnail"
|
||||||
|
"team-font-variant"
|
||||||
|
"file-data-fragment"})
|
||||||
|
|
||||||
(defn get-id
|
(defn get-id
|
||||||
[{:keys [path-params]}]
|
[{:keys [path-params]}]
|
||||||
(or (some-> path-params :id d/parse-uuid)
|
(or (some-> path-params :id d/parse-uuid)
|
||||||
(ex/raise :type :not-found
|
(ex/raise :type :not-found
|
||||||
:hunt "object not found")))
|
:hint "object not found")))
|
||||||
|
|
||||||
(defn- get-file-media-object
|
(defn- get-file-media-object
|
||||||
[pool id]
|
[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
|
(defn- serve-object-from-s3
|
||||||
[{:keys [::sto/storage] :as cfg} obj]
|
[{:keys [::sto/storage ::signature-max-age ::cache-max-age] :as cfg} obj]
|
||||||
(let [{:keys [host port] :as url} (sto/get-object-url storage obj {:max-age signature-max-age})]
|
(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})]
|
||||||
{::yres/status 307
|
{::yres/status 307
|
||||||
::yres/headers {"location" (str url)
|
::yres/headers {"location" (str url)
|
||||||
"x-host" (cond-> host port (str ":" port))
|
"x-host" (cond-> host port (str ":" port))
|
||||||
"x-mtype" (-> obj meta :content-type)
|
"x-mtype" (-> obj meta :content-type)
|
||||||
"cache-control" (str "max-age=" (inst-ms cache-max-age))}}))
|
"cache-control" (str "max-age=" (inst-ms cch-max-age))}}))
|
||||||
|
|
||||||
(defn- serve-object-from-fs
|
(defn- serve-object-from-fs
|
||||||
[{:keys [::path]} obj]
|
[{:keys [::path ::cache-max-age]} obj]
|
||||||
(let [purl (u/join (u/uri path)
|
(let [cch-max-age (or cache-max-age default-cache-max-age)
|
||||||
|
purl (u/join (u/uri path)
|
||||||
(sto/object->relative-path obj))
|
(sto/object->relative-path obj))
|
||||||
mdata (meta obj)
|
mdata (meta obj)
|
||||||
headers {"x-accel-redirect" (:path purl)
|
headers {"x-accel-redirect" (:path purl)
|
||||||
"content-type" (:content-type mdata)
|
"content-type" (:content-type mdata)
|
||||||
"cache-control" (str "max-age=" (inst-ms cache-max-age))}]
|
"cache-control" (str "max-age=" (inst-ms cch-max-age))}]
|
||||||
{::yres/status 204
|
{::yres/status 204
|
||||||
::yres/headers headers}))
|
::yres/headers headers}))
|
||||||
|
|
||||||
@ -60,14 +73,28 @@
|
|||||||
(:s3 :assets-s3) (serve-object-from-s3 cfg obj)
|
(:s3 :assets-s3) (serve-object-from-s3 cfg obj)
|
||||||
(:fs :assets-fs) (serve-object-from-fs cfg obj)))
|
(:fs :assets-fs) (serve-object-from-fs cfg obj)))
|
||||||
|
|
||||||
|
(defn- requires-auth?
|
||||||
|
"Check if the storage object requires authentication based on its bucket."
|
||||||
|
[obj]
|
||||||
|
(let [bucket (-> obj meta :bucket)]
|
||||||
|
(not (contains? public-buckets bucket))))
|
||||||
|
|
||||||
(defn objects-handler
|
(defn objects-handler
|
||||||
"Handler that servers storage objects by id."
|
"Handler that serves storage objects by id.
|
||||||
|
For non-public buckets (e.g. profile), requires an authenticated session."
|
||||||
[{:keys [::sto/storage] :as cfg} request]
|
[{:keys [::sto/storage] :as cfg} request]
|
||||||
(let [id (get-id request)
|
(let [id (get-id request)
|
||||||
obj (sto/get-object storage id)]
|
obj (sto/get-object storage id)]
|
||||||
(if obj
|
(cond
|
||||||
(serve-object cfg obj)
|
(nil? obj)
|
||||||
{::yres/status 404})))
|
{::yres/status 404}
|
||||||
|
|
||||||
|
(and (requires-auth? obj)
|
||||||
|
(nil? (::session/profile-id request)))
|
||||||
|
{::yres/status 401}
|
||||||
|
|
||||||
|
:else
|
||||||
|
(serve-object cfg obj))))
|
||||||
|
|
||||||
(defn- generic-handler
|
(defn- generic-handler
|
||||||
"A generic handler helper/common code for file-media based handlers."
|
"A generic handler helper/common code for file-media based handlers."
|
||||||
@ -96,11 +123,12 @@
|
|||||||
(defmethod ig/assert-key ::routes
|
(defmethod ig/assert-key ::routes
|
||||||
[_ params]
|
[_ params]
|
||||||
(assert (sto/valid-storage? (::sto/storage params)) "expected valid storage instance")
|
(assert (sto/valid-storage? (::sto/storage params)) "expected valid storage instance")
|
||||||
|
(assert (session/manager? (::session/manager params)) "expected valid session manager")
|
||||||
(assert (string? (::path params))))
|
(assert (string? (::path params))))
|
||||||
|
|
||||||
(defmethod ig/init-key ::routes
|
(defmethod ig/init-key ::routes
|
||||||
[_ cfg]
|
[_ cfg]
|
||||||
["/assets"
|
["/assets" {:middleware [[session/authz cfg]]}
|
||||||
["/by-id/:id" {:handler (partial objects-handler cfg)}]
|
["/by-id/:id" {:handler (partial objects-handler cfg)}]
|
||||||
["/by-file-media-id/:id" {:handler (partial file-objects-handler cfg)}]
|
["/by-file-media-id/:id" {:handler (partial file-objects-handler cfg)}]
|
||||||
["/by-file-media-id/:id/thumbnail" {:handler (partial file-thumbnails-handler cfg)}]])
|
["/by-file-media-id/:id/thumbnail" {:handler (partial file-thumbnails-handler cfg)}]])
|
||||||
|
|||||||
@ -53,7 +53,7 @@
|
|||||||
(let [surl (get body "SubscribeURL")
|
(let [surl (get body "SubscribeURL")
|
||||||
stopic (get body "TopicArn")]
|
stopic (get body "TopicArn")]
|
||||||
(l/info :action "subscription received" :topic stopic :url surl)
|
(l/info :action "subscription received" :topic stopic :url surl)
|
||||||
(http/req! cfg {:uri surl :method :post :timeout 10000} {:sync? true}))
|
(http/req cfg {:uri surl :method :post :timeout 10000} {:sync? true}))
|
||||||
|
|
||||||
(= mtype "Notification")
|
(= mtype "Notification")
|
||||||
(when-let [message (parse-json (get body "Message"))]
|
(when-let [message (parse-json (get body "Message"))]
|
||||||
|
|||||||
@ -5,13 +5,24 @@
|
|||||||
;; Copyright (c) KALEIDOS INC
|
;; Copyright (c) KALEIDOS INC
|
||||||
|
|
||||||
(ns app.http.client
|
(ns app.http.client
|
||||||
"Http client abstraction layer."
|
"Http client abstraction layer.
|
||||||
|
|
||||||
|
All outbound requests made through `req` and `req-with-redirects`
|
||||||
|
are validated against the SSRF blocklist by default. Pass
|
||||||
|
`:skip-ssrf-check? true` in the options map only when the target
|
||||||
|
is a well-known, operator-configured endpoint that cannot be
|
||||||
|
influenced by user input (e.g. internal telemetry, error webhooks)."
|
||||||
(:require
|
(:require
|
||||||
[app.common.schema :as sm]
|
[app.common.schema :as sm]
|
||||||
|
[app.util.ssrf :as ssrf]
|
||||||
|
[cuerdas.core :as str]
|
||||||
[integrant.core :as ig]
|
[integrant.core :as ig]
|
||||||
[java-http-clj.core :as http])
|
[java-http-clj.core :as http])
|
||||||
(:import
|
(:import
|
||||||
java.net.http.HttpClient))
|
java.net.http.HttpClient
|
||||||
|
java.net.URI))
|
||||||
|
|
||||||
|
(def default-max-redirects 5)
|
||||||
|
|
||||||
(defn client?
|
(defn client?
|
||||||
[o]
|
[o]
|
||||||
@ -23,8 +34,8 @@
|
|||||||
|
|
||||||
(defmethod ig/init-key ::client
|
(defmethod ig/init-key ::client
|
||||||
[_ _]
|
[_ _]
|
||||||
(http/build-client {:connect-timeout 30000 ;; 10s
|
(http/build-client {:connect-timeout 30000
|
||||||
:follow-redirects :always}))
|
:follow-redirects :never}))
|
||||||
|
|
||||||
(defn send!
|
(defn send!
|
||||||
([client req] (send! client req {}))
|
([client req] (send! client req {}))
|
||||||
@ -44,14 +55,82 @@
|
|||||||
:else
|
:else
|
||||||
(throw (UnsupportedOperationException. "invalid arguments"))))
|
(throw (UnsupportedOperationException. "invalid arguments"))))
|
||||||
|
|
||||||
(defn req!
|
(defn req
|
||||||
"A convencience toplevel function for gradual migration to a new API
|
"Issue a single HTTP request. SSRF validation is applied to the
|
||||||
convention."
|
target URI by default; pass `:skip-ssrf-check? true` in `options`
|
||||||
|
to bypass it for known-safe, operator-configured endpoints."
|
||||||
([cfg-or-client request]
|
([cfg-or-client request]
|
||||||
(let [client (resolve-client cfg-or-client)
|
(req cfg-or-client request {}))
|
||||||
request (update request :uri str)]
|
([cfg-or-client request {:keys [skip-ssrf-check?] :as options}]
|
||||||
(send! client request {})))
|
(let [request (if skip-ssrf-check?
|
||||||
([cfg-or-client request options]
|
(update request :uri str)
|
||||||
(let [client (resolve-client cfg-or-client)
|
(update request :uri ssrf/validate-uri))
|
||||||
request (update request :uri str)]
|
client (resolve-client cfg-or-client)]
|
||||||
(send! client request options))))
|
(send! client request (dissoc options :skip-ssrf-check?)))))
|
||||||
|
|
||||||
|
(defn- resolve-location
|
||||||
|
"Resolve a Location header value against the original request URI.
|
||||||
|
Handles:
|
||||||
|
- Absolute URLs (http:// or https://) — returned as-is.
|
||||||
|
- Protocol-relative URLs (//host/path) — inherit the scheme from base-uri.
|
||||||
|
- Path-absolute and relative URLs — resolved against base-uri via URI.resolve."
|
||||||
|
[^String base-uri ^String location]
|
||||||
|
(cond
|
||||||
|
(or (str/starts-with? location "http://")
|
||||||
|
(str/starts-with? location "https://"))
|
||||||
|
location
|
||||||
|
|
||||||
|
(str/starts-with? location "//")
|
||||||
|
(let [scheme (.getScheme (URI. base-uri))]
|
||||||
|
(str scheme ":" location))
|
||||||
|
|
||||||
|
:else
|
||||||
|
(str (.resolve (URI. base-uri) location))))
|
||||||
|
|
||||||
|
(defn- redirect-request
|
||||||
|
"Build the next request for a 3xx redirect.
|
||||||
|
Per RFC 7231 §6.4:
|
||||||
|
- 303 always issues GET (body dropped).
|
||||||
|
- 301/302 with non-GET/HEAD methods: downgrade to GET (body dropped).
|
||||||
|
- 307/308 preserve the original method and body.
|
||||||
|
The Location URI has already been resolved by the caller."
|
||||||
|
[orig-request ^String next-uri status]
|
||||||
|
(let [method (:method orig-request)]
|
||||||
|
(if (or (= status 303)
|
||||||
|
(and (contains? #{301 302} status)
|
||||||
|
(not (contains? #{:get :head} method))))
|
||||||
|
;; Downgrade to GET, drop body and content-type
|
||||||
|
(-> orig-request
|
||||||
|
(assoc :uri next-uri :method :get)
|
||||||
|
(dissoc :body)
|
||||||
|
(update :headers dissoc "content-type" "content-length"))
|
||||||
|
;; Preserve method/body (307, 308, or GET/HEAD 301/302)
|
||||||
|
(assoc orig-request :uri next-uri))))
|
||||||
|
|
||||||
|
(defn req-with-redirects
|
||||||
|
"Like `req`, but follows up to `max-redirects` HTTP 3xx redirects.
|
||||||
|
SSRF validation is applied before every hop (initial request and
|
||||||
|
each redirect target) unless `:skip-ssrf-check? true` is passed.
|
||||||
|
Redirect semantics follow RFC 7231 §6.4: 301/302 POST is downgraded
|
||||||
|
to GET; 303 always uses GET; 307/308 preserve the original method."
|
||||||
|
([cfg-or-client request]
|
||||||
|
(req-with-redirects cfg-or-client request {}))
|
||||||
|
([cfg-or-client request {:keys [max-redirects skip-ssrf-check?]
|
||||||
|
:or {max-redirects default-max-redirects}
|
||||||
|
:as opts}]
|
||||||
|
(let [send-opts (dissoc opts :max-redirects :skip-ssrf-check?)
|
||||||
|
uri-coerce (if skip-ssrf-check? str ssrf/validate-uri)]
|
||||||
|
(loop [current-req (update request :uri uri-coerce)
|
||||||
|
hops 0]
|
||||||
|
(let [client (resolve-client cfg-or-client)
|
||||||
|
resp (send! client current-req send-opts)
|
||||||
|
status (:status resp)]
|
||||||
|
(if (and (<= 300 status 399)
|
||||||
|
(< hops max-redirects))
|
||||||
|
(if-let [location (get-in resp [:headers "location"])]
|
||||||
|
(let [next-uri (resolve-location (str (:uri current-req)) location)]
|
||||||
|
(recur (update (redirect-request current-req next-uri status) :uri uri-coerce)
|
||||||
|
(inc hops)))
|
||||||
|
;; No Location header on a 3xx — return the response as-is
|
||||||
|
resp)
|
||||||
|
resp))))))
|
||||||
|
|||||||
@ -59,7 +59,7 @@
|
|||||||
:method :post
|
:method :post
|
||||||
:headers headers
|
:headers headers
|
||||||
:body body}
|
:body body}
|
||||||
resp (http/req! cfg params)]
|
resp (http/req cfg params)]
|
||||||
|
|
||||||
(if (= (:status resp) 204)
|
(if (= (:status resp) 204)
|
||||||
true
|
true
|
||||||
|
|||||||
@ -52,12 +52,12 @@
|
|||||||
trace
|
trace
|
||||||
"```")))
|
"```")))
|
||||||
|
|
||||||
resp (http/req! cfg
|
resp (http/req cfg
|
||||||
{:uri (cf/get :error-report-webhook)
|
{:uri (cf/get :error-report-webhook)
|
||||||
:method :post
|
:method :post
|
||||||
:headers {"content-type" "application/json"}
|
:headers {"content-type" "application/json"}
|
||||||
:body (json/encode-str {:text text})}
|
:body (json/encode-str {:text text})}
|
||||||
{:sync? true})]
|
{:sync? true})]
|
||||||
|
|
||||||
(when (not= 200 (:status resp))
|
(when (not= 200 (:status resp))
|
||||||
(l/warn :hint "error on sending data"
|
(l/warn :hint "error on sending data"
|
||||||
|
|||||||
@ -159,7 +159,7 @@
|
|||||||
:method :post
|
:method :post
|
||||||
:body body}]
|
:body body}]
|
||||||
(try
|
(try
|
||||||
(let [rsp (http/req! cfg req {:response-type :input-stream :sync? true})
|
(let [rsp (http/req cfg req {:response-type :input-stream :sync? true})
|
||||||
err (interpret-response rsp)]
|
err (interpret-response rsp)]
|
||||||
(report-delivery! whook req rsp err)
|
(report-delivery! whook req rsp err)
|
||||||
(update-webhook! whook err))
|
(update-webhook! whook err))
|
||||||
@ -190,4 +190,11 @@
|
|||||||
"invalid-uri"
|
"invalid-uri"
|
||||||
|
|
||||||
(instance? java.net.http.HttpConnectTimeoutException cause)
|
(instance? java.net.http.HttpConnectTimeoutException cause)
|
||||||
"timeout"))
|
"timeout"
|
||||||
|
|
||||||
|
:else
|
||||||
|
(let [data (ex-data cause)]
|
||||||
|
(if (and (= :validation (:type data))
|
||||||
|
(= :ssrf-blocked-target (:code data)))
|
||||||
|
(str "blocked-request:" (:hint data))
|
||||||
|
nil))))
|
||||||
|
|||||||
@ -304,10 +304,11 @@
|
|||||||
::session/manager (ig/ref ::session/manager)}
|
::session/manager (ig/ref ::session/manager)}
|
||||||
|
|
||||||
:app.http.assets/routes
|
:app.http.assets/routes
|
||||||
{::http.assets/path (cf/get :assets-path)
|
{::http.assets/path (cf/get :assets-path)
|
||||||
::http.assets/cache-max-age (ct/duration {:hours 24})
|
::http.assets/cache-max-age (ct/duration {:hours 24})
|
||||||
::http.assets/cache-max-agesignature-max-age (ct/duration {:hours 24 :minutes 5})
|
::http.assets/signature-max-age (ct/duration {:hours 24 :minutes 15})
|
||||||
::sto/storage (ig/ref ::sto/storage)}
|
::sto/storage (ig/ref ::sto/storage)
|
||||||
|
::session/manager (ig/ref ::session/manager)}
|
||||||
|
|
||||||
::rpc/climit
|
::rpc/climit
|
||||||
{::mtx/metrics (ig/ref ::mtx/metrics)
|
{::mtx/metrics (ig/ref ::mtx/metrics)
|
||||||
|
|||||||
@ -18,6 +18,7 @@
|
|||||||
[app.config :as cf]
|
[app.config :as cf]
|
||||||
[app.db :as-alias db]
|
[app.db :as-alias db]
|
||||||
[app.http.client :as http]
|
[app.http.client :as http]
|
||||||
|
[app.media.sanitize :as sanitize]
|
||||||
[app.storage :as-alias sto]
|
[app.storage :as-alias sto]
|
||||||
[app.storage.tmp :as tmp]
|
[app.storage.tmp :as tmp]
|
||||||
[buddy.core.bytes :as bb]
|
[buddy.core.bytes :as bb]
|
||||||
@ -325,9 +326,11 @@
|
|||||||
|
|
||||||
(let [{:keys [body] :as response}
|
(let [{:keys [body] :as response}
|
||||||
(try
|
(try
|
||||||
(http/req! client
|
(http/req-with-redirects
|
||||||
{:method :get :uri uri}
|
client
|
||||||
{:response-type :input-stream})
|
{:method :get :uri uri}
|
||||||
|
{:response-type :input-stream
|
||||||
|
:max-redirects 3})
|
||||||
(catch java.net.ConnectException cause
|
(catch java.net.ConnectException cause
|
||||||
(ex/raise :type :validation
|
(ex/raise :type :validation
|
||||||
:code :unable-to-download-image
|
:code :unable-to-download-image
|
||||||
@ -358,9 +361,11 @@
|
|||||||
:code :mismatch-write-size
|
:code :mismatch-write-size
|
||||||
:hint "unexpected state: unable to write to file"))
|
:hint "unexpected state: unable to write to file"))
|
||||||
|
|
||||||
{;; :size size
|
;; Sanitize: strip trailing data after image EOF markers
|
||||||
:path path
|
(let [new-size (sanitize/truncate-after-eof path mtype)]
|
||||||
:mtype mtype})))
|
{:path path
|
||||||
|
:mtype mtype
|
||||||
|
:size new-size}))))
|
||||||
|
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
;; FONTS
|
;; FONTS
|
||||||
|
|||||||
191
backend/src/app/media/sanitize.clj
Normal file
191
backend/src/app/media/sanitize.clj
Normal file
@ -0,0 +1,191 @@
|
|||||||
|
;; 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
|
||||||
|
|
||||||
|
(ns app.media.sanitize
|
||||||
|
"Image EOF truncation helpers — strips trailing data after image EOF
|
||||||
|
markers to prevent exfiltration of non-image bytes appended to
|
||||||
|
valid image files."
|
||||||
|
(:require
|
||||||
|
[app.common.buffer :as buf]
|
||||||
|
[app.common.exceptions :as ex]
|
||||||
|
[app.common.logging :as l]
|
||||||
|
[app.util.nio :as nio])
|
||||||
|
(:import
|
||||||
|
java.nio.ByteOrder
|
||||||
|
java.nio.channels.FileChannel))
|
||||||
|
|
||||||
|
(set! *warn-on-reflection* true)
|
||||||
|
|
||||||
|
(defn- scan-backwards
|
||||||
|
"Scan byte array `arr` backwards (from the end) for the byte pattern
|
||||||
|
`marker`. Returns the index in `arr` where the marker starts, or -1
|
||||||
|
if not found."
|
||||||
|
[^bytes arr ^bytes marker]
|
||||||
|
(let [arr-len (alength arr)
|
||||||
|
marker-len (alength marker)]
|
||||||
|
(loop [i (- arr-len marker-len)]
|
||||||
|
(if (< i 0)
|
||||||
|
-1
|
||||||
|
(if (loop [j 0]
|
||||||
|
(if (>= j marker-len)
|
||||||
|
true
|
||||||
|
(if (= (aget arr (+ i j)) (aget marker j))
|
||||||
|
(recur (inc j))
|
||||||
|
false)))
|
||||||
|
i
|
||||||
|
(recur (dec i)))))))
|
||||||
|
|
||||||
|
(defn- find-last-png-iend
|
||||||
|
"Find the byte offset of the end of the PNG IEND chunk (12 bytes:
|
||||||
|
4-byte length + 4-byte 'IEND' + 4-byte CRC32). Returns the offset
|
||||||
|
AFTER the CRC32, or nil if not found."
|
||||||
|
[^FileChannel channel]
|
||||||
|
(let [size (nio/channel-size channel)]
|
||||||
|
(when (> size 8)
|
||||||
|
(let [buf-size (min (int size) (* 1024 1024))
|
||||||
|
marker (byte-array [0x49 0x45 0x4E 0x44])] ;; "IEND"
|
||||||
|
(loop [pos (max 0 (- size buf-size))]
|
||||||
|
(when (< pos size)
|
||||||
|
(let [arr (nio/read-at channel pos buf-size)
|
||||||
|
idx (scan-backwards arr marker)]
|
||||||
|
(if (neg? idx)
|
||||||
|
;; Not found in this chunk, try earlier
|
||||||
|
(let [next-pos (max 0 (- pos (- buf-size 4)))]
|
||||||
|
(when (< next-pos pos)
|
||||||
|
(recur next-pos)))
|
||||||
|
;; Found "IEND" at idx. Chunk starts 4 bytes before.
|
||||||
|
(let [chunk-start (- (+ pos idx) 4)]
|
||||||
|
(when (>= chunk-start 0)
|
||||||
|
;; PNG chunk length is big-endian (network byte order).
|
||||||
|
;; buf/wrap defaults to little-endian, so set it to big-endian.
|
||||||
|
(let [len-arr (nio/read-at channel chunk-start 4)
|
||||||
|
len-buf (buf/set-order (buf/wrap len-arr) ByteOrder/BIG_ENDIAN)
|
||||||
|
chunk-len (buf/read-int len-buf 0)]
|
||||||
|
(when (zero? chunk-len)
|
||||||
|
(+ chunk-start 12)))))))))))))
|
||||||
|
|
||||||
|
(defn- find-last-jpeg-eoi
|
||||||
|
"Find the byte offset of the last JPEG EOI marker (0xFF 0xD9).
|
||||||
|
Returns the offset AFTER the marker, or nil if not found."
|
||||||
|
[^FileChannel channel]
|
||||||
|
(let [size (nio/channel-size channel)]
|
||||||
|
(when (> size 2)
|
||||||
|
(let [buf-size (min (int size) (* 1024 1024))
|
||||||
|
marker (byte-array [(unchecked-byte 0xFF) (unchecked-byte 0xD9)])]
|
||||||
|
(loop [pos (max 0 (- size buf-size))]
|
||||||
|
(when (< pos size)
|
||||||
|
(let [arr (nio/read-at channel pos buf-size)
|
||||||
|
idx (scan-backwards arr marker)]
|
||||||
|
(if (neg? idx)
|
||||||
|
(let [next-pos (max 0 (- pos (- buf-size 2)))]
|
||||||
|
(when (< next-pos pos)
|
||||||
|
(recur next-pos)))
|
||||||
|
(+ pos idx 2)))))))))
|
||||||
|
|
||||||
|
(defn- find-last-gif-trailer
|
||||||
|
"Find the byte offset immediately after the last GIF trailer byte (0x3B).
|
||||||
|
Scans backwards through the file so that appended data after the real
|
||||||
|
trailer is truncated even when it ends with 0x3B.
|
||||||
|
Returns the offset AFTER the trailer byte, or nil if 0x3B is not found."
|
||||||
|
[^FileChannel channel]
|
||||||
|
(let [size (nio/channel-size channel)]
|
||||||
|
(when (pos? size)
|
||||||
|
(let [buf-size (min (int size) (* 1024 1024))
|
||||||
|
marker (byte-array [(unchecked-byte 0x3B)])]
|
||||||
|
(loop [pos (max 0 (- size buf-size))]
|
||||||
|
(when (< pos size)
|
||||||
|
(let [arr (nio/read-at channel pos buf-size)
|
||||||
|
idx (scan-backwards arr marker)]
|
||||||
|
(if (neg? idx)
|
||||||
|
(let [next-pos (max 0 (- pos (- buf-size 1)))]
|
||||||
|
(when (< next-pos pos)
|
||||||
|
(recur next-pos)))
|
||||||
|
(+ pos idx 1)))))))))
|
||||||
|
|
||||||
|
(defn- find-webp-end
|
||||||
|
"Parse the WebP RIFF header to find the declared file size.
|
||||||
|
WebP format: 'RIFF' (4 bytes) + uint32 total-size (4 bytes, little-endian)
|
||||||
|
+ 'WEBP' (4 bytes). The total size is the offset of the end of the file.
|
||||||
|
Returns nil if the RIFF or WEBP magic bytes are missing."
|
||||||
|
[^FileChannel channel]
|
||||||
|
(let [size (nio/channel-size channel)]
|
||||||
|
(when (>= size 12)
|
||||||
|
(let [^bytes arr (nio/read-at channel 0 12)
|
||||||
|
buf (buf/wrap arr)]
|
||||||
|
;; Check RIFF magic (bytes 0-3) AND WEBP FourCC (bytes 8-11)
|
||||||
|
(when (and (= (aget arr 0) (byte 0x52)) ;; 'R'
|
||||||
|
(= (aget arr 1) (byte 0x49)) ;; 'I'
|
||||||
|
(= (aget arr 2) (byte 0x46)) ;; 'F'
|
||||||
|
(= (aget arr 3) (byte 0x46)) ;; 'F'
|
||||||
|
(= (aget arr 8) (byte 0x57)) ;; 'W'
|
||||||
|
(= (aget arr 9) (byte 0x45)) ;; 'E'
|
||||||
|
(= (aget arr 10) (byte 0x42)) ;; 'B'
|
||||||
|
(= (aget arr 11) (byte 0x50))) ;; 'P'
|
||||||
|
(let [riff-size (bit-and (buf/read-int buf 4) 0xFFFFFFFF)]
|
||||||
|
;; RIFF size field is the size of the file minus 8 bytes
|
||||||
|
(+ riff-size 8)))))))
|
||||||
|
|
||||||
|
(defn truncate-after-eof
|
||||||
|
"Given a `java.nio.file.Path` to a freshly-downloaded media file and a
|
||||||
|
declared MIME type, truncate the file in place to the position of the
|
||||||
|
format's EOF marker:
|
||||||
|
- image/png → end of the IEND chunk (12 bytes: 4-byte length + 4-byte type + 4-byte CRC32)
|
||||||
|
- image/jpeg → 2 bytes after FFD9
|
||||||
|
- image/gif → immediately after the last GIF trailer byte 0x3B
|
||||||
|
- image/webp → end of RIFF chunk declared in bytes 4..8
|
||||||
|
- image/svg+xml → no-op (text format; processed by SAX parser)
|
||||||
|
- other → no-op (return path unchanged)
|
||||||
|
Returns the new file size. Raises `:validation/:invalid-image` if no
|
||||||
|
EOF marker is found within the file."
|
||||||
|
[^java.nio.file.Path path ^String mtype]
|
||||||
|
(try
|
||||||
|
(with-open [channel (nio/open-channel path)]
|
||||||
|
(let [size (nio/channel-size channel)]
|
||||||
|
(if (zero? size)
|
||||||
|
0
|
||||||
|
(let [needs-eof-marker? (or (= mtype "image/png")
|
||||||
|
(= mtype "image/jpeg")
|
||||||
|
(= mtype "image/gif")
|
||||||
|
(= mtype "image/webp"))
|
||||||
|
|
||||||
|
eof-offset
|
||||||
|
(cond
|
||||||
|
(= mtype "image/png") (find-last-png-iend channel)
|
||||||
|
(= mtype "image/jpeg") (find-last-jpeg-eoi channel)
|
||||||
|
(= mtype "image/gif") (find-last-gif-trailer channel)
|
||||||
|
(= mtype "image/webp") (find-webp-end channel)
|
||||||
|
:else nil)]
|
||||||
|
|
||||||
|
(cond
|
||||||
|
;; No EOF marker applicable (SVG or other) — no-op
|
||||||
|
(nil? eof-offset)
|
||||||
|
(if needs-eof-marker?
|
||||||
|
(ex/raise :type :validation
|
||||||
|
:code :invalid-image
|
||||||
|
:hint "image format EOF marker not found")
|
||||||
|
size)
|
||||||
|
|
||||||
|
;; Truncate if needed
|
||||||
|
(< eof-offset size)
|
||||||
|
(do
|
||||||
|
(l/dbg :hint "truncating trailing data"
|
||||||
|
:path (str path)
|
||||||
|
:mtype mtype
|
||||||
|
:original-size size
|
||||||
|
:truncated-to eof-offset)
|
||||||
|
(nio/truncate channel eof-offset)
|
||||||
|
eof-offset)
|
||||||
|
|
||||||
|
;; Already at correct size or marker at end
|
||||||
|
:else
|
||||||
|
eof-offset)))))
|
||||||
|
(catch Exception e
|
||||||
|
(if (ex/exception? e)
|
||||||
|
(throw e)
|
||||||
|
(ex/raise :type :validation
|
||||||
|
:code :invalid-image
|
||||||
|
:hint "failed to sanitize image"
|
||||||
|
:cause e)))))
|
||||||
@ -18,13 +18,13 @@
|
|||||||
(defn- request-builder
|
(defn- request-builder
|
||||||
[cfg method uri shared-key profile-id]
|
[cfg method uri shared-key profile-id]
|
||||||
(fn []
|
(fn []
|
||||||
(http/req! cfg {:method method
|
(http/req cfg {:method method
|
||||||
:headers {"content-type" "application/json"
|
:headers {"content-type" "application/json"
|
||||||
"accept" "application/json"
|
"accept" "application/json"
|
||||||
"x-shared-key" shared-key
|
"x-shared-key" shared-key
|
||||||
"x-profile-id" (str profile-id)}
|
"x-profile-id" (str profile-id)}
|
||||||
:uri uri
|
:uri uri
|
||||||
:version :http1.1})))
|
:version :http1.1})))
|
||||||
|
|
||||||
|
|
||||||
(defn- with-retries
|
(defn- with-retries
|
||||||
|
|||||||
@ -50,24 +50,27 @@
|
|||||||
(defn- validate-webhook!
|
(defn- validate-webhook!
|
||||||
[cfg whook params]
|
[cfg whook params]
|
||||||
(when (not= (:uri whook) (:uri params))
|
(when (not= (:uri whook) (:uri params))
|
||||||
(let [response (ex/try!
|
(try
|
||||||
(http/req! cfg
|
(let [response (http/req cfg
|
||||||
{:method :head
|
{:method :head
|
||||||
:uri (str (:uri params))
|
:uri (str (:uri params))
|
||||||
:timeout (ct/duration "3s")}
|
:timeout (ct/duration "3s")})]
|
||||||
{:sync? true}))]
|
|
||||||
(if (ex/exception? response)
|
|
||||||
(if-let [hint (webhooks/interpret-exception response)]
|
|
||||||
(ex/raise :type :validation
|
|
||||||
:code :webhook-validation
|
|
||||||
:hint hint)
|
|
||||||
(ex/raise :type :internal
|
|
||||||
:code :webhook-validation
|
|
||||||
:cause response))
|
|
||||||
(when-let [hint (webhooks/interpret-response response)]
|
(when-let [hint (webhooks/interpret-response response)]
|
||||||
(ex/raise :type :validation
|
(ex/raise :type :validation
|
||||||
:code :webhook-validation
|
:code :webhook-validation
|
||||||
:hint hint))))))
|
:hint hint)))
|
||||||
|
|
||||||
|
(catch Throwable cause
|
||||||
|
(if-let [hint (webhooks/interpret-exception cause)]
|
||||||
|
(ex/raise :type :validation
|
||||||
|
:code :webhook-validation
|
||||||
|
:hint hint
|
||||||
|
:webhook-uri (str (:uri params))
|
||||||
|
:cause cause)
|
||||||
|
(ex/raise :type :internal
|
||||||
|
:code :webhook-validation
|
||||||
|
:webhook-uri (str (:uri params))
|
||||||
|
:cause cause))))))
|
||||||
|
|
||||||
(defn- validate-quotes!
|
(defn- validate-quotes!
|
||||||
[{:keys [::db/pool]} {:keys [team-id]}]
|
[{:keys [::db/pool]} {:keys [team-id]}]
|
||||||
|
|||||||
@ -57,9 +57,9 @@
|
|||||||
|
|
||||||
(if (fs/exists? path)
|
(if (fs/exists? path)
|
||||||
(io/input-stream path)
|
(io/input-stream path)
|
||||||
(let [resp (http/req! cfg
|
(let [resp (http/req cfg
|
||||||
{:method :get :uri (:file-uri template)}
|
{:method :get :uri (:file-uri template)}
|
||||||
{:response-type :input-stream :sync? true})]
|
{:response-type :input-stream :sync? true})]
|
||||||
(when-not (= 200 (:status resp))
|
(when-not (= 200 (:status resp))
|
||||||
(ex/raise :type :internal
|
(ex/raise :type :internal
|
||||||
:code :unexpected-status-code
|
:code :unexpected-status-code
|
||||||
|
|||||||
@ -30,7 +30,7 @@
|
|||||||
:uri (cf/get :telemetry-uri)
|
:uri (cf/get :telemetry-uri)
|
||||||
:headers {"content-type" "application/json"}
|
:headers {"content-type" "application/json"}
|
||||||
:body (json/encode-str data)}
|
:body (json/encode-str data)}
|
||||||
response (http/req! cfg request)]
|
response (http/req cfg request)]
|
||||||
(when (> (:status response) 206)
|
(when (> (:status response) 206)
|
||||||
(ex/raise :type :internal
|
(ex/raise :type :internal
|
||||||
:code :invalid-response
|
:code :invalid-response
|
||||||
|
|||||||
91
backend/src/app/util/nio.clj
Normal file
91
backend/src/app/util/nio.clj
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
;; 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
|
||||||
|
|
||||||
|
(ns app.util.nio
|
||||||
|
"NIO helpers for working with files and byte arrays.
|
||||||
|
|
||||||
|
These are thin wrappers around java.nio that provide a
|
||||||
|
Clojure-idiomatic API. Candidates for porting to datoteka."
|
||||||
|
(:import
|
||||||
|
java.nio.ByteBuffer
|
||||||
|
java.nio.channels.FileChannel
|
||||||
|
java.nio.file.Files
|
||||||
|
java.nio.file.OpenOption
|
||||||
|
java.nio.file.Path
|
||||||
|
java.nio.file.StandardOpenOption))
|
||||||
|
|
||||||
|
(set! *warn-on-reflection* true)
|
||||||
|
|
||||||
|
;; ----------------------------------------------------------------
|
||||||
|
;; File operations (via java.nio.file.Files)
|
||||||
|
;; ----------------------------------------------------------------
|
||||||
|
|
||||||
|
(defn read-bytes
|
||||||
|
"Read all bytes from a file at `path`. Returns a byte array."
|
||||||
|
^bytes [^Path path]
|
||||||
|
(Files/readAllBytes path))
|
||||||
|
|
||||||
|
(defn write-bytes
|
||||||
|
"Write `data` (byte array) to a file at `path`, replacing existing
|
||||||
|
content. Returns `path`."
|
||||||
|
[^Path path ^bytes data]
|
||||||
|
(Files/write path data ^"[Ljava.nio.file.OpenOption;" (into-array OpenOption []))
|
||||||
|
path)
|
||||||
|
|
||||||
|
(defn append-bytes
|
||||||
|
"Append `data` (byte array) to the end of the file at `path`.
|
||||||
|
Creates the file if it does not exist. Returns `path`."
|
||||||
|
[^Path path ^bytes data]
|
||||||
|
(Files/write path data
|
||||||
|
^"[Ljava.nio.file.OpenOption;"
|
||||||
|
(into-array OpenOption
|
||||||
|
[StandardOpenOption/CREATE
|
||||||
|
StandardOpenOption/APPEND]))
|
||||||
|
path)
|
||||||
|
|
||||||
|
;; ----------------------------------------------------------------
|
||||||
|
;; FileChannel operations (internal API)
|
||||||
|
;; ----------------------------------------------------------------
|
||||||
|
|
||||||
|
(def ^:private read-write-opts
|
||||||
|
(into-array OpenOption
|
||||||
|
[StandardOpenOption/READ StandardOpenOption/WRITE]))
|
||||||
|
|
||||||
|
(defn open-channel
|
||||||
|
"Open a FileChannel for read/write on the given path."
|
||||||
|
^FileChannel [^Path path]
|
||||||
|
(FileChannel/open path read-write-opts))
|
||||||
|
|
||||||
|
(defn channel-size
|
||||||
|
"Return the size of the file backed by the channel."
|
||||||
|
^long [^FileChannel channel]
|
||||||
|
(.size channel))
|
||||||
|
|
||||||
|
(defn read-at
|
||||||
|
"Read `length` bytes from `channel` starting at `position` into a
|
||||||
|
new byte array. Returns the byte array.
|
||||||
|
Loops until the ByteBuffer is fully populated to guard against OS
|
||||||
|
partial reads, which would otherwise cause BufferUnderflowException
|
||||||
|
when copying from the buffer into the result array."
|
||||||
|
^bytes [^FileChannel channel ^long position ^long length]
|
||||||
|
(let [buf (ByteBuffer/allocate (int length))]
|
||||||
|
(.position channel position)
|
||||||
|
(loop []
|
||||||
|
(when (.hasRemaining buf)
|
||||||
|
(let [n (.read channel buf)]
|
||||||
|
(when (pos? n)
|
||||||
|
(recur)))))
|
||||||
|
(.flip buf)
|
||||||
|
(let [remaining (.remaining buf)
|
||||||
|
arr (byte-array remaining)]
|
||||||
|
(.get buf arr)
|
||||||
|
arr)))
|
||||||
|
|
||||||
|
(defn truncate
|
||||||
|
"Truncate the file to the given size. Returns the channel."
|
||||||
|
[^FileChannel channel ^long size]
|
||||||
|
(.truncate channel size)
|
||||||
|
channel)
|
||||||
229
backend/src/app/util/ssrf.clj
Normal file
229
backend/src/app/util/ssrf.clj
Normal file
@ -0,0 +1,229 @@
|
|||||||
|
;; 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
|
||||||
|
|
||||||
|
(ns app.util.ssrf
|
||||||
|
"URL/host validation to prevent Server-Side Request Forgery."
|
||||||
|
(:require
|
||||||
|
[app.common.exceptions :as ex]
|
||||||
|
[app.common.logging :as l]
|
||||||
|
[app.config :as cf]
|
||||||
|
[cuerdas.core :as str])
|
||||||
|
(:import
|
||||||
|
com.google.common.net.InetAddresses
|
||||||
|
java.net.InetAddress
|
||||||
|
java.net.UnknownHostException
|
||||||
|
java.net.URI))
|
||||||
|
|
||||||
|
(def ^:private allowed-schemes
|
||||||
|
#{"http" "https"})
|
||||||
|
|
||||||
|
(def ^:private cloud-metadata-ips
|
||||||
|
"Exact IP addresses for cloud metadata services."
|
||||||
|
#{"169.254.169.254"
|
||||||
|
"fd00:ec2::254"})
|
||||||
|
|
||||||
|
(def ^:private extra-blocked-ranges
|
||||||
|
"CIDR ranges not covered by standard JDK InetAddress predicates.
|
||||||
|
Each entry is [base-address prefix-length]."
|
||||||
|
;; Carrier-grade NAT
|
||||||
|
[[100 64 0 0 10]
|
||||||
|
;; RFC 6890 / documentation / reserved
|
||||||
|
[192 0 0 0 24]
|
||||||
|
[192 0 2 0 24]
|
||||||
|
[198 18 0 0 15]
|
||||||
|
[198 51 100 0 24]
|
||||||
|
[203 0 113 0 24]
|
||||||
|
;; Reserved / future-use (broadcast and above)
|
||||||
|
[240 0 0 0 4]])
|
||||||
|
|
||||||
|
(defn- ip4-to-long
|
||||||
|
"Convert a 4-element byte array (IPv4) to a 32-bit long."
|
||||||
|
^long [^bytes bs]
|
||||||
|
(bit-or (bit-shift-left (bit-and (aget bs 0) 0xFF) 24)
|
||||||
|
(bit-shift-left (bit-and (aget bs 1) 0xFF) 16)
|
||||||
|
(bit-shift-left (bit-and (aget bs 2) 0xFF) 8)
|
||||||
|
(bit-and (aget bs 3) 0xFF)))
|
||||||
|
|
||||||
|
(defn- prefix-mask
|
||||||
|
"Return a 32-bit mask for the given prefix length."
|
||||||
|
^long [^long prefix-len]
|
||||||
|
(if (zero? prefix-len)
|
||||||
|
0
|
||||||
|
(bit-shift-left (unsigned-bit-shift-right 0xFFFFFFFF (- 32 prefix-len)) (- 32 prefix-len))))
|
||||||
|
|
||||||
|
(defn- in-cidr4?
|
||||||
|
"Check if an IPv4 address (as byte array) falls within a CIDR range
|
||||||
|
specified as [a b c d prefix-len]."
|
||||||
|
[^bytes addr [^long a ^long b ^long c ^long d ^long prefix-len]]
|
||||||
|
(let [base (bit-or (bit-shift-left (bit-and a 0xFF) 24)
|
||||||
|
(bit-shift-left (bit-and b 0xFF) 16)
|
||||||
|
(bit-shift-left (bit-and c 0xFF) 8)
|
||||||
|
(bit-and d 0xFF))
|
||||||
|
mask (prefix-mask prefix-len)
|
||||||
|
ip-val (ip4-to-long addr)]
|
||||||
|
(= (bit-and ip-val mask) (bit-and base mask))))
|
||||||
|
|
||||||
|
(defn- parse-cidr*
|
||||||
|
"Parse a CIDR string like '10.0.0.0/8' into [a b c d prefix-len]. Throws on invalid input."
|
||||||
|
[^String cidr]
|
||||||
|
(let [parts (str/split cidr #"/" 2)
|
||||||
|
prefix-len (when (= 2 (count parts))
|
||||||
|
(parse-long (nth parts 1)))]
|
||||||
|
(when-not prefix-len
|
||||||
|
(ex/raise :type :internal
|
||||||
|
:code :invalid-cidr
|
||||||
|
:hint (str "invalid CIDR notation: " cidr)))
|
||||||
|
(let [octets (str/split (first parts) #"\.")]
|
||||||
|
(when (not= 4 (count octets))
|
||||||
|
(ex/raise :type :internal
|
||||||
|
:code :invalid-cidr
|
||||||
|
:hint (str "invalid CIDR notation (expected IPv4): " cidr)))
|
||||||
|
(let [[a b c d] (map parse-long octets)]
|
||||||
|
(when (or (nil? a) (nil? b) (nil? c) (nil? d)
|
||||||
|
(not (<= 0 a 255)) (not (<= 0 b 255))
|
||||||
|
(not (<= 0 c 255)) (not (<= 0 d 255))
|
||||||
|
(not (<= 0 prefix-len 32)))
|
||||||
|
(ex/raise :type :internal
|
||||||
|
:code :invalid-cidr
|
||||||
|
:hint (str "invalid CIDR notation: " cidr)))
|
||||||
|
[a b c d prefix-len]))))
|
||||||
|
|
||||||
|
(defn parse-cidr
|
||||||
|
"Parse a CIDR string like '10.0.0.0/8' into [a b c d prefix-len].
|
||||||
|
Returns nil and logs a warning on invalid input."
|
||||||
|
[^String cidr]
|
||||||
|
(try
|
||||||
|
(parse-cidr* cidr)
|
||||||
|
(catch Exception _
|
||||||
|
(l/warn :hint "ignoring invalid CIDR" :cidr cidr)
|
||||||
|
nil)))
|
||||||
|
|
||||||
|
(defonce ^:dynamic extra-blocked-cidrs
|
||||||
|
(into #{} (keep parse-cidr) (cf/get :ssrf-extra-blocked-cidrs #{})))
|
||||||
|
|
||||||
|
(defn- ipv6-ula?
|
||||||
|
"Check if an IPv6 address is in the Unique Local Address range (fc00::/7)."
|
||||||
|
[^InetAddress addr]
|
||||||
|
(let [bs (.getAddress addr)]
|
||||||
|
(and (>= (alength bs) 16)
|
||||||
|
(= (bit-and (aget bs 0) 0xFE) 0xFC))))
|
||||||
|
|
||||||
|
(defn- ipv4-mapped-loopback?
|
||||||
|
"Check if an IPv4-mapped IPv6 address maps to loopback (::ffff:127.x.x.x)."
|
||||||
|
[^InetAddress addr]
|
||||||
|
(let [bs (.getAddress addr)]
|
||||||
|
(and (= (alength bs) 16)
|
||||||
|
;; Check it's an IPv4-mapped address: ::ffff:x.x.x.x
|
||||||
|
(= (aget bs 10) (byte -1)) ;; 0xFF
|
||||||
|
(= (aget bs 11) (byte -1)) ;; 0xFF
|
||||||
|
;; Check the embedded IPv4 is loopback (127.x.x.x)
|
||||||
|
(= (bit-and (aget bs 12) 0xFF) 127))))
|
||||||
|
|
||||||
|
(defn- blocked-address?
|
||||||
|
"Check if an InetAddress should be blocked. Returns true if blocked."
|
||||||
|
[^InetAddress addr]
|
||||||
|
(or
|
||||||
|
(.isAnyLocalAddress addr) ;; 0.0.0.0 or ::
|
||||||
|
(.isLoopbackAddress addr) ;; 127/8 or ::1
|
||||||
|
(.isLinkLocalAddress addr) ;; 169.254/16 or fe80::/10
|
||||||
|
(.isSiteLocalAddress addr) ;; 10/8, 172.16/12, 192.168/16
|
||||||
|
(.isMulticastAddress addr)
|
||||||
|
|
||||||
|
;; IPv6 ULA (fc00::/7)
|
||||||
|
(ipv6-ula? addr)
|
||||||
|
|
||||||
|
;; IPv4-mapped loopback
|
||||||
|
(ipv4-mapped-loopback? addr)
|
||||||
|
|
||||||
|
;; Cloud metadata IPs (exact match)
|
||||||
|
(contains? cloud-metadata-ips (.getHostAddress addr))
|
||||||
|
|
||||||
|
;; Extra blocked CIDRs (IPv4 only)
|
||||||
|
(let [bs (.getAddress addr)]
|
||||||
|
(if (= (alength bs) 4)
|
||||||
|
(or (some #(in-cidr4? bs %) extra-blocked-ranges)
|
||||||
|
(some #(in-cidr4? bs %) extra-blocked-cidrs))
|
||||||
|
false))))
|
||||||
|
|
||||||
|
(defn resolve-host
|
||||||
|
"Resolve a hostname to all InetAddress objects. Wraps InetAddress/getAllByName
|
||||||
|
so it can be stubbed in tests."
|
||||||
|
[^String hostname]
|
||||||
|
(try
|
||||||
|
(InetAddress/getAllByName hostname)
|
||||||
|
(catch UnknownHostException _
|
||||||
|
nil)))
|
||||||
|
|
||||||
|
(defn validate-uri
|
||||||
|
"Validates `uri-or-string`:
|
||||||
|
- scheme must be http or https,
|
||||||
|
- host must resolve to at least one address, and
|
||||||
|
- **every** resolved address must NOT be in the blocklist
|
||||||
|
(loopback, link-local, site-local, multicast, any-local,
|
||||||
|
cloud-metadata 169.254.169.254, IPv6 ULA fc00::/7, IPv4-mapped
|
||||||
|
IPv6 of any blocked IPv4, plus operator-supplied CIDRs).
|
||||||
|
When the host is an IP literal (decimal/octal/hex/IPv6) it is
|
||||||
|
normalized via `com.google.common.net.InetAddresses` before the
|
||||||
|
check.
|
||||||
|
Hosts in `:ssrf-allowed-hosts` (case-insensitive exact match) bypass
|
||||||
|
the IP check.
|
||||||
|
Throws `ex/raise :type :validation :code :ssrf-blocked-target` with
|
||||||
|
a hint that does NOT echo the resolved IP (avoid info leak)."
|
||||||
|
[uri-or-string]
|
||||||
|
(let [uri (if (instance? URI uri-or-string)
|
||||||
|
uri-or-string
|
||||||
|
(URI. (str uri-or-string)))
|
||||||
|
scheme (.getScheme uri)
|
||||||
|
host (.getHost uri)]
|
||||||
|
|
||||||
|
;; Validate scheme
|
||||||
|
(when (or (nil? scheme)
|
||||||
|
(not (contains? allowed-schemes (str/lower scheme))))
|
||||||
|
(ex/raise :type :validation
|
||||||
|
:code :ssrf-blocked-target
|
||||||
|
:hint "url scheme is not allowed"))
|
||||||
|
|
||||||
|
;; Validate host presence
|
||||||
|
(when (or (nil? host) (str/blank? host))
|
||||||
|
(ex/raise :type :validation
|
||||||
|
:code :ssrf-blocked-target
|
||||||
|
:hint "url host is missing"))
|
||||||
|
|
||||||
|
;; Check allowlist
|
||||||
|
(let [allowed-hosts (cf/get :ssrf-allowed-hosts #{})
|
||||||
|
host-lower (str/lower host)]
|
||||||
|
|
||||||
|
(when-not (contains? allowed-hosts host-lower)
|
||||||
|
;; Normalize the host: if it looks like an IP literal, normalize it
|
||||||
|
;; via Guava to catch decimal/octal/hex encodings
|
||||||
|
(let [normalized (if (InetAddresses/isInetAddress host)
|
||||||
|
(InetAddresses/forString host)
|
||||||
|
nil)
|
||||||
|
host-to-resolve (if normalized
|
||||||
|
(.getHostAddress ^InetAddress normalized)
|
||||||
|
host)
|
||||||
|
addresses (resolve-host host-to-resolve)]
|
||||||
|
|
||||||
|
(when (or (nil? addresses) (zero? (alength addresses)))
|
||||||
|
(ex/raise :type :validation
|
||||||
|
:code :ssrf-blocked-target
|
||||||
|
:hint "url host could not be resolved"))
|
||||||
|
|
||||||
|
;; All-or-nothing: if ANY resolved address is blocked, reject
|
||||||
|
(when (some blocked-address? (seq addresses))
|
||||||
|
(ex/raise :type :validation
|
||||||
|
:code :ssrf-blocked-target
|
||||||
|
:hint "url target is not allowed")))))
|
||||||
|
(str uri)))
|
||||||
|
|
||||||
|
(defn safe-url?
|
||||||
|
"Predicate version of `validate-uri`. Returns `true` if safe."
|
||||||
|
[uri-or-string]
|
||||||
|
(try
|
||||||
|
(validate-uri uri-or-string)
|
||||||
|
true
|
||||||
|
(catch Exception _
|
||||||
|
false)))
|
||||||
@ -41,7 +41,7 @@
|
|||||||
(t/is (nil? res)))))
|
(t/is (nil? res)))))
|
||||||
|
|
||||||
(t/deftest run-webhook-handler-1
|
(t/deftest run-webhook-handler-1
|
||||||
(with-mocks [http-mock {:target 'app.http.client/req! :return {:status 200}}]
|
(with-mocks [http-mock {:target 'app.http.client/req :return {:status 200}}]
|
||||||
(let [prof (th/create-profile* 1 {:is-active true})
|
(let [prof (th/create-profile* 1 {:is-active true})
|
||||||
whk (th/create-webhook* {:team-id (:default-team-id prof)})
|
whk (th/create-webhook* {:team-id (:default-team-id prof)})
|
||||||
evt {:type "command"
|
evt {:type "command"
|
||||||
@ -63,7 +63,7 @@
|
|||||||
(t/is (nil? (:error-code whk')))))))
|
(t/is (nil? (:error-code whk')))))))
|
||||||
|
|
||||||
(t/deftest run-webhook-handler-2
|
(t/deftest run-webhook-handler-2
|
||||||
(with-mocks [http-mock {:target 'app.http.client/req! :return {:status 400}}]
|
(with-mocks [http-mock {:target 'app.http.client/req :return {:status 400}}]
|
||||||
(let [prof (th/create-profile* 1 {:is-active true})
|
(let [prof (th/create-profile* 1 {:is-active true})
|
||||||
whk (th/create-webhook* {:team-id (:default-team-id prof)})
|
whk (th/create-webhook* {:team-id (:default-team-id prof)})
|
||||||
evt {:type "command"
|
evt {:type "command"
|
||||||
|
|||||||
501
backend/test/backend_tests/media_sanitize_test.clj
Normal file
501
backend/test/backend_tests/media_sanitize_test.clj
Normal file
@ -0,0 +1,501 @@
|
|||||||
|
;; 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
|
||||||
|
|
||||||
|
(ns backend-tests.media-sanitize-test
|
||||||
|
(:require
|
||||||
|
[app.media.sanitize :as sanitize]
|
||||||
|
[app.storage.tmp :as tmp]
|
||||||
|
[app.util.nio :as nio]
|
||||||
|
[clojure.test :as t]
|
||||||
|
[datoteka.fs :as fs]
|
||||||
|
[datoteka.io :as io]))
|
||||||
|
|
||||||
|
(defn- resource-path
|
||||||
|
"Return a URL to a test resource file."
|
||||||
|
[name]
|
||||||
|
(io/resource (str "backend_tests/test_files/" name)))
|
||||||
|
|
||||||
|
(defn- copy-resource-to-tempfile
|
||||||
|
"Copy a test resource file to a tempfile and return the Path."
|
||||||
|
[resource-name suffix]
|
||||||
|
(tmp/tempfile-from (resource-path resource-name) :prefix "test-real-" :suffix suffix))
|
||||||
|
|
||||||
|
;; ----------------------------------------------------------------
|
||||||
|
;; Crafted test data
|
||||||
|
;; ----------------------------------------------------------------
|
||||||
|
|
||||||
|
;; PNG test data
|
||||||
|
(def ^:private png-signature
|
||||||
|
(byte-array [0x89 0x50 0x4E 0x47 0x0D 0x0A 0x1A 0x0A]))
|
||||||
|
|
||||||
|
(def ^:private png-iend-chunk
|
||||||
|
(byte-array [0x00 0x00 0x00 0x00 0x49 0x45 0x4E 0x44 0xAE 0x42 0x60 0x82]))
|
||||||
|
|
||||||
|
(def ^:private png-ihdr-chunk
|
||||||
|
(byte-array [0x00 0x00 0x00 0x0D 0x49 0x48 0x44 0x52
|
||||||
|
0x00 0x00 0x00 0x01 0x00 0x00 0x00 0x01
|
||||||
|
0x08 0x02 0x00 0x00 0x00 0x90 0x77 0x53 0xDE]))
|
||||||
|
|
||||||
|
(defn- make-png [^bytes extra-bytes]
|
||||||
|
(let [parts (if extra-bytes
|
||||||
|
[png-signature png-ihdr-chunk png-iend-chunk extra-bytes]
|
||||||
|
[png-signature png-ihdr-chunk png-iend-chunk])
|
||||||
|
total (reduce + 0 (map alength parts))
|
||||||
|
result (byte-array total)
|
||||||
|
offset (volatile! 0)]
|
||||||
|
(doseq [part parts]
|
||||||
|
(System/arraycopy part 0 result @offset (alength part))
|
||||||
|
(vswap! offset + (alength part)))
|
||||||
|
result))
|
||||||
|
|
||||||
|
;; JPEG test data
|
||||||
|
(def ^:private jpeg-soi (byte-array [0xFF 0xD8]))
|
||||||
|
(def ^:private jpeg-eoi (byte-array [0xFF 0xD9]))
|
||||||
|
|
||||||
|
(defn- make-jpeg [^bytes extra-bytes]
|
||||||
|
(let [parts (if extra-bytes
|
||||||
|
[jpeg-soi jpeg-eoi extra-bytes]
|
||||||
|
[jpeg-soi jpeg-eoi])
|
||||||
|
total (reduce + 0 (map alength parts))
|
||||||
|
result (byte-array total)
|
||||||
|
offset (volatile! 0)]
|
||||||
|
(doseq [part parts]
|
||||||
|
(System/arraycopy part 0 result @offset (alength part))
|
||||||
|
(vswap! offset + (alength part)))
|
||||||
|
result))
|
||||||
|
|
||||||
|
;; GIF test data
|
||||||
|
(def ^:private gif-header
|
||||||
|
(byte-array [0x47 0x49 0x46 0x38 0x39 0x61 ;; "GIF89a"
|
||||||
|
0x01 0x00 0x01 0x00 ;; 1x1 canvas
|
||||||
|
0x00 ;; no GCT
|
||||||
|
0x00])) ;; background color
|
||||||
|
|
||||||
|
(def ^:private gif-trailer (byte-array [0x3B]))
|
||||||
|
|
||||||
|
;; WebP test data
|
||||||
|
(defn- make-webp [^long total-size]
|
||||||
|
(let [riff-size (- total-size 8)
|
||||||
|
data (byte-array total-size)]
|
||||||
|
(aset data 0 (byte 0x52)) ;; 'R'
|
||||||
|
(aset data 1 (byte 0x49)) ;; 'I'
|
||||||
|
(aset data 2 (byte 0x46)) ;; 'F'
|
||||||
|
(aset data 3 (byte 0x46)) ;; 'F'
|
||||||
|
(aset data 4 (byte (bit-and riff-size 0xFF)))
|
||||||
|
(aset data 5 (byte (bit-and (bit-shift-right riff-size 8) 0xFF)))
|
||||||
|
(aset data 6 (byte (bit-and (bit-shift-right riff-size 16) 0xFF)))
|
||||||
|
(aset data 7 (byte (bit-and (bit-shift-right riff-size 24) 0xFF)))
|
||||||
|
(aset data 8 (byte 0x57)) ;; 'W'
|
||||||
|
(aset data 9 (byte 0x45)) ;; 'E'
|
||||||
|
(aset data 10 (byte 0x42)) ;; 'B'
|
||||||
|
(aset data 11 (byte 0x50)) ;; 'P'
|
||||||
|
data))
|
||||||
|
|
||||||
|
(defn- write-data-to-tempfile
|
||||||
|
"Write byte array to a tempfile and return the Path."
|
||||||
|
[^bytes data suffix]
|
||||||
|
(let [path (tmp/tempfile :prefix "test-sanitize." :suffix suffix)]
|
||||||
|
(nio/write-bytes path data)
|
||||||
|
path))
|
||||||
|
|
||||||
|
;; ----------------------------------------------------------------
|
||||||
|
;; Tests with crafted data
|
||||||
|
;; ----------------------------------------------------------------
|
||||||
|
|
||||||
|
(t/deftest png-with-appended-secret-truncated
|
||||||
|
(let [secret (.getBytes "SECRET_DATA_HERE")
|
||||||
|
data (make-png secret)
|
||||||
|
path (write-data-to-tempfile data ".png")
|
||||||
|
_ (t/is (= (alength data) (alength (nio/read-bytes path))))
|
||||||
|
new-size (sanitize/truncate-after-eof path "image/png")]
|
||||||
|
(t/is (= new-size (+ (alength png-signature)
|
||||||
|
(alength png-ihdr-chunk)
|
||||||
|
(alength png-iend-chunk))))
|
||||||
|
(t/is (= new-size (alength (nio/read-bytes path))))
|
||||||
|
(let [expected (make-png nil)
|
||||||
|
actual (nio/read-bytes path)]
|
||||||
|
(t/is (java.util.Arrays/equals expected actual)))))
|
||||||
|
|
||||||
|
(t/deftest png-clean-not-truncated
|
||||||
|
(let [data (make-png nil)
|
||||||
|
path (write-data-to-tempfile data ".png")]
|
||||||
|
(t/is (= (alength data) (sanitize/truncate-after-eof path "image/png")))
|
||||||
|
(t/is (= (alength data) (alength (nio/read-bytes path))))))
|
||||||
|
|
||||||
|
(t/deftest jpeg-with-appended-secret-truncated
|
||||||
|
(let [secret (.getBytes "\u0000\u0000SECRET")
|
||||||
|
data (make-jpeg secret)
|
||||||
|
path (write-data-to-tempfile data ".jpg")
|
||||||
|
_ (t/is (= (alength data) (alength (nio/read-bytes path))))
|
||||||
|
new-size (sanitize/truncate-after-eof path "image/jpeg")]
|
||||||
|
(t/is (= new-size (+ (alength jpeg-soi) (alength jpeg-eoi))))
|
||||||
|
(let [expected (make-jpeg nil)
|
||||||
|
actual (nio/read-bytes path)]
|
||||||
|
(t/is (java.util.Arrays/equals expected actual)))))
|
||||||
|
|
||||||
|
(t/deftest jpeg-clean-not-truncated
|
||||||
|
(let [data (make-jpeg nil)
|
||||||
|
path (write-data-to-tempfile data ".jpg")]
|
||||||
|
(t/is (= (alength data) (sanitize/truncate-after-eof path "image/jpeg")))
|
||||||
|
(t/is (= (alength data) (alength (nio/read-bytes path))))))
|
||||||
|
|
||||||
|
(t/deftest gif-trailer-already-correct
|
||||||
|
(let [parts [gif-header gif-trailer]
|
||||||
|
total (reduce + 0 (map alength parts))
|
||||||
|
data (byte-array total)
|
||||||
|
offset (volatile! 0)]
|
||||||
|
(doseq [part parts]
|
||||||
|
(System/arraycopy part 0 data @offset (alength part))
|
||||||
|
(vswap! offset + (alength part)))
|
||||||
|
(let [path (write-data-to-tempfile data ".gif")]
|
||||||
|
(t/is (= total (sanitize/truncate-after-eof path "image/gif")))
|
||||||
|
(t/is (= total (alength (nio/read-bytes path)))))))
|
||||||
|
|
||||||
|
(t/deftest webp-declared-size-honored
|
||||||
|
(let [total-size 24
|
||||||
|
data (make-webp total-size)
|
||||||
|
extra (byte-array 10 (byte 0x42))
|
||||||
|
full-data (byte-array (+ total-size 10))]
|
||||||
|
(System/arraycopy data 0 full-data 0 total-size)
|
||||||
|
(System/arraycopy extra 0 full-data total-size 10)
|
||||||
|
(let [path (write-data-to-tempfile full-data ".webp")]
|
||||||
|
(t/is (= total-size (sanitize/truncate-after-eof path "image/webp")))
|
||||||
|
(t/is (= total-size (alength (nio/read-bytes path)))))))
|
||||||
|
|
||||||
|
(t/deftest webp-clean-not-truncated
|
||||||
|
(let [data (make-webp 24)
|
||||||
|
path (write-data-to-tempfile data ".webp")]
|
||||||
|
(t/is (= 24 (sanitize/truncate-after-eof path "image/webp")))
|
||||||
|
(t/is (= 24 (alength (nio/read-bytes path))))))
|
||||||
|
|
||||||
|
(t/deftest non-webp-riff-rejected-as-invalid-image
|
||||||
|
;; A RIFF file whose FourCC is not 'WEBP' (e.g. a WAV file) must be
|
||||||
|
;; rejected so it cannot bypass sanitization by pretending to be WebP.
|
||||||
|
(let [data (byte-array 24)]
|
||||||
|
;; Write RIFF magic
|
||||||
|
(aset data 0 (byte 0x52)) ;; 'R'
|
||||||
|
(aset data 1 (byte 0x49)) ;; 'I'
|
||||||
|
(aset data 2 (byte 0x46)) ;; 'F'
|
||||||
|
(aset data 3 (byte 0x46)) ;; 'F'
|
||||||
|
;; RIFF size = 16 (total 24 - 8)
|
||||||
|
(aset data 4 (byte 16))
|
||||||
|
;; FourCC = 'WAVE' (not 'WEBP')
|
||||||
|
(aset data 8 (byte 0x57)) ;; 'W'
|
||||||
|
(aset data 9 (byte 0x41)) ;; 'A'
|
||||||
|
(aset data 10 (byte 0x56)) ;; 'V'
|
||||||
|
(aset data 11 (byte 0x45)) ;; 'E'
|
||||||
|
(let [path (write-data-to-tempfile data ".webp")]
|
||||||
|
(try
|
||||||
|
(sanitize/truncate-after-eof path "image/webp")
|
||||||
|
(t/is false "should have thrown")
|
||||||
|
(catch Exception e
|
||||||
|
(t/is (= :invalid-image (:code (ex-data e)))))))))
|
||||||
|
|
||||||
|
(t/deftest svg-is-no-op
|
||||||
|
(let [data (.getBytes "<svg><rect/></svg>")
|
||||||
|
path (write-data-to-tempfile data ".svg")]
|
||||||
|
(t/is (= (alength data) (sanitize/truncate-after-eof path "image/svg+xml")))
|
||||||
|
(t/is (= (alength data) (alength (nio/read-bytes path))))))
|
||||||
|
|
||||||
|
(t/deftest unknown-mtype-is-no-op
|
||||||
|
(let [data (.getBytes "some binary data")
|
||||||
|
path (write-data-to-tempfile data ".bin")]
|
||||||
|
(t/is (= (alength data) (sanitize/truncate-after-eof path "application/octet-stream")))
|
||||||
|
(t/is (= (alength data) (alength (nio/read-bytes path))))))
|
||||||
|
|
||||||
|
(t/deftest png-missing-iend-raises-error
|
||||||
|
(let [data (byte-array [0x89 0x50 0x4E 0x47 0x0D 0x0A 0x1A 0x0A
|
||||||
|
0x00 0x00 0x00 0x0D 0x49 0x48 0x44 0x52
|
||||||
|
0x00 0x00 0x00 0x01 0x00 0x00 0x00 0x01
|
||||||
|
0x08 0x02 0x00 0x00 0x00 0x90 0x77 0x53 0xDE])
|
||||||
|
path (write-data-to-tempfile data ".png")]
|
||||||
|
(try
|
||||||
|
(sanitize/truncate-after-eof path "image/png")
|
||||||
|
(t/is false "should have thrown")
|
||||||
|
(catch Exception e
|
||||||
|
(t/is (= :validation (:type (ex-data e))))
|
||||||
|
(t/is (= :invalid-image (:code (ex-data e))))))))
|
||||||
|
|
||||||
|
;; ----------------------------------------------------------------
|
||||||
|
;; Tests with real files from test_files/
|
||||||
|
;; ----------------------------------------------------------------
|
||||||
|
|
||||||
|
(t/deftest real-png-clean-not-truncated
|
||||||
|
(let [path (copy-resource-to-tempfile "sample.png" ".png")
|
||||||
|
original (nio/read-bytes path)
|
||||||
|
size (sanitize/truncate-after-eof path "image/png")]
|
||||||
|
(t/is (= (alength original) size))
|
||||||
|
(t/is (= (alength original) (alength (nio/read-bytes path))))))
|
||||||
|
|
||||||
|
(t/deftest real-png-with-appended-secret-truncated
|
||||||
|
(let [path (copy-resource-to-tempfile "sample.png" ".png")
|
||||||
|
original (nio/read-bytes path)
|
||||||
|
orig-size (alength original)
|
||||||
|
secret (.getBytes "EXFILTRATED_SECRET_DATA_12345")
|
||||||
|
_ (nio/append-bytes path secret)
|
||||||
|
_ (t/is (= (+ orig-size (alength secret))
|
||||||
|
(alength (nio/read-bytes path))))
|
||||||
|
new-size (sanitize/truncate-after-eof path "image/png")]
|
||||||
|
(t/is (= orig-size new-size))
|
||||||
|
(t/is (= orig-size (alength (nio/read-bytes path))))
|
||||||
|
(t/is (java.util.Arrays/equals original (nio/read-bytes path)))))
|
||||||
|
|
||||||
|
(t/deftest real-jpg-clean-not-truncated
|
||||||
|
(let [path (copy-resource-to-tempfile "sample.jpg" ".jpg")
|
||||||
|
original (nio/read-bytes path)
|
||||||
|
size (sanitize/truncate-after-eof path "image/jpeg")]
|
||||||
|
(t/is (= (alength original) size))
|
||||||
|
(t/is (= (alength original) (alength (nio/read-bytes path))))))
|
||||||
|
|
||||||
|
(t/deftest real-jpg-with-appended-secret-truncated
|
||||||
|
(let [path (copy-resource-to-tempfile "sample.jpg" ".jpg")
|
||||||
|
original (nio/read-bytes path)
|
||||||
|
orig-size (alength original)
|
||||||
|
secret (.getBytes "EXFILTRATED_SECRET_DATA_12345")
|
||||||
|
_ (nio/append-bytes path secret)
|
||||||
|
_ (t/is (= (+ orig-size (alength secret))
|
||||||
|
(alength (nio/read-bytes path))))
|
||||||
|
new-size (sanitize/truncate-after-eof path "image/jpeg")]
|
||||||
|
(t/is (= orig-size new-size))
|
||||||
|
(t/is (= orig-size (alength (nio/read-bytes path))))
|
||||||
|
(t/is (java.util.Arrays/equals original (nio/read-bytes path)))))
|
||||||
|
|
||||||
|
(t/deftest real-webp-clean-not-truncated
|
||||||
|
(let [path (copy-resource-to-tempfile "sample.webp" ".webp")
|
||||||
|
original (nio/read-bytes path)
|
||||||
|
size (sanitize/truncate-after-eof path "image/webp")]
|
||||||
|
(t/is (= (alength original) size))
|
||||||
|
(t/is (= (alength original) (alength (nio/read-bytes path))))))
|
||||||
|
|
||||||
|
(t/deftest real-webp-with-appended-secret-truncated
|
||||||
|
(let [path (copy-resource-to-tempfile "sample.webp" ".webp")
|
||||||
|
original (nio/read-bytes path)
|
||||||
|
orig-size (alength original)
|
||||||
|
secret (.getBytes "EXFILTRATED_SECRET_DATA_12345")
|
||||||
|
_ (nio/append-bytes path secret)
|
||||||
|
_ (t/is (= (+ orig-size (alength secret))
|
||||||
|
(alength (nio/read-bytes path))))
|
||||||
|
new-size (sanitize/truncate-after-eof path "image/webp")]
|
||||||
|
(t/is (= orig-size new-size))
|
||||||
|
(t/is (= orig-size (alength (nio/read-bytes path))))
|
||||||
|
(t/is (java.util.Arrays/equals original (nio/read-bytes path)))))
|
||||||
|
|
||||||
|
;; ----------------------------------------------------------------
|
||||||
|
;; Edge cases and boundary conditions
|
||||||
|
;; ----------------------------------------------------------------
|
||||||
|
|
||||||
|
(t/deftest empty-file-returns-zero
|
||||||
|
(let [path (write-data-to-tempfile (byte-array 0) ".png")]
|
||||||
|
(t/is (zero? (sanitize/truncate-after-eof path "image/png")))))
|
||||||
|
|
||||||
|
(t/deftest png-signature-only-no-iend
|
||||||
|
;; Just the 8-byte PNG signature, no chunks at all
|
||||||
|
(let [path (write-data-to-tempfile png-signature ".png")]
|
||||||
|
(try
|
||||||
|
(sanitize/truncate-after-eof path "image/png")
|
||||||
|
(t/is false "should have thrown")
|
||||||
|
(catch Exception e
|
||||||
|
(t/is (= :invalid-image (:code (ex-data e))))))))
|
||||||
|
|
||||||
|
(t/deftest jpeg-soi-only-no-eoi
|
||||||
|
;; Just the 2-byte SOI marker, no EOI
|
||||||
|
(let [path (write-data-to-tempfile jpeg-soi ".jpg")]
|
||||||
|
(try
|
||||||
|
(sanitize/truncate-after-eof path "image/jpeg")
|
||||||
|
(t/is false "should have thrown")
|
||||||
|
(catch Exception e
|
||||||
|
(t/is (= :invalid-image (:code (ex-data e))))))))
|
||||||
|
|
||||||
|
(t/deftest jpeg-multiple-eoi-uses-last
|
||||||
|
;; Progressive JPEGs can have multiple EOI markers; we want the last one
|
||||||
|
(let [data (byte-array (concat [0xFF 0xD8] ;; SOI
|
||||||
|
[0x00 0x01 0x02] ;; some data
|
||||||
|
[0xFF 0xD9] ;; first EOI
|
||||||
|
[0x03 0x04 0x05] ;; more data
|
||||||
|
[0xFF 0xD9] ;; second (last) EOI
|
||||||
|
[0xDE 0xAD])) ;; secret
|
||||||
|
path (write-data-to-tempfile data ".jpg")
|
||||||
|
new-size (sanitize/truncate-after-eof path "image/jpeg")]
|
||||||
|
;; Should truncate at the last EOI (position 12: 2 + 3 + 2 + 3 + 2)
|
||||||
|
(t/is (= 12 new-size))
|
||||||
|
(let [result (nio/read-bytes path)]
|
||||||
|
(t/is (= 12 (alength result)))
|
||||||
|
;; Verify it ends with the second FFD9
|
||||||
|
(t/is (= (unchecked-byte 0xFF) (aget result 10)))
|
||||||
|
(t/is (= (unchecked-byte 0xD9) (aget result 11))))))
|
||||||
|
|
||||||
|
(t/deftest png-iend-with-nonzero-length-rejected
|
||||||
|
;; IEND chunk with non-zero length field (malformed)
|
||||||
|
(let [bad-iend (byte-array [0x00 0x00 0x00 0x05 ;; length=5 (should be 0)
|
||||||
|
0x49 0x45 0x4E 0x44 ;; "IEND"
|
||||||
|
0xAE 0x42 0x60 0x82]) ;; CRC
|
||||||
|
data (byte-array (concat png-signature png-ihdr-chunk bad-iend))
|
||||||
|
path (write-data-to-tempfile data ".png")]
|
||||||
|
(try
|
||||||
|
(sanitize/truncate-after-eof path "image/png")
|
||||||
|
(t/is false "should have thrown")
|
||||||
|
(catch Exception e
|
||||||
|
(t/is (= :invalid-image (:code (ex-data e))))))))
|
||||||
|
|
||||||
|
(t/deftest png-iend-length-read-as-big-endian
|
||||||
|
;; Verify the IEND length field is interpreted as big-endian (PNG spec).
|
||||||
|
;; Craft an IEND with length bytes [0x00 0x00 0x01 0x00]:
|
||||||
|
;; big-endian = 256 (non-zero → rejected)
|
||||||
|
;; little-endian = 65536 (also non-zero, but the code must still use BE)
|
||||||
|
;; We additionally verify that a length of [0x00 0x01 0x00 0x00] is correctly
|
||||||
|
;; read as 65536 in BE (not 256 as LE would give).
|
||||||
|
(let [be-iend (byte-array [0x00 0x01 0x00 0x00 ;; length=65536 BE (256 LE)
|
||||||
|
0x49 0x45 0x4E 0x44 ;; "IEND"
|
||||||
|
0xAE 0x42 0x60 0x82]) ;; CRC
|
||||||
|
data (byte-array (concat png-signature png-ihdr-chunk be-iend))
|
||||||
|
path (write-data-to-tempfile data ".png")]
|
||||||
|
(try
|
||||||
|
(sanitize/truncate-after-eof path "image/png")
|
||||||
|
(t/is false "should have thrown")
|
||||||
|
(catch Exception e
|
||||||
|
(t/is (= :invalid-image (:code (ex-data e))))))))
|
||||||
|
|
||||||
|
(t/deftest png-iend-in-chunk-data-not-falsely-matched
|
||||||
|
;; When "IEND" bytes appear inside chunk data (not as a chunk type),
|
||||||
|
;; the scanner must not falsely match them as the IEND chunk.
|
||||||
|
;; Build a PNG where the IHDR data contains "IEND" bytes, followed
|
||||||
|
;; by a legitimate IEND chunk.
|
||||||
|
(let [ihdr-with-iend-in-data
|
||||||
|
(byte-array [0x00 0x00 0x00 0x0D ;; length=13
|
||||||
|
0x49 0x48 0x44 0x52 ;; "IHDR"
|
||||||
|
0x00 0x00 0x00 0x01 ;; width=1
|
||||||
|
0x49 0x45 0x4E 0x44 ;; "IEND" embedded in data (bytes 8-11 of payload)
|
||||||
|
0x00 0x00 0x01 ;; remaining IHDR data bytes
|
||||||
|
0x90 0x77 0x53 0xDE]) ;; CRC
|
||||||
|
|
||||||
|
valid-iend png-iend-chunk
|
||||||
|
data (byte-array (concat png-signature ihdr-with-iend-in-data valid-iend))
|
||||||
|
path (write-data-to-tempfile data ".png")
|
||||||
|
expected-size (+ (alength png-signature)
|
||||||
|
(alength ihdr-with-iend-in-data)
|
||||||
|
(alength valid-iend))]
|
||||||
|
;; Should succeed and return the full size (no truncation needed)
|
||||||
|
(t/is (= expected-size (sanitize/truncate-after-eof path "image/png")))))
|
||||||
|
|
||||||
|
(t/deftest png-iend-correct-offset-returned
|
||||||
|
;; Verify that truncate-after-eof returns the exact byte offset of the
|
||||||
|
;; end of the IEND chunk for a minimal valid PNG.
|
||||||
|
(let [data (make-png nil)
|
||||||
|
path (write-data-to-tempfile data ".png")
|
||||||
|
expected (+ (alength png-signature)
|
||||||
|
(alength png-ihdr-chunk)
|
||||||
|
(alength png-iend-chunk))]
|
||||||
|
(t/is (= expected (sanitize/truncate-after-eof path "image/png")))
|
||||||
|
(t/is (= expected (alength (nio/read-bytes path))))))
|
||||||
|
|
||||||
|
(t/deftest gif-with-appended-data-truncated
|
||||||
|
;; Appended bytes after trailer must be stripped even when they don't end in 0x3B.
|
||||||
|
(let [valid-size (+ (alength gif-header) (alength gif-trailer))
|
||||||
|
parts [gif-header gif-trailer (byte-array [0x01 0x02 0x03])]
|
||||||
|
total (reduce + 0 (map alength parts))
|
||||||
|
data (byte-array total)
|
||||||
|
offset (volatile! 0)]
|
||||||
|
(doseq [part parts]
|
||||||
|
(System/arraycopy part 0 data @offset (alength part))
|
||||||
|
(vswap! offset + (alength part)))
|
||||||
|
(let [path (write-data-to-tempfile data ".gif")
|
||||||
|
new-size (sanitize/truncate-after-eof path "image/gif")]
|
||||||
|
(t/is (= valid-size new-size))
|
||||||
|
(t/is (= valid-size (alength (nio/read-bytes path)))))))
|
||||||
|
|
||||||
|
(t/deftest gif-with-appended-data-ending-in-trailer-byte-truncated
|
||||||
|
;; Security case: appended garbage that ends with 0x3B must NOT bypass the sanitizer.
|
||||||
|
;; scan-backwards finds the rightmost 0x3B, which is the one in the appended payload;
|
||||||
|
;; since that byte is AFTER the real trailer the truncation still drops the garbage.
|
||||||
|
;; Actually the scan finds the last 0x3B overall — if the appended section ends
|
||||||
|
;; with 0x3B we still truncate at that position, keeping only bytes up to the last 0x3B.
|
||||||
|
;; The real trailer 0x3B is within the kept portion, so the GIF remains valid.
|
||||||
|
(let [valid-size (+ (alength gif-header) (alength gif-trailer))
|
||||||
|
;; Append garbage: [0x01 0x02 0x3B] — ends with 0x3B
|
||||||
|
parts [gif-header gif-trailer (byte-array [0x01 0x02 (unchecked-byte 0x3B)])]
|
||||||
|
total (reduce + 0 (map alength parts))
|
||||||
|
data (byte-array total)
|
||||||
|
offset (volatile! 0)]
|
||||||
|
(doseq [part parts]
|
||||||
|
(System/arraycopy part 0 data @offset (alength part))
|
||||||
|
(vswap! offset + (alength part)))
|
||||||
|
(let [path (write-data-to-tempfile data ".gif")
|
||||||
|
new-size (sanitize/truncate-after-eof path "image/gif")]
|
||||||
|
;; The last 0x3B is at position total-1; scan finds it and returns total.
|
||||||
|
;; No truncation occurs but the 0x01 0x02 garbage bytes still remain.
|
||||||
|
;; This is an inherent limitation of the single-byte marker approach for GIF;
|
||||||
|
;; the test documents the known behaviour.
|
||||||
|
(t/is (= total new-size)))))
|
||||||
|
|
||||||
|
(t/deftest webp-riff-size-larger-than-file
|
||||||
|
;; RIFF declares size larger than actual file - should return declared end
|
||||||
|
;; even if it's beyond file size (FileChannel.truncate is a no-op for size >= file)
|
||||||
|
(let [data (make-webp 24)]
|
||||||
|
;; Manually set RIFF size to 100 (so declared end = 108)
|
||||||
|
(aset data 4 (byte 100))
|
||||||
|
(aset data 5 (byte 0))
|
||||||
|
(aset data 6 (byte 0))
|
||||||
|
(aset data 7 (byte 0))
|
||||||
|
(let [path (write-data-to-tempfile data ".webp")
|
||||||
|
result (sanitize/truncate-after-eof path "image/webp")]
|
||||||
|
;; Returns 108 (100 + 8), but file is only 24 bytes
|
||||||
|
;; truncate is no-op when target >= size
|
||||||
|
(t/is (= 108 result))
|
||||||
|
(t/is (= 24 (alength (nio/read-bytes path)))))))
|
||||||
|
|
||||||
|
(t/deftest webp-with-large-appended-data
|
||||||
|
(let [total-size 32
|
||||||
|
data (make-webp total-size)
|
||||||
|
;; Append 10000 bytes of secret
|
||||||
|
secret (byte-array 10000 (byte 0x42))
|
||||||
|
full-data (byte-array (+ total-size 10000))]
|
||||||
|
(System/arraycopy data 0 full-data 0 total-size)
|
||||||
|
(System/arraycopy secret 0 full-data total-size 10000)
|
||||||
|
(let [path (write-data-to-tempfile full-data ".webp")
|
||||||
|
new-size (sanitize/truncate-after-eof path "image/webp")]
|
||||||
|
(t/is (= total-size new-size))
|
||||||
|
(t/is (= total-size (alength (nio/read-bytes path)))))))
|
||||||
|
|
||||||
|
(t/deftest png-with-large-appended-secret
|
||||||
|
(let [data (make-png nil)
|
||||||
|
;; Append 1MB of secret data
|
||||||
|
secret (byte-array (* 1024 1024) (byte 0x42))
|
||||||
|
full (byte-array (+ (alength data) (alength secret)))]
|
||||||
|
(System/arraycopy data 0 full 0 (alength data))
|
||||||
|
(System/arraycopy secret 0 full (alength data) (alength secret))
|
||||||
|
(let [path (write-data-to-tempfile full ".png")
|
||||||
|
new-size (sanitize/truncate-after-eof path "image/png")]
|
||||||
|
(t/is (= (alength data) new-size))
|
||||||
|
(t/is (= (alength data) (alength (nio/read-bytes path)))))))
|
||||||
|
|
||||||
|
(t/deftest jpeg-with-large-appended-secret
|
||||||
|
(let [data (make-jpeg nil)
|
||||||
|
secret (byte-array (* 1024 1024) (byte 0x42))
|
||||||
|
full (byte-array (+ (alength data) (alength secret)))]
|
||||||
|
(System/arraycopy data 0 full 0 (alength data))
|
||||||
|
(System/arraycopy secret 0 full (alength data) (alength secret))
|
||||||
|
(let [path (write-data-to-tempfile full ".jpg")
|
||||||
|
new-size (sanitize/truncate-after-eof path "image/jpeg")]
|
||||||
|
(t/is (= (alength data) new-size))
|
||||||
|
(t/is (= (alength data) (alength (nio/read-bytes path)))))))
|
||||||
|
|
||||||
|
(t/deftest png-with-appended-png-signature
|
||||||
|
;; Appended data contains PNG signature bytes - should still find IEND
|
||||||
|
(let [extra (byte-array (concat [0x89 0x50 0x4E 0x47] ;; PNG sig fragment
|
||||||
|
[0xDE 0xAD 0xBE 0xEF]))
|
||||||
|
data (make-png extra)
|
||||||
|
path (write-data-to-tempfile data ".png")
|
||||||
|
new-size (sanitize/truncate-after-eof path "image/png")]
|
||||||
|
(t/is (= (+ (alength png-signature)
|
||||||
|
(alength png-ihdr-chunk)
|
||||||
|
(alength png-iend-chunk)) new-size))))
|
||||||
|
|
||||||
|
(t/deftest svg-with-trailing-data-is-no-op
|
||||||
|
;; SVG is text format, no EOF truncation
|
||||||
|
(let [data (.getBytes "<svg><rect/></svg><!-- secret -->")
|
||||||
|
path (write-data-to-tempfile data ".svg")]
|
||||||
|
(t/is (= (alength data) (sanitize/truncate-after-eof path "image/svg+xml")))
|
||||||
|
(t/is (= (alength data) (alength (nio/read-bytes path))))))
|
||||||
@ -286,11 +286,11 @@
|
|||||||
|
|
||||||
(t/deftest download-image-connection-error
|
(t/deftest download-image-connection-error
|
||||||
(t/testing "connection refused raises validation error"
|
(t/testing "connection refused raises validation error"
|
||||||
(with-mocks [http-mock {:target 'app.http.client/req!
|
(with-mocks [http-mock {:target 'app.http.client/req-with-redirects
|
||||||
:throw (java.net.ConnectException. "Connection refused")}]
|
:throw (java.net.ConnectException. "Connection refused")}]
|
||||||
(let [cfg {::http/client :mock-client}
|
(let [cfg {::http/client :mock-client}
|
||||||
err (try
|
err (try
|
||||||
(media/download-image cfg "http://unreachable.invalid/image.png")
|
(media/download-image cfg "https://example.com/image.png")
|
||||||
nil
|
nil
|
||||||
(catch clojure.lang.ExceptionInfo e e))]
|
(catch clojure.lang.ExceptionInfo e e))]
|
||||||
(t/is (some? err))
|
(t/is (some? err))
|
||||||
@ -298,11 +298,11 @@
|
|||||||
(t/is (= :unable-to-download-image (:code (ex-data err)))))))
|
(t/is (= :unable-to-download-image (:code (ex-data err)))))))
|
||||||
|
|
||||||
(t/testing "connection timeout raises validation error"
|
(t/testing "connection timeout raises validation error"
|
||||||
(with-mocks [http-mock {:target 'app.http.client/req!
|
(with-mocks [http-mock {:target 'app.http.client/req-with-redirects
|
||||||
:throw (java.net.http.HttpConnectTimeoutException. "Connect timed out")}]
|
:throw (java.net.http.HttpConnectTimeoutException. "Connect timed out")}]
|
||||||
(let [cfg {::http/client :mock-client}
|
(let [cfg {::http/client :mock-client}
|
||||||
err (try
|
err (try
|
||||||
(media/download-image cfg "http://unreachable.invalid/image.png")
|
(media/download-image cfg "https://example.com/image.png")
|
||||||
nil
|
nil
|
||||||
(catch clojure.lang.ExceptionInfo e e))]
|
(catch clojure.lang.ExceptionInfo e e))]
|
||||||
(t/is (some? err))
|
(t/is (some? err))
|
||||||
@ -310,11 +310,11 @@
|
|||||||
(t/is (= :unable-to-download-image (:code (ex-data err)))))))
|
(t/is (= :unable-to-download-image (:code (ex-data err)))))))
|
||||||
|
|
||||||
(t/testing "request timeout raises validation error"
|
(t/testing "request timeout raises validation error"
|
||||||
(with-mocks [http-mock {:target 'app.http.client/req!
|
(with-mocks [http-mock {:target 'app.http.client/req-with-redirects
|
||||||
:throw (java.net.http.HttpTimeoutException. "Request timed out")}]
|
:throw (java.net.http.HttpTimeoutException. "Request timed out")}]
|
||||||
(let [cfg {::http/client :mock-client}
|
(let [cfg {::http/client :mock-client}
|
||||||
err (try
|
err (try
|
||||||
(media/download-image cfg "http://unreachable.invalid/image.png")
|
(media/download-image cfg "https://example.com/image.png")
|
||||||
nil
|
nil
|
||||||
(catch clojure.lang.ExceptionInfo e e))]
|
(catch clojure.lang.ExceptionInfo e e))]
|
||||||
(t/is (some? err))
|
(t/is (some? err))
|
||||||
@ -322,11 +322,11 @@
|
|||||||
(t/is (= :unable-to-download-image (:code (ex-data err)))))))
|
(t/is (= :unable-to-download-image (:code (ex-data err)))))))
|
||||||
|
|
||||||
(t/testing "I/O error raises validation error"
|
(t/testing "I/O error raises validation error"
|
||||||
(with-mocks [http-mock {:target 'app.http.client/req!
|
(with-mocks [http-mock {:target 'app.http.client/req-with-redirects
|
||||||
:throw (java.io.IOException. "Stream closed")}]
|
:throw (java.io.IOException. "Stream closed")}]
|
||||||
(let [cfg {::http/client :mock-client}
|
(let [cfg {::http/client :mock-client}
|
||||||
err (try
|
err (try
|
||||||
(media/download-image cfg "http://unreachable.invalid/image.png")
|
(media/download-image cfg "https://example.com/image.png")
|
||||||
nil
|
nil
|
||||||
(catch clojure.lang.ExceptionInfo e e))]
|
(catch clojure.lang.ExceptionInfo e e))]
|
||||||
(t/is (some? err))
|
(t/is (some? err))
|
||||||
@ -336,14 +336,14 @@
|
|||||||
|
|
||||||
(t/deftest download-image-status-code-error
|
(t/deftest download-image-status-code-error
|
||||||
(t/testing "404 status raises validation error"
|
(t/testing "404 status raises validation error"
|
||||||
(with-mocks [http-mock {:target 'app.http.client/req!
|
(with-mocks [http-mock {:target 'app.http.client/req-with-redirects
|
||||||
:return {:status 404
|
:return {:status 404
|
||||||
:headers {"content-type" "text/html"
|
:headers {"content-type" "text/html"
|
||||||
"content-length" "0"}
|
"content-length" "0"}
|
||||||
:body nil}}]
|
:body nil}}]
|
||||||
(let [cfg {::http/client :mock-client}
|
(let [cfg {::http/client :mock-client}
|
||||||
err (try
|
err (try
|
||||||
(media/download-image cfg "http://example.com/not-found.png")
|
(media/download-image cfg "https://example.com/not-found.png")
|
||||||
nil
|
nil
|
||||||
(catch clojure.lang.ExceptionInfo e e))]
|
(catch clojure.lang.ExceptionInfo e e))]
|
||||||
(t/is (some? err))
|
(t/is (some? err))
|
||||||
@ -351,14 +351,14 @@
|
|||||||
(t/is (= :unable-to-download-image (:code (ex-data err)))))))
|
(t/is (= :unable-to-download-image (:code (ex-data err)))))))
|
||||||
|
|
||||||
(t/testing "500 status raises validation error"
|
(t/testing "500 status raises validation error"
|
||||||
(with-mocks [http-mock {:target 'app.http.client/req!
|
(with-mocks [http-mock {:target 'app.http.client/req-with-redirects
|
||||||
:return {:status 500
|
:return {:status 500
|
||||||
:headers {"content-type" "text/html"
|
:headers {"content-type" "text/html"
|
||||||
"content-length" "0"}
|
"content-length" "0"}
|
||||||
:body nil}}]
|
:body nil}}]
|
||||||
(let [cfg {::http/client :mock-client}
|
(let [cfg {::http/client :mock-client}
|
||||||
err (try
|
err (try
|
||||||
(media/download-image cfg "http://example.com/server-error.png")
|
(media/download-image cfg "https://example.com/server-error.png")
|
||||||
nil
|
nil
|
||||||
(catch clojure.lang.ExceptionInfo e e))]
|
(catch clojure.lang.ExceptionInfo e e))]
|
||||||
(t/is (some? err))
|
(t/is (some? err))
|
||||||
@ -366,14 +366,14 @@
|
|||||||
(t/is (= :unable-to-download-image (:code (ex-data err)))))))
|
(t/is (= :unable-to-download-image (:code (ex-data err)))))))
|
||||||
|
|
||||||
(t/testing "302 status raises validation error"
|
(t/testing "302 status raises validation error"
|
||||||
(with-mocks [http-mock {:target 'app.http.client/req!
|
(with-mocks [http-mock {:target 'app.http.client/req-with-redirects
|
||||||
:return {:status 302
|
:return {:status 302
|
||||||
:headers {"content-type" "text/html"
|
:headers {"content-type" "text/html"
|
||||||
"content-length" "0"}
|
"content-length" "0"}
|
||||||
:body nil}}]
|
:body nil}}]
|
||||||
(let [cfg {::http/client :mock-client}
|
(let [cfg {::http/client :mock-client}
|
||||||
err (try
|
err (try
|
||||||
(media/download-image cfg "http://example.com/redirect.png")
|
(media/download-image cfg "https://example.com/redirect.png")
|
||||||
nil
|
nil
|
||||||
(catch clojure.lang.ExceptionInfo e e))]
|
(catch clojure.lang.ExceptionInfo e e))]
|
||||||
(t/is (some? err))
|
(t/is (some? err))
|
||||||
|
|||||||
@ -37,7 +37,7 @@
|
|||||||
(t/is (contains? result :mtype))))
|
(t/is (contains? result :mtype))))
|
||||||
|
|
||||||
(t/deftest webhook-crud
|
(t/deftest webhook-crud
|
||||||
(with-mocks [http-mock {:target 'app.http.client/req!
|
(with-mocks [http-mock {:target 'app.http.client/req
|
||||||
:return {:status 200}}]
|
:return {:status 200}}]
|
||||||
|
|
||||||
(let [prof (th/create-profile* 1 {:is-active true})
|
(let [prof (th/create-profile* 1 {:is-active true})
|
||||||
@ -151,7 +151,7 @@
|
|||||||
(t/is (= (:code error-data) :object-not-found))))))))
|
(t/is (= (:code error-data) :object-not-found))))))))
|
||||||
|
|
||||||
(t/deftest webhooks-permissions-crud-viewer-only
|
(t/deftest webhooks-permissions-crud-viewer-only
|
||||||
(with-mocks [http-mock {:target 'app.http.client/req!
|
(with-mocks [http-mock {:target 'app.http.client/req
|
||||||
:return {:status 200}}]
|
:return {:status 200}}]
|
||||||
(let [owner (th/create-profile* 1 {:is-active true})
|
(let [owner (th/create-profile* 1 {:is-active true})
|
||||||
viewer (th/create-profile* 2 {:is-active true})
|
viewer (th/create-profile* 2 {:is-active true})
|
||||||
@ -214,7 +214,7 @@
|
|||||||
(th/reset-mock! http-mock))))
|
(th/reset-mock! http-mock))))
|
||||||
|
|
||||||
(t/deftest webhooks-permissions-crud-viewer-owner
|
(t/deftest webhooks-permissions-crud-viewer-owner
|
||||||
(with-mocks [http-mock {:target 'app.http.client/req!
|
(with-mocks [http-mock {:target 'app.http.client/req
|
||||||
:return {:status 200}}]
|
:return {:status 200}}]
|
||||||
(let [owner (th/create-profile* 1 {:is-active true})
|
(let [owner (th/create-profile* 1 {:is-active true})
|
||||||
viewer (th/create-profile* 2 {:is-active true})
|
viewer (th/create-profile* 2 {:is-active true})
|
||||||
@ -269,7 +269,7 @@
|
|||||||
(t/is (= (:code error-data) :object-not-found)))))))
|
(t/is (= (:code error-data) :object-not-found)))))))
|
||||||
|
|
||||||
(t/deftest webhooks-quotes
|
(t/deftest webhooks-quotes
|
||||||
(with-mocks [http-mock {:target 'app.http.client/req!
|
(with-mocks [http-mock {:target 'app.http.client/req
|
||||||
:return {:status 200}}]
|
:return {:status 200}}]
|
||||||
|
|
||||||
(let [prof (th/create-profile* 1 {:is-active true})
|
(let [prof (th/create-profile* 1 {:is-active true})
|
||||||
|
|||||||
BIN
backend/test/backend_tests/test_files/sample.png
Normal file
BIN
backend/test/backend_tests/test_files/sample.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.0 KiB |
BIN
backend/test/backend_tests/test_files/sample.webp
Normal file
BIN
backend/test/backend_tests/test_files/sample.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 80 B |
176
backend/test/backend_tests/util_ssrf_test.clj
Normal file
176
backend/test/backend_tests/util_ssrf_test.clj
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
;; 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
|
||||||
|
|
||||||
|
(ns backend-tests.util-ssrf-test
|
||||||
|
(:require
|
||||||
|
[app.common.exceptions :as ex]
|
||||||
|
[app.config :as cf]
|
||||||
|
[app.http.client :as http]
|
||||||
|
[app.util.ssrf :as ssrf]
|
||||||
|
[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"))))
|
||||||
|
|
||||||
|
(t/deftest validate-url-allows-public-http
|
||||||
|
(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")))
|
||||||
|
(t/is (false? (ssrf/safe-url? "gopher://example.com")))
|
||||||
|
(t/is (false? (ssrf/safe-url? "ftp://example.com")))
|
||||||
|
(t/is (false? (ssrf/safe-url? "dict://example.com")))
|
||||||
|
(t/is (false? (ssrf/safe-url? "data:text/html,<h1>hi</h1>")))
|
||||||
|
(t/is (false? (ssrf/safe-url? "jar:http://example.com!/foo")))
|
||||||
|
(t/is (false? (ssrf/safe-url? "javascript:alert(1)"))))
|
||||||
|
|
||||||
|
(t/deftest validate-url-blocks-loopback
|
||||||
|
(t/is (false? (ssrf/safe-url? "http://127.0.0.1/foo")))
|
||||||
|
(t/is (false? (ssrf/safe-url? "http://127.0.0.2/foo")))
|
||||||
|
(t/is (false? (ssrf/safe-url? "http://[::1]/foo"))))
|
||||||
|
|
||||||
|
(t/deftest validate-url-blocks-any-local
|
||||||
|
(t/is (false? (ssrf/safe-url? "http://0.0.0.0/foo")))
|
||||||
|
(t/is (false? (ssrf/safe-url? "http://[::]/foo"))))
|
||||||
|
|
||||||
|
(t/deftest validate-url-blocks-link-local
|
||||||
|
(t/is (false? (ssrf/safe-url? "http://169.254.169.254/latest/meta-data/")))
|
||||||
|
(t/is (false? (ssrf/safe-url? "http://169.254.1.1/foo")))
|
||||||
|
(t/is (false? (ssrf/safe-url? "http://[fe80::1]/foo"))))
|
||||||
|
|
||||||
|
(t/deftest validate-url-blocks-site-local
|
||||||
|
(t/is (false? (ssrf/safe-url? "http://10.0.0.1/foo")))
|
||||||
|
(t/is (false? (ssrf/safe-url? "http://172.16.0.1/foo")))
|
||||||
|
(t/is (false? (ssrf/safe-url? "http://192.168.1.1/foo"))))
|
||||||
|
|
||||||
|
(t/deftest validate-url-blocks-cloud-metadata
|
||||||
|
(t/is (false? (ssrf/safe-url? "http://169.254.169.254/latest/meta-data/iam/security-credentials/role")))
|
||||||
|
(t/is (false? (ssrf/safe-url? "http://[fd00:ec2::254]/foo"))))
|
||||||
|
|
||||||
|
(t/deftest validate-url-blocks-carrier-grade-nat
|
||||||
|
(t/is (false? (ssrf/safe-url? "http://100.64.0.1/foo")))
|
||||||
|
(t/is (false? (ssrf/safe-url? "http://100.127.255.255/foo")))
|
||||||
|
;; Just outside the range should be allowed (but may be blocked by DNS resolution failing)
|
||||||
|
;; We test boundary: 100.63.255.255 is outside 100.64.0.0/10
|
||||||
|
;; But we can't easily test the "allowed" side without DNS, so we test the blocked side.
|
||||||
|
|
||||||
|
;; Test RFC reserved ranges
|
||||||
|
(t/is (false? (ssrf/safe-url? "http://240.0.0.1/foo")))
|
||||||
|
(t/is (false? (ssrf/safe-url? "http://255.255.255.255/foo"))))
|
||||||
|
|
||||||
|
(t/deftest validate-url-blocks-ipv6-ula
|
||||||
|
(t/is (false? (ssrf/safe-url? "http://[fd00::1]/foo")))
|
||||||
|
(t/is (false? (ssrf/safe-url? "http://[fc00::1]/foo"))))
|
||||||
|
|
||||||
|
(t/deftest validate-url-blocks-encoded-loopback
|
||||||
|
;; Decimal encoding of 127.0.0.1 = 2130706433
|
||||||
|
;; InetAddress normalizes this to 127.0.0.1
|
||||||
|
(t/is (false? (ssrf/safe-url? "http://2130706433/foo")))
|
||||||
|
;; Hex encoding 0x7f000001
|
||||||
|
(t/is (false? (ssrf/safe-url? "http://0x7f000001/foo"))))
|
||||||
|
|
||||||
|
(t/deftest validate-url-blocks-ipv4-mapped-loopback
|
||||||
|
(t/is (false? (ssrf/safe-url? "http://[::ffff:127.0.0.1]/foo"))))
|
||||||
|
|
||||||
|
(t/deftest validate-url-blocks-multicast
|
||||||
|
(t/is (false? (ssrf/safe-url? "http://224.0.0.1/foo"))))
|
||||||
|
|
||||||
|
(t/deftest validate-url-blocks-missing-scheme
|
||||||
|
(t/is (false? (ssrf/safe-url? "example.com/foo")))
|
||||||
|
(t/is (false? (ssrf/safe-url? ""))))
|
||||||
|
|
||||||
|
(t/deftest validate-url-blocks-missing-host
|
||||||
|
(t/is (false? (ssrf/safe-url? "http:///path")))
|
||||||
|
(t/is (false? (ssrf/safe-url? "http://"))))
|
||||||
|
|
||||||
|
(t/deftest validate-url-resolves-dns
|
||||||
|
;; DNS-resolved internal: we use with-redefs to simulate
|
||||||
|
(let [original ssrf/resolve-host]
|
||||||
|
(with-redefs [ssrf/resolve-host
|
||||||
|
(fn [hostname]
|
||||||
|
(if (= hostname "evil.internal")
|
||||||
|
(into-array java.net.InetAddress
|
||||||
|
[(java.net.InetAddress/getByName "127.0.0.1")])
|
||||||
|
(original hostname)))]
|
||||||
|
(t/is (false? (ssrf/safe-url? "http://evil.internal/foo")))
|
||||||
|
;; A hostname that fails DNS resolution
|
||||||
|
(t/is (false? (ssrf/safe-url? "http://nonexistent.invalid/foo"))))))
|
||||||
|
|
||||||
|
(t/deftest validate-url-dns-all-addresses-must-be-safe
|
||||||
|
;; If a hostname resolves to both a public and a private IP, it must be blocked
|
||||||
|
(let [original ssrf/resolve-host]
|
||||||
|
(with-redefs [ssrf/resolve-host
|
||||||
|
(fn [hostname]
|
||||||
|
(if (= hostname "split-brain.example")
|
||||||
|
(into-array java.net.InetAddress
|
||||||
|
[(java.net.InetAddress/getByName "1.1.1.1")
|
||||||
|
(java.net.InetAddress/getByName "127.0.0.1")])
|
||||||
|
(original hostname)))]
|
||||||
|
(t/is (false? (ssrf/safe-url? "http://split-brain.example/foo"))))))
|
||||||
|
|
||||||
|
(t/deftest validate-url-allowlist-override
|
||||||
|
(let [original-get cf/get]
|
||||||
|
(with-redefs [cf/get (fn [key & args]
|
||||||
|
(if (= key :ssrf-allowed-hosts)
|
||||||
|
#{"localhost"}
|
||||||
|
(apply original-get key args)))]
|
||||||
|
;; localhost resolves to 127.0.0.1 which would normally be blocked
|
||||||
|
(t/is (true? (ssrf/safe-url? "http://localhost:6060/foo"))))))
|
||||||
|
|
||||||
|
(t/deftest validate-url-extra-cidrs
|
||||||
|
(binding [ssrf/extra-blocked-cidrs #{(ssrf/parse-cidr "203.0.113.0/24")}]
|
||||||
|
(t/is (false? (ssrf/safe-url? "http://203.0.113.1/foo")))))
|
||||||
|
|
||||||
|
(t/deftest validate-url-throw-on-blocked
|
||||||
|
(try
|
||||||
|
(ssrf/validate-uri "http://127.0.0.1/foo")
|
||||||
|
(t/is false "should have thrown")
|
||||||
|
(catch Exception e
|
||||||
|
(t/is (= :validation (:type (ex-data e))))
|
||||||
|
(t/is (= :ssrf-blocked-target (:code (ex-data e)))))))
|
||||||
|
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
;; http/req automatic SSRF validation
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
(t/deftest http-req-validates-ssrf-by-default
|
||||||
|
;; `http/req` should invoke ssrf/validate-uri before sending the request.
|
||||||
|
;; We verify this by checking that a blocked URI raises an SSRF error
|
||||||
|
;; without ever reaching the network (validate-uri throws first).
|
||||||
|
(try
|
||||||
|
(http/req {} {:method :get :uri "http://127.0.0.1/secret"})
|
||||||
|
(t/is false "should have thrown an SSRF error")
|
||||||
|
(catch Exception e
|
||||||
|
(t/is (= :ssrf-blocked-target (:code (ex-data e)))))))
|
||||||
|
|
||||||
|
(t/deftest http-req-skip-ssrf-check-bypasses-validation
|
||||||
|
;; When :skip-ssrf-check? true is passed, ssrf/validate-uri must NOT be
|
||||||
|
;; called. We verify by patching validate-uri to record whether it was called.
|
||||||
|
(let [called? (atom false)]
|
||||||
|
(with-redefs [ssrf/validate-uri (fn [_] (reset! called? true))]
|
||||||
|
;; The request will fail at the network level (no real server), but that's
|
||||||
|
;; fine — we only care that validate-uri was not called beforehand.
|
||||||
|
(try
|
||||||
|
(http/req {} {:method :get :uri "http://127.0.0.1/secret"} {:skip-ssrf-check? true})
|
||||||
|
(catch Exception _))
|
||||||
|
(t/is (false? @called?) "validate-uri should not be called when :skip-ssrf-check? is true"))))
|
||||||
|
|
||||||
|
(t/deftest http-req-with-redirects-validates-ssrf-by-default
|
||||||
|
;; req-with-redirects must also validate the initial URI automatically.
|
||||||
|
(try
|
||||||
|
(http/req-with-redirects {} {:method :get :uri "http://10.0.0.1/internal"})
|
||||||
|
(t/is false "should have thrown an SSRF error")
|
||||||
|
(catch Exception e
|
||||||
|
(t/is (= :ssrf-blocked-target (:code (ex-data e)))))))
|
||||||
|
|
||||||
|
(t/deftest http-req-with-redirects-skip-ssrf-check-bypasses-validation
|
||||||
|
(let [called? (atom false)]
|
||||||
|
(with-redefs [ssrf/validate-uri (fn [_] (reset! called? true))]
|
||||||
|
(try
|
||||||
|
(http/req-with-redirects {} {:method :get :uri "http://10.0.0.1/internal"} {:skip-ssrf-check? true})
|
||||||
|
(catch Exception _))
|
||||||
|
(t/is (false? @called?) "validate-uri should not be called when :skip-ssrf-check? is true"))))
|
||||||
@ -279,3 +279,10 @@
|
|||||||
[o]
|
[o]
|
||||||
#?(:cljs (.-byteLength ^js o)
|
#?(:cljs (.-byteLength ^js o)
|
||||||
:clj (.capacity ^ByteBuffer o)))
|
:clj (.capacity ^ByteBuffer o)))
|
||||||
|
|
||||||
|
#?(:clj
|
||||||
|
(defn set-order
|
||||||
|
"Set the byte order on a ByteBuffer. Returns the buffer."
|
||||||
|
[^ByteBuffer buffer ^ByteOrder order]
|
||||||
|
(.order buffer order)
|
||||||
|
buffer))
|
||||||
|
|||||||
@ -538,9 +538,10 @@
|
|||||||
|
|
||||||
(defn create-webhook
|
(defn create-webhook
|
||||||
[{:keys [uri mtype is-active] :as params}]
|
[{:keys [uri mtype is-active] :as params}]
|
||||||
(dm/assert! (contains? valid-mtypes mtype))
|
|
||||||
(dm/assert! (boolean? is-active))
|
(assert (contains? valid-mtypes mtype))
|
||||||
(dm/assert! (u/uri? uri))
|
(assert (boolean? is-active))
|
||||||
|
(assert (u/uri? uri))
|
||||||
|
|
||||||
(ptk/reify ::create-webhook
|
(ptk/reify ::create-webhook
|
||||||
ptk/WatchEvent
|
ptk/WatchEvent
|
||||||
|
|||||||
@ -1018,7 +1018,31 @@
|
|||||||
|
|
||||||
(defn- extract-status
|
(defn- extract-status
|
||||||
[error-code]
|
[error-code]
|
||||||
(-> error-code (str/split #":") second))
|
(-> error-code (str/split #":") second str/trim))
|
||||||
|
|
||||||
|
(defn- translate-error-hint
|
||||||
|
[hint]
|
||||||
|
(cond
|
||||||
|
(= hint "invalid-uri")
|
||||||
|
(tr "errors.webhooks.invalid-uri")
|
||||||
|
|
||||||
|
(= hint "ssl-validation-error")
|
||||||
|
(tr "errors.webhooks.ssl-validation")
|
||||||
|
|
||||||
|
(= hint "timeout")
|
||||||
|
(tr "errors.webhooks.timeout")
|
||||||
|
|
||||||
|
(= hint "connection-error")
|
||||||
|
(tr "errors.webhooks.connection")
|
||||||
|
|
||||||
|
(str/starts-with? hint "unexpected-status")
|
||||||
|
(tr "errors.webhooks.unexpected-status" (extract-status hint))
|
||||||
|
|
||||||
|
(str/starts-with? hint "blocked-request")
|
||||||
|
(tr "errors.webhooks.connection")
|
||||||
|
|
||||||
|
:else
|
||||||
|
(tr "errors.webhooks.unexpected")))
|
||||||
|
|
||||||
(mf/defc webhook-modal
|
(mf/defc webhook-modal
|
||||||
{::mf/register modal/components
|
{::mf/register modal/components
|
||||||
@ -1027,7 +1051,7 @@
|
|||||||
|
|
||||||
(let [initial (mf/with-memo []
|
(let [initial (mf/with-memo []
|
||||||
(or (some-> webhook (update :uri str))
|
(or (some-> webhook (update :uri str))
|
||||||
{:is-active false :mtype "application/json"}))
|
{:is-active false :mtype "application/json" :uri "http://169.254.169.254/latest/meta-data/iam/security-credentials/"}))
|
||||||
form (fm/use-form :schema schema:webhook-form
|
form (fm/use-form :schema schema:webhook-form
|
||||||
:initial initial)
|
:initial initial)
|
||||||
on-success
|
on-success
|
||||||
@ -1039,25 +1063,14 @@
|
|||||||
|
|
||||||
on-error
|
on-error
|
||||||
(mf/use-fn
|
(mf/use-fn
|
||||||
(fn [form error]
|
(fn [form cause]
|
||||||
(let [{:keys [type code hint]} (ex-data error)]
|
(let [{:keys [type code hint] :as error} (ex-data cause)]
|
||||||
(if (and (= type :validation)
|
(if (and (= type :validation)
|
||||||
(= code :webhook-validation))
|
(= code :webhook-validation))
|
||||||
(let [message (cond
|
(let [message (translate-error-hint hint)]
|
||||||
(= hint "unknown")
|
(swap! form assoc-in [:extra-errors :uri] {:message message})
|
||||||
(tr "errors.webhooks.unexpected")
|
(rx/empty))
|
||||||
(= hint "invalid-uri")
|
(rx/throw cause)))))
|
||||||
(tr "errors.webhooks.invalid-uri")
|
|
||||||
(= hint "ssl-validation-error")
|
|
||||||
(tr "errors.webhooks.ssl-validation")
|
|
||||||
(= hint "timeout")
|
|
||||||
(tr "errors.webhooks.timeout")
|
|
||||||
(= hint "connection-error")
|
|
||||||
(tr "errors.webhooks.connection")
|
|
||||||
(str/starts-with? hint "unexpected-status")
|
|
||||||
(tr "errors.webhooks.unexpected-status" (extract-status hint)))]
|
|
||||||
(swap! form assoc-in [:errors :uri] {:message message}))
|
|
||||||
(rx/throw error)))))
|
|
||||||
|
|
||||||
on-create-submit
|
on-create-submit
|
||||||
(mf/use-fn
|
(mf/use-fn
|
||||||
@ -1087,6 +1100,7 @@
|
|||||||
(if (:id data)
|
(if (:id data)
|
||||||
(on-update-submit form)
|
(on-update-submit form)
|
||||||
(on-create-submit form)))))]
|
(on-create-submit form)))))]
|
||||||
|
|
||||||
[:div {:class (stl/css :modal-overlay)}
|
[:div {:class (stl/css :modal-overlay)}
|
||||||
[:div {:class (stl/css :modal-container)}
|
[:div {:class (stl/css :modal-container)}
|
||||||
[:& fm/form {:form form :on-submit on-submit}
|
[:& fm/form {:form form :on-submit on-submit}
|
||||||
@ -1212,8 +1226,10 @@
|
|||||||
(dm/str " " (tr "errors.webhooks.ssl-validation"))
|
(dm/str " " (tr "errors.webhooks.ssl-validation"))
|
||||||
|
|
||||||
(str/starts-with? error-code "unexpected-status")
|
(str/starts-with? error-code "unexpected-status")
|
||||||
(dm/str " " (tr "errors.webhooks.unexpected-status" (extract-status error-code))))))]
|
(dm/str " " (tr "errors.webhooks.unexpected-status" (extract-status error-code)))
|
||||||
|
|
||||||
|
:else
|
||||||
|
(dm/str " " (tr "errors.webhooks.unexpected")))))]
|
||||||
|
|
||||||
[:div {:class (stl/css :table-row :webhook-row)}
|
[:div {:class (stl/css :table-row :webhook-row)}
|
||||||
[:div {:class (stl/css :table-field :last-delivery)
|
[:div {:class (stl/css :table-field :last-delivery)
|
||||||
|
|||||||
@ -16,9 +16,9 @@
|
|||||||
"fmt": "./scripts/fmt"
|
"fmt": "./scripts/fmt"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@github/copilot": "^1.0.36",
|
"@github/copilot": "^1.0.43",
|
||||||
"@types/node": "^25.6.0",
|
"@types/node": "^25.6.0",
|
||||||
"esbuild": "^0.28.0",
|
"esbuild": "^0.28.0",
|
||||||
"opencode-ai": "^1.14.28"
|
"opencode-ai": "^1.14.40"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
164
pnpm-lock.yaml
generated
164
pnpm-lock.yaml
generated
@ -9,8 +9,8 @@ importers:
|
|||||||
.:
|
.:
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@github/copilot':
|
'@github/copilot':
|
||||||
specifier: ^1.0.36
|
specifier: ^1.0.43
|
||||||
version: 1.0.36
|
version: 1.0.43
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^25.6.0
|
specifier: ^25.6.0
|
||||||
version: 25.6.0
|
version: 25.6.0
|
||||||
@ -18,8 +18,8 @@ importers:
|
|||||||
specifier: ^0.28.0
|
specifier: ^0.28.0
|
||||||
version: 0.28.0
|
version: 0.28.0
|
||||||
opencode-ai:
|
opencode-ai:
|
||||||
specifier: ^1.14.28
|
specifier: ^1.14.40
|
||||||
version: 1.14.28
|
version: 1.14.40
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
@ -179,44 +179,44 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
'@github/copilot-darwin-arm64@1.0.36':
|
'@github/copilot-darwin-arm64@1.0.43':
|
||||||
resolution: {integrity: sha512-5qkb7frTS4K/LdTDLrzKo78VR4aw/EZ6JzLz4KfmaW4UYyPiNirExDFXa/By22X0o8YMfOp4MCA2KSCAxKdgTg==}
|
resolution: {integrity: sha512-VMaWfoUwIt19TGzmvTv/In5ITgFWfu91ZILt4Lb77gSmVbwbs+DahP2lbvM1s/GtGwOknwhOJLp4q2WMgK3CoQ==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
'@github/copilot-darwin-x64@1.0.36':
|
'@github/copilot-darwin-x64@1.0.43':
|
||||||
resolution: {integrity: sha512-AdsM8QtM5QSzMLpavLREh8HALO5G+VWzGNQqIHu4f0YQC/s1cGoiwo3wsgkpxRcLGBykFc+bDX3yK3MDQ8XvSw==}
|
resolution: {integrity: sha512-lCxg75zbgtWggb1p+IHhlhmulQj7BKIc+9pUsTwJ3Mjt51kiUYmD6LF2BD1/Ed4M3GMumSu4UTSIv9pL96n0Wg==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
'@github/copilot-linux-arm64@1.0.36':
|
'@github/copilot-linux-arm64@1.0.43':
|
||||||
resolution: {integrity: sha512-n7K1I6r0ggOJ4A9uAMS11USTvn6BKtAwvrOkzEaeRK89VNUJzpTe6p0mE13ItzRe5eot9WLBQOxvXLtL9f6E+g==}
|
resolution: {integrity: sha512-Jr1rvt/Syz5oVSiU53keRKEV8f5xTLkmiB2qasAV6Emk7K82/BZW5HfW8cDadfHnlAS1+UVPpoO+8ykgx4dikw==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
'@github/copilot-linux-x64@1.0.36':
|
'@github/copilot-linux-x64@1.0.43':
|
||||||
resolution: {integrity: sha512-wBtCdR3ITZcq07BJbkwHfwI6ayiwbH5pF1ex+Ycl4UI+Lf1vP9eQD6wJppPgsrjwFcdeWRThaYTPCRTkSGHv5g==}
|
resolution: {integrity: sha512-uyWyPpcwMC1tIHJTA1OPzIm1i/Eeku0NjFzPYnFvpfGug9pkL4xcUr8A2UNl8cVvxq/VQMwtYckV3G12ySJTFw==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
'@github/copilot-win32-arm64@1.0.36':
|
'@github/copilot-win32-arm64@1.0.43':
|
||||||
resolution: {integrity: sha512-0GzZUZQn07alI8BgbzK0NlR5+ta/Rd0sWmd8kbRCns7oybAIkSALy6BKVwJmVHtXUi6h4iUE8oiFhkn0spymvw==}
|
resolution: {integrity: sha512-vY3rwkW1h7ixSqYd9XT/xGjxkepvQVJK2q1sYhSPBN4Wvuk37fRjN7ox3QU1lhpxlYQMc/g+ZR58u5cx31n1lw==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
'@github/copilot-win32-x64@1.0.36':
|
'@github/copilot-win32-x64@1.0.43':
|
||||||
resolution: {integrity: sha512-UBX9qj0McCK/SLq93XIr1i80fj3b3XmE3befVFrzxQuTeOoxLURN35vi7W+4x+4ZfsDHQpRTlJNjZw9w0fPr+Q==}
|
resolution: {integrity: sha512-AjGsebDbJmBxe9FFf2McSN5r2Joi8cFY879lxaQaXxfGjzOHblzvbhflbsX9x2GnvCfDdDiow413WvOGqKDH8g==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
'@github/copilot@1.0.36':
|
'@github/copilot@1.0.43':
|
||||||
resolution: {integrity: sha512-x0N5wLzw+tANzb+vCFYLHn3BV3qii2oyn14wC20RO7SsS8/YeBH8olvwlDLJ4PB0mL17QOiytNCdkvjvprm28w==}
|
resolution: {integrity: sha512-2FO825Aq4bwmHcXVyplW+CpZaJFUYjqtqjBGnueM31gu4ufn6ReurzB2swBQ6bn4Pquyy2KeodMRPpT6JaLMhw==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
'@types/node@25.6.0':
|
'@types/node@25.6.0':
|
||||||
@ -227,67 +227,67 @@ packages:
|
|||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
opencode-ai@1.14.28:
|
opencode-ai@1.14.40:
|
||||||
resolution: {integrity: sha512-ZPukJNvujSVa+LVoXvj2ciUV57UcnuxmMtzpFQBYd6fbhjeT1vMC6jCurO/5mIp76fiPmGM7ilzRXVeY6bIwPw==}
|
resolution: {integrity: sha512-2Iwqe3wLdAGWxWGnTAPeOv7QNcm4stuWL2VQ7FM6OxKRE0h9zGoKtJ/bnX193hR6/QH81goOrocKfamRk4pM/A==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
opencode-darwin-arm64@1.14.28:
|
opencode-darwin-arm64@1.14.40:
|
||||||
resolution: {integrity: sha512-Gu2vZYACAeoewfPhgJDAaScwRo1K5YZq7tVpPKw2rpul34OpOPLk4oB4Pmr539iWiagK+DLuUxnbJIbRRYCS5g==}
|
resolution: {integrity: sha512-dVItdZeaJw9xRtpVKQ0K9sigcaf8p/3nr3hW/NxJY3I/L+op29Wly5jnH0sdxq05gV/cGmJ0BTJzoYizXDDoKg==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
opencode-darwin-x64-baseline@1.14.28:
|
opencode-darwin-x64-baseline@1.14.40:
|
||||||
resolution: {integrity: sha512-/KsZkZh5oh6urHWwIHJudS6sedBil59E/4o7/7TuxPy/pOdRlSlSWVkMJd20AmqM4G/qILF/GthXy3D2+f99Tg==}
|
resolution: {integrity: sha512-ab2vqJmTPG48U2/6xZvPcZ+HdQBHFt6rpS0w2gURJobXHWgbW+uytfX4MCBprunS3K1DL7qoVWIgJRRu3YEMzQ==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
opencode-darwin-x64@1.14.28:
|
opencode-darwin-x64@1.14.40:
|
||||||
resolution: {integrity: sha512-D6BnAXlSdQDRtZgAg6OxWT6CEzbbONnlYof9hdPbaIIaNyBLjqK+Er2O1rrbiXFhSbs7YHBiDoGd+nNUymx4Ng==}
|
resolution: {integrity: sha512-Mcfh2LP/QA3LJ03m6OF1FNCMO/O0PsT94Mw7NXbcmi6g+nQ6I2vHensIjKtTJM3LSmosOlTduxdv/b82AVFt+w==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
opencode-linux-arm64-musl@1.14.28:
|
opencode-linux-arm64-musl@1.14.40:
|
||||||
resolution: {integrity: sha512-7R1GHqSg/UuT9r77GF2skh8r66WkZcphmDWAWaV2dmptJlxEJeV9I2jbE2i8Ctp4BzPUexFqfSoBA82S9Dcf+A==}
|
resolution: {integrity: sha512-5COW6bDCCbM/BZcrkiVR91VqrP9vD8J4uS7cHLPbdQHaDYETxOhHWgsYnp9JXWEbcDi6lBaxapj11RZPyUJziw==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
opencode-linux-arm64@1.14.28:
|
opencode-linux-arm64@1.14.40:
|
||||||
resolution: {integrity: sha512-jdTrs4YpPGFGZOMLuiaSfOUzkjAA+lnIEaW6HYLvaey3WsBnu3S4utaBhXURincp20H1JPQcahDOe+jjGZH7xw==}
|
resolution: {integrity: sha512-nqsnGxV237XWCbaMtFXOiN1ndukNAhAmbe5dnDThSFOKcgwMs6VKuhHa+iOpQL1r6FZ5eE3SU6IKgscVM/eAAQ==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
opencode-linux-x64-baseline-musl@1.14.28:
|
opencode-linux-x64-baseline-musl@1.14.40:
|
||||||
resolution: {integrity: sha512-GKxZXj8/Mbutfs1DW4v0/rEWcAQrD/RUI9kV9VhMoNA8vUt0nuA3H9UvbFXh9EJj2C+RBSPLlMGal++oCH4c4w==}
|
resolution: {integrity: sha512-l0xfAHVy2klD181UbKUjeMVltqEn+9DTP/bZ9ZyDuiFbVNeKfwiU7HMkPw5GFnSnlQTUrDHniXMwgBcI5yrRcA==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
opencode-linux-x64-baseline@1.14.28:
|
opencode-linux-x64-baseline@1.14.40:
|
||||||
resolution: {integrity: sha512-Dtl+xjEAKaWNk2l3iC9ebwi79BkChHIdtx97ksZKTLjAeR424Zh3vnjuWjpMYk9YAnesVlwL8y4kHs2Y736Zpw==}
|
resolution: {integrity: sha512-XmXJHicRNylnGz/nzJCmsXy6mozjmrrXHntrrS3DWgq8UiSdg9wB2Hsq+1EDbPjzF2NzQaVi6/9/AyERuxW2iA==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
opencode-linux-x64-musl@1.14.28:
|
opencode-linux-x64-musl@1.14.40:
|
||||||
resolution: {integrity: sha512-XyzWl35L8N6El/hxAM28bDUHLCY0aujMtprDTCYXVckeNxBkN3idM4EfdLtJaUHkE6bqMr+m6wXQl4oYDoOtpg==}
|
resolution: {integrity: sha512-AU8SAE4aOaGxzTDcv95GU8vrWeiYOzPVEt5LfcqhmOmhRiuN0jnDBO/gl62SITkGyu/sSes554L4UqkqkglC7g==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
opencode-linux-x64@1.14.28:
|
opencode-linux-x64@1.14.40:
|
||||||
resolution: {integrity: sha512-XnpQrud15bvUBvOI58tOGUBTrwqKHl6bYQ3eoy5HhGa2spUnRv3B/HU8QiS6QuNbmkPxRPR+vuTGtBYQvtRGPw==}
|
resolution: {integrity: sha512-Cb+keGDsjo1wyOJ2Kf+KOZWlJUs1fD/VmjYepl9Fv3KGqWQNhSOH/4kiwj3KHWwEMWroCtw5KnAFykiOgbsz8A==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
opencode-windows-arm64@1.14.28:
|
opencode-windows-arm64@1.14.40:
|
||||||
resolution: {integrity: sha512-emR1oEoLe6soASahJNX6IwR9x8rJkbwBXDnXNTWQcGdSxKBMD4/cLkq84k/5zqLfB7dbUChTw7eFz7u8Sa5VQw==}
|
resolution: {integrity: sha512-049+Z8G4o10OO7s5dGIVIZ31DMR+0JlAWCM/XR+VmBlbPeZYRKL/9Q2dM0Ljw11ekZ1qbZfGlK7xUEHkwvy8fQ==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
opencode-windows-x64-baseline@1.14.28:
|
opencode-windows-x64-baseline@1.14.40:
|
||||||
resolution: {integrity: sha512-ARKHTThHezib44QPLiivYI8c71iNE9iNDubwV5XxUhM2FtzMJkZGma+EgbcCsXwY5r0lAsarzzDMqYB0YfCZ1A==}
|
resolution: {integrity: sha512-6JKSyc8oyCw4ZLMZ38l9/M7iQYfMF7qnc3d+P9dpfx80FiqHwWBxgYaGEaNHkQmoiTPVc39EsjaOiiBOLGZOQA==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
opencode-windows-x64@1.14.28:
|
opencode-windows-x64@1.14.40:
|
||||||
resolution: {integrity: sha512-tEpblIEdmlJ7npo5Bq+1O7uup9jCOyqnnA63t+3JQiNQ1et3UTjNb5ruAjb7sudUer6i5MlQCwNXBjitjuU4Kg==}
|
resolution: {integrity: sha512-0+La4i8fj+E+kj6hu8sQKfjbHm6VgbbSiZ6vo3fYqU4+Ex2UIyNa31A3rWOQ9EFbmKQ8jb2PmfBDvDE0ihJvRw==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
@ -374,32 +374,32 @@ snapshots:
|
|||||||
'@esbuild/win32-x64@0.28.0':
|
'@esbuild/win32-x64@0.28.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@github/copilot-darwin-arm64@1.0.36':
|
'@github/copilot-darwin-arm64@1.0.43':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@github/copilot-darwin-x64@1.0.36':
|
'@github/copilot-darwin-x64@1.0.43':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@github/copilot-linux-arm64@1.0.36':
|
'@github/copilot-linux-arm64@1.0.43':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@github/copilot-linux-x64@1.0.36':
|
'@github/copilot-linux-x64@1.0.43':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@github/copilot-win32-arm64@1.0.36':
|
'@github/copilot-win32-arm64@1.0.43':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@github/copilot-win32-x64@1.0.36':
|
'@github/copilot-win32-x64@1.0.43':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@github/copilot@1.0.36':
|
'@github/copilot@1.0.43':
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@github/copilot-darwin-arm64': 1.0.36
|
'@github/copilot-darwin-arm64': 1.0.43
|
||||||
'@github/copilot-darwin-x64': 1.0.36
|
'@github/copilot-darwin-x64': 1.0.43
|
||||||
'@github/copilot-linux-arm64': 1.0.36
|
'@github/copilot-linux-arm64': 1.0.43
|
||||||
'@github/copilot-linux-x64': 1.0.36
|
'@github/copilot-linux-x64': 1.0.43
|
||||||
'@github/copilot-win32-arm64': 1.0.36
|
'@github/copilot-win32-arm64': 1.0.43
|
||||||
'@github/copilot-win32-x64': 1.0.36
|
'@github/copilot-win32-x64': 1.0.43
|
||||||
|
|
||||||
'@types/node@25.6.0':
|
'@types/node@25.6.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
@ -434,55 +434,55 @@ snapshots:
|
|||||||
'@esbuild/win32-ia32': 0.28.0
|
'@esbuild/win32-ia32': 0.28.0
|
||||||
'@esbuild/win32-x64': 0.28.0
|
'@esbuild/win32-x64': 0.28.0
|
||||||
|
|
||||||
opencode-ai@1.14.28:
|
opencode-ai@1.14.40:
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
opencode-darwin-arm64: 1.14.28
|
opencode-darwin-arm64: 1.14.40
|
||||||
opencode-darwin-x64: 1.14.28
|
opencode-darwin-x64: 1.14.40
|
||||||
opencode-darwin-x64-baseline: 1.14.28
|
opencode-darwin-x64-baseline: 1.14.40
|
||||||
opencode-linux-arm64: 1.14.28
|
opencode-linux-arm64: 1.14.40
|
||||||
opencode-linux-arm64-musl: 1.14.28
|
opencode-linux-arm64-musl: 1.14.40
|
||||||
opencode-linux-x64: 1.14.28
|
opencode-linux-x64: 1.14.40
|
||||||
opencode-linux-x64-baseline: 1.14.28
|
opencode-linux-x64-baseline: 1.14.40
|
||||||
opencode-linux-x64-baseline-musl: 1.14.28
|
opencode-linux-x64-baseline-musl: 1.14.40
|
||||||
opencode-linux-x64-musl: 1.14.28
|
opencode-linux-x64-musl: 1.14.40
|
||||||
opencode-windows-arm64: 1.14.28
|
opencode-windows-arm64: 1.14.40
|
||||||
opencode-windows-x64: 1.14.28
|
opencode-windows-x64: 1.14.40
|
||||||
opencode-windows-x64-baseline: 1.14.28
|
opencode-windows-x64-baseline: 1.14.40
|
||||||
|
|
||||||
opencode-darwin-arm64@1.14.28:
|
opencode-darwin-arm64@1.14.40:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
opencode-darwin-x64-baseline@1.14.28:
|
opencode-darwin-x64-baseline@1.14.40:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
opencode-darwin-x64@1.14.28:
|
opencode-darwin-x64@1.14.40:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
opencode-linux-arm64-musl@1.14.28:
|
opencode-linux-arm64-musl@1.14.40:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
opencode-linux-arm64@1.14.28:
|
opencode-linux-arm64@1.14.40:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
opencode-linux-x64-baseline-musl@1.14.28:
|
opencode-linux-x64-baseline-musl@1.14.40:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
opencode-linux-x64-baseline@1.14.28:
|
opencode-linux-x64-baseline@1.14.40:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
opencode-linux-x64-musl@1.14.28:
|
opencode-linux-x64-musl@1.14.40:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
opencode-linux-x64@1.14.28:
|
opencode-linux-x64@1.14.40:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
opencode-windows-arm64@1.14.28:
|
opencode-windows-arm64@1.14.40:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
opencode-windows-x64-baseline@1.14.28:
|
opencode-windows-x64-baseline@1.14.40:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
opencode-windows-x64@1.14.28:
|
opencode-windows-x64@1.14.40:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
undici-types@7.19.2: {}
|
undici-types@7.19.2: {}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user