mirror of
https://github.com/penpot/penpot.git
synced 2026-05-08 17:48:39 +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 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 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)
|
||||
- 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)
|
||||
|
||||
@ -43,7 +43,7 @@
|
||||
(defn- discover-oidc-config
|
||||
[cfg {:keys [base-uri] :as provider}]
|
||||
(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))
|
||||
(let [data (-> rsp :body json/decode)
|
||||
@ -105,7 +105,7 @@
|
||||
|
||||
(defn- fetch-oidc-jwks
|
||||
[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)
|
||||
(-> body json/decode :keys process-oidc-jwks)
|
||||
(ex/raise :type ::internal
|
||||
@ -235,7 +235,7 @@
|
||||
:timeout 6000
|
||||
:method :get}
|
||||
|
||||
{:keys [status body]} (http/req! cfg params)]
|
||||
{:keys [status body]} (http/req cfg params)]
|
||||
|
||||
(when-not (int-in-range? status 200 300)
|
||||
(ex/raise :type :internal
|
||||
@ -452,7 +452,7 @@
|
||||
:grant-type (:grant_type 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)
|
||||
(let [data (json/decode body)
|
||||
data {:token/access (get data :access_token)
|
||||
@ -507,7 +507,7 @@
|
||||
:headers {"Authorization" (str (:token/type tdata) " " (:token/access tdata))}
|
||||
:timeout 6000
|
||||
:method :get}
|
||||
response (http/req! cfg params)]
|
||||
response (http/req cfg params)]
|
||||
|
||||
(l/trc :hint "user info response"
|
||||
:status (:status response)
|
||||
|
||||
@ -85,7 +85,11 @@
|
||||
:email-verify-threshold "15m"
|
||||
|
||||
: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
|
||||
(do #_sm/optional-keys
|
||||
@ -245,7 +249,11 @@
|
||||
[:objects-storage-fs-directory {:optional true} :string]
|
||||
[:objects-storage-s3-bucket {:optional true} :string]
|
||||
[: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
|
||||
[config]
|
||||
|
||||
@ -12,43 +12,56 @@
|
||||
[app.common.time :as ct]
|
||||
[app.common.uri :as u]
|
||||
[app.db :as db]
|
||||
[app.http.session :as session]
|
||||
[app.storage :as sto]
|
||||
[integrant.core :as ig]
|
||||
[yetti.response :as-alias yres]))
|
||||
|
||||
(def ^:private cache-max-age
|
||||
(def ^:private default-cache-max-age
|
||||
(ct/duration {:hours 24}))
|
||||
|
||||
(def ^:private signature-max-age
|
||||
(def ^:private default-signature-max-age
|
||||
(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
|
||||
[{:keys [path-params]}]
|
||||
(or (some-> path-params :id d/parse-uuid)
|
||||
(ex/raise :type :not-found
|
||||
:hunt "object not found")))
|
||||
:hint "object not found")))
|
||||
|
||||
(defn- get-file-media-object
|
||||
[pool id]
|
||||
(db/get pool :file-media-object {:id id} {::db/remove-deleted false}))
|
||||
|
||||
(defn- serve-object-from-s3
|
||||
[{:keys [::sto/storage] :as cfg} obj]
|
||||
(let [{:keys [host port] :as url} (sto/get-object-url storage obj {:max-age signature-max-age})]
|
||||
[{:keys [::sto/storage ::signature-max-age ::cache-max-age] :as cfg} obj]
|
||||
(let [sig-max-age (or signature-max-age default-signature-max-age)
|
||||
cch-max-age (or cache-max-age default-cache-max-age)
|
||||
{:keys [host port] :as url} (sto/get-object-url storage obj {:max-age sig-max-age})]
|
||||
{::yres/status 307
|
||||
::yres/headers {"location" (str url)
|
||||
"x-host" (cond-> host port (str ":" port))
|
||||
"x-mtype" (-> obj meta :content-type)
|
||||
"cache-control" (str "max-age=" (inst-ms cache-max-age))}}))
|
||||
"cache-control" (str "max-age=" (inst-ms cch-max-age))}}))
|
||||
|
||||
(defn- serve-object-from-fs
|
||||
[{:keys [::path]} obj]
|
||||
(let [purl (u/join (u/uri path)
|
||||
[{:keys [::path ::cache-max-age]} obj]
|
||||
(let [cch-max-age (or cache-max-age default-cache-max-age)
|
||||
purl (u/join (u/uri path)
|
||||
(sto/object->relative-path obj))
|
||||
mdata (meta obj)
|
||||
headers {"x-accel-redirect" (:path purl)
|
||||
"content-type" (:content-type mdata)
|
||||
"cache-control" (str "max-age=" (inst-ms cache-max-age))}]
|
||||
"cache-control" (str "max-age=" (inst-ms cch-max-age))}]
|
||||
{::yres/status 204
|
||||
::yres/headers headers}))
|
||||
|
||||
@ -60,14 +73,28 @@
|
||||
(:s3 :assets-s3) (serve-object-from-s3 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
|
||||
"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]
|
||||
(let [id (get-id request)
|
||||
obj (sto/get-object storage id)]
|
||||
(if obj
|
||||
(serve-object cfg obj)
|
||||
{::yres/status 404})))
|
||||
(cond
|
||||
(nil? obj)
|
||||
{::yres/status 404}
|
||||
|
||||
(and (requires-auth? obj)
|
||||
(nil? (::session/profile-id request)))
|
||||
{::yres/status 401}
|
||||
|
||||
:else
|
||||
(serve-object cfg obj))))
|
||||
|
||||
(defn- generic-handler
|
||||
"A generic handler helper/common code for file-media based handlers."
|
||||
@ -96,11 +123,12 @@
|
||||
(defmethod ig/assert-key ::routes
|
||||
[_ params]
|
||||
(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))))
|
||||
|
||||
(defmethod ig/init-key ::routes
|
||||
[_ cfg]
|
||||
["/assets"
|
||||
["/assets" {:middleware [[session/authz 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/thumbnail" {:handler (partial file-thumbnails-handler cfg)}]])
|
||||
|
||||
@ -53,7 +53,7 @@
|
||||
(let [surl (get body "SubscribeURL")
|
||||
stopic (get body "TopicArn")]
|
||||
(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")
|
||||
(when-let [message (parse-json (get body "Message"))]
|
||||
|
||||
@ -5,13 +5,24 @@
|
||||
;; Copyright (c) KALEIDOS INC
|
||||
|
||||
(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
|
||||
[app.common.schema :as sm]
|
||||
[app.util.ssrf :as ssrf]
|
||||
[cuerdas.core :as str]
|
||||
[integrant.core :as ig]
|
||||
[java-http-clj.core :as http])
|
||||
(:import
|
||||
java.net.http.HttpClient))
|
||||
java.net.http.HttpClient
|
||||
java.net.URI))
|
||||
|
||||
(def default-max-redirects 5)
|
||||
|
||||
(defn client?
|
||||
[o]
|
||||
@ -23,8 +34,8 @@
|
||||
|
||||
(defmethod ig/init-key ::client
|
||||
[_ _]
|
||||
(http/build-client {:connect-timeout 30000 ;; 10s
|
||||
:follow-redirects :always}))
|
||||
(http/build-client {:connect-timeout 30000
|
||||
:follow-redirects :never}))
|
||||
|
||||
(defn send!
|
||||
([client req] (send! client req {}))
|
||||
@ -44,14 +55,82 @@
|
||||
:else
|
||||
(throw (UnsupportedOperationException. "invalid arguments"))))
|
||||
|
||||
(defn req!
|
||||
"A convencience toplevel function for gradual migration to a new API
|
||||
convention."
|
||||
(defn req
|
||||
"Issue a single HTTP request. SSRF validation is applied to the
|
||||
target URI by default; pass `:skip-ssrf-check? true` in `options`
|
||||
to bypass it for known-safe, operator-configured endpoints."
|
||||
([cfg-or-client request]
|
||||
(let [client (resolve-client cfg-or-client)
|
||||
request (update request :uri str)]
|
||||
(send! client request {})))
|
||||
([cfg-or-client request options]
|
||||
(let [client (resolve-client cfg-or-client)
|
||||
request (update request :uri str)]
|
||||
(send! client request options))))
|
||||
(req cfg-or-client request {}))
|
||||
([cfg-or-client request {:keys [skip-ssrf-check?] :as options}]
|
||||
(let [request (if skip-ssrf-check?
|
||||
(update request :uri str)
|
||||
(update request :uri ssrf/validate-uri))
|
||||
client (resolve-client cfg-or-client)]
|
||||
(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
|
||||
:headers headers
|
||||
:body body}
|
||||
resp (http/req! cfg params)]
|
||||
resp (http/req cfg params)]
|
||||
|
||||
(if (= (:status resp) 204)
|
||||
true
|
||||
|
||||
@ -52,12 +52,12 @@
|
||||
trace
|
||||
"```")))
|
||||
|
||||
resp (http/req! cfg
|
||||
{:uri (cf/get :error-report-webhook)
|
||||
:method :post
|
||||
:headers {"content-type" "application/json"}
|
||||
:body (json/encode-str {:text text})}
|
||||
{:sync? true})]
|
||||
resp (http/req cfg
|
||||
{:uri (cf/get :error-report-webhook)
|
||||
:method :post
|
||||
:headers {"content-type" "application/json"}
|
||||
:body (json/encode-str {:text text})}
|
||||
{:sync? true})]
|
||||
|
||||
(when (not= 200 (:status resp))
|
||||
(l/warn :hint "error on sending data"
|
||||
|
||||
@ -159,7 +159,7 @@
|
||||
:method :post
|
||||
:body body}]
|
||||
(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)]
|
||||
(report-delivery! whook req rsp err)
|
||||
(update-webhook! whook err))
|
||||
@ -190,4 +190,11 @@
|
||||
"invalid-uri"
|
||||
|
||||
(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)}
|
||||
|
||||
:app.http.assets/routes
|
||||
{::http.assets/path (cf/get :assets-path)
|
||||
::http.assets/cache-max-age (ct/duration {:hours 24})
|
||||
::http.assets/cache-max-agesignature-max-age (ct/duration {:hours 24 :minutes 5})
|
||||
::sto/storage (ig/ref ::sto/storage)}
|
||||
{::http.assets/path (cf/get :assets-path)
|
||||
::http.assets/cache-max-age (ct/duration {:hours 24})
|
||||
::http.assets/signature-max-age (ct/duration {:hours 24 :minutes 15})
|
||||
::sto/storage (ig/ref ::sto/storage)
|
||||
::session/manager (ig/ref ::session/manager)}
|
||||
|
||||
::rpc/climit
|
||||
{::mtx/metrics (ig/ref ::mtx/metrics)
|
||||
|
||||
@ -18,6 +18,7 @@
|
||||
[app.config :as cf]
|
||||
[app.db :as-alias db]
|
||||
[app.http.client :as http]
|
||||
[app.media.sanitize :as sanitize]
|
||||
[app.storage :as-alias sto]
|
||||
[app.storage.tmp :as tmp]
|
||||
[buddy.core.bytes :as bb]
|
||||
@ -325,9 +326,11 @@
|
||||
|
||||
(let [{:keys [body] :as response}
|
||||
(try
|
||||
(http/req! client
|
||||
{:method :get :uri uri}
|
||||
{:response-type :input-stream})
|
||||
(http/req-with-redirects
|
||||
client
|
||||
{:method :get :uri uri}
|
||||
{:response-type :input-stream
|
||||
:max-redirects 3})
|
||||
(catch java.net.ConnectException cause
|
||||
(ex/raise :type :validation
|
||||
:code :unable-to-download-image
|
||||
@ -358,9 +361,11 @@
|
||||
:code :mismatch-write-size
|
||||
:hint "unexpected state: unable to write to file"))
|
||||
|
||||
{;; :size size
|
||||
:path path
|
||||
:mtype mtype})))
|
||||
;; Sanitize: strip trailing data after image EOF markers
|
||||
(let [new-size (sanitize/truncate-after-eof path mtype)]
|
||||
{:path path
|
||||
:mtype mtype
|
||||
:size new-size}))))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; 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
|
||||
[cfg method uri shared-key profile-id]
|
||||
(fn []
|
||||
(http/req! cfg {:method method
|
||||
:headers {"content-type" "application/json"
|
||||
"accept" "application/json"
|
||||
"x-shared-key" shared-key
|
||||
"x-profile-id" (str profile-id)}
|
||||
:uri uri
|
||||
:version :http1.1})))
|
||||
(http/req cfg {:method method
|
||||
:headers {"content-type" "application/json"
|
||||
"accept" "application/json"
|
||||
"x-shared-key" shared-key
|
||||
"x-profile-id" (str profile-id)}
|
||||
:uri uri
|
||||
:version :http1.1})))
|
||||
|
||||
|
||||
(defn- with-retries
|
||||
|
||||
@ -50,24 +50,27 @@
|
||||
(defn- validate-webhook!
|
||||
[cfg whook params]
|
||||
(when (not= (:uri whook) (:uri params))
|
||||
(let [response (ex/try!
|
||||
(http/req! cfg
|
||||
(try
|
||||
(let [response (http/req cfg
|
||||
{:method :head
|
||||
:uri (str (:uri params))
|
||||
: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))
|
||||
:timeout (ct/duration "3s")})]
|
||||
(when-let [hint (webhooks/interpret-response response)]
|
||||
(ex/raise :type :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!
|
||||
[{:keys [::db/pool]} {:keys [team-id]}]
|
||||
|
||||
@ -57,9 +57,9 @@
|
||||
|
||||
(if (fs/exists? path)
|
||||
(io/input-stream path)
|
||||
(let [resp (http/req! cfg
|
||||
{:method :get :uri (:file-uri template)}
|
||||
{:response-type :input-stream :sync? true})]
|
||||
(let [resp (http/req cfg
|
||||
{:method :get :uri (:file-uri template)}
|
||||
{:response-type :input-stream :sync? true})]
|
||||
(when-not (= 200 (:status resp))
|
||||
(ex/raise :type :internal
|
||||
:code :unexpected-status-code
|
||||
|
||||
@ -30,7 +30,7 @@
|
||||
:uri (cf/get :telemetry-uri)
|
||||
:headers {"content-type" "application/json"}
|
||||
:body (json/encode-str data)}
|
||||
response (http/req! cfg request)]
|
||||
response (http/req cfg request)]
|
||||
(when (> (:status response) 206)
|
||||
(ex/raise :type :internal
|
||||
: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/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})
|
||||
whk (th/create-webhook* {:team-id (:default-team-id prof)})
|
||||
evt {:type "command"
|
||||
@ -63,7 +63,7 @@
|
||||
(t/is (nil? (:error-code whk')))))))
|
||||
|
||||
(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})
|
||||
whk (th/create-webhook* {:team-id (:default-team-id prof)})
|
||||
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/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")}]
|
||||
(let [cfg {::http/client :mock-client}
|
||||
err (try
|
||||
(media/download-image cfg "http://unreachable.invalid/image.png")
|
||||
(media/download-image cfg "https://example.com/image.png")
|
||||
nil
|
||||
(catch clojure.lang.ExceptionInfo e e))]
|
||||
(t/is (some? err))
|
||||
@ -298,11 +298,11 @@
|
||||
(t/is (= :unable-to-download-image (:code (ex-data err)))))))
|
||||
|
||||
(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")}]
|
||||
(let [cfg {::http/client :mock-client}
|
||||
err (try
|
||||
(media/download-image cfg "http://unreachable.invalid/image.png")
|
||||
(media/download-image cfg "https://example.com/image.png")
|
||||
nil
|
||||
(catch clojure.lang.ExceptionInfo e e))]
|
||||
(t/is (some? err))
|
||||
@ -310,11 +310,11 @@
|
||||
(t/is (= :unable-to-download-image (:code (ex-data err)))))))
|
||||
|
||||
(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")}]
|
||||
(let [cfg {::http/client :mock-client}
|
||||
err (try
|
||||
(media/download-image cfg "http://unreachable.invalid/image.png")
|
||||
(media/download-image cfg "https://example.com/image.png")
|
||||
nil
|
||||
(catch clojure.lang.ExceptionInfo e e))]
|
||||
(t/is (some? err))
|
||||
@ -322,11 +322,11 @@
|
||||
(t/is (= :unable-to-download-image (:code (ex-data err)))))))
|
||||
|
||||
(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")}]
|
||||
(let [cfg {::http/client :mock-client}
|
||||
err (try
|
||||
(media/download-image cfg "http://unreachable.invalid/image.png")
|
||||
(media/download-image cfg "https://example.com/image.png")
|
||||
nil
|
||||
(catch clojure.lang.ExceptionInfo e e))]
|
||||
(t/is (some? err))
|
||||
@ -336,14 +336,14 @@
|
||||
|
||||
(t/deftest download-image-status-code-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
|
||||
:headers {"content-type" "text/html"
|
||||
"content-length" "0"}
|
||||
:body nil}}]
|
||||
(let [cfg {::http/client :mock-client}
|
||||
err (try
|
||||
(media/download-image cfg "http://example.com/not-found.png")
|
||||
(media/download-image cfg "https://example.com/not-found.png")
|
||||
nil
|
||||
(catch clojure.lang.ExceptionInfo e e))]
|
||||
(t/is (some? err))
|
||||
@ -351,14 +351,14 @@
|
||||
(t/is (= :unable-to-download-image (:code (ex-data err)))))))
|
||||
|
||||
(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
|
||||
:headers {"content-type" "text/html"
|
||||
"content-length" "0"}
|
||||
:body nil}}]
|
||||
(let [cfg {::http/client :mock-client}
|
||||
err (try
|
||||
(media/download-image cfg "http://example.com/server-error.png")
|
||||
(media/download-image cfg "https://example.com/server-error.png")
|
||||
nil
|
||||
(catch clojure.lang.ExceptionInfo e e))]
|
||||
(t/is (some? err))
|
||||
@ -366,14 +366,14 @@
|
||||
(t/is (= :unable-to-download-image (:code (ex-data err)))))))
|
||||
|
||||
(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
|
||||
:headers {"content-type" "text/html"
|
||||
"content-length" "0"}
|
||||
:body nil}}]
|
||||
(let [cfg {::http/client :mock-client}
|
||||
err (try
|
||||
(media/download-image cfg "http://example.com/redirect.png")
|
||||
(media/download-image cfg "https://example.com/redirect.png")
|
||||
nil
|
||||
(catch clojure.lang.ExceptionInfo e e))]
|
||||
(t/is (some? err))
|
||||
|
||||
@ -37,7 +37,7 @@
|
||||
(t/is (contains? result :mtype))))
|
||||
|
||||
(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}}]
|
||||
|
||||
(let [prof (th/create-profile* 1 {:is-active true})
|
||||
@ -151,7 +151,7 @@
|
||||
(t/is (= (:code error-data) :object-not-found))))))))
|
||||
|
||||
(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}}]
|
||||
(let [owner (th/create-profile* 1 {:is-active true})
|
||||
viewer (th/create-profile* 2 {:is-active true})
|
||||
@ -214,7 +214,7 @@
|
||||
(th/reset-mock! http-mock))))
|
||||
|
||||
(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}}]
|
||||
(let [owner (th/create-profile* 1 {:is-active true})
|
||||
viewer (th/create-profile* 2 {:is-active true})
|
||||
@ -269,7 +269,7 @@
|
||||
(t/is (= (:code error-data) :object-not-found)))))))
|
||||
|
||||
(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}}]
|
||||
|
||||
(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]
|
||||
#?(:cljs (.-byteLength ^js 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
|
||||
[{:keys [uri mtype is-active] :as params}]
|
||||
(dm/assert! (contains? valid-mtypes mtype))
|
||||
(dm/assert! (boolean? is-active))
|
||||
(dm/assert! (u/uri? uri))
|
||||
|
||||
(assert (contains? valid-mtypes mtype))
|
||||
(assert (boolean? is-active))
|
||||
(assert (u/uri? uri))
|
||||
|
||||
(ptk/reify ::create-webhook
|
||||
ptk/WatchEvent
|
||||
|
||||
@ -1018,7 +1018,31 @@
|
||||
|
||||
(defn- extract-status
|
||||
[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/register modal/components
|
||||
@ -1027,7 +1051,7 @@
|
||||
|
||||
(let [initial (mf/with-memo []
|
||||
(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
|
||||
:initial initial)
|
||||
on-success
|
||||
@ -1039,25 +1063,14 @@
|
||||
|
||||
on-error
|
||||
(mf/use-fn
|
||||
(fn [form error]
|
||||
(let [{:keys [type code hint]} (ex-data error)]
|
||||
(fn [form cause]
|
||||
(let [{:keys [type code hint] :as error} (ex-data cause)]
|
||||
(if (and (= type :validation)
|
||||
(= code :webhook-validation))
|
||||
(let [message (cond
|
||||
(= hint "unknown")
|
||||
(tr "errors.webhooks.unexpected")
|
||||
(= 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)))]
|
||||
(swap! form assoc-in [:errors :uri] {:message message}))
|
||||
(rx/throw error)))))
|
||||
(let [message (translate-error-hint hint)]
|
||||
(swap! form assoc-in [:extra-errors :uri] {:message message})
|
||||
(rx/empty))
|
||||
(rx/throw cause)))))
|
||||
|
||||
on-create-submit
|
||||
(mf/use-fn
|
||||
@ -1087,6 +1100,7 @@
|
||||
(if (:id data)
|
||||
(on-update-submit form)
|
||||
(on-create-submit form)))))]
|
||||
|
||||
[:div {:class (stl/css :modal-overlay)}
|
||||
[:div {:class (stl/css :modal-container)}
|
||||
[:& fm/form {:form form :on-submit on-submit}
|
||||
@ -1212,8 +1226,10 @@
|
||||
(dm/str " " (tr "errors.webhooks.ssl-validation"))
|
||||
|
||||
(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-field :last-delivery)
|
||||
|
||||
@ -16,9 +16,9 @@
|
||||
"fmt": "./scripts/fmt"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@github/copilot": "^1.0.36",
|
||||
"@github/copilot": "^1.0.43",
|
||||
"@types/node": "^25.6.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:
|
||||
'@github/copilot':
|
||||
specifier: ^1.0.36
|
||||
version: 1.0.36
|
||||
specifier: ^1.0.43
|
||||
version: 1.0.43
|
||||
'@types/node':
|
||||
specifier: ^25.6.0
|
||||
version: 25.6.0
|
||||
@ -18,8 +18,8 @@ importers:
|
||||
specifier: ^0.28.0
|
||||
version: 0.28.0
|
||||
opencode-ai:
|
||||
specifier: ^1.14.28
|
||||
version: 1.14.28
|
||||
specifier: ^1.14.40
|
||||
version: 1.14.40
|
||||
|
||||
packages:
|
||||
|
||||
@ -179,44 +179,44 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@github/copilot-darwin-arm64@1.0.36':
|
||||
resolution: {integrity: sha512-5qkb7frTS4K/LdTDLrzKo78VR4aw/EZ6JzLz4KfmaW4UYyPiNirExDFXa/By22X0o8YMfOp4MCA2KSCAxKdgTg==}
|
||||
'@github/copilot-darwin-arm64@1.0.43':
|
||||
resolution: {integrity: sha512-VMaWfoUwIt19TGzmvTv/In5ITgFWfu91ZILt4Lb77gSmVbwbs+DahP2lbvM1s/GtGwOknwhOJLp4q2WMgK3CoQ==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
hasBin: true
|
||||
|
||||
'@github/copilot-darwin-x64@1.0.36':
|
||||
resolution: {integrity: sha512-AdsM8QtM5QSzMLpavLREh8HALO5G+VWzGNQqIHu4f0YQC/s1cGoiwo3wsgkpxRcLGBykFc+bDX3yK3MDQ8XvSw==}
|
||||
'@github/copilot-darwin-x64@1.0.43':
|
||||
resolution: {integrity: sha512-lCxg75zbgtWggb1p+IHhlhmulQj7BKIc+9pUsTwJ3Mjt51kiUYmD6LF2BD1/Ed4M3GMumSu4UTSIv9pL96n0Wg==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
hasBin: true
|
||||
|
||||
'@github/copilot-linux-arm64@1.0.36':
|
||||
resolution: {integrity: sha512-n7K1I6r0ggOJ4A9uAMS11USTvn6BKtAwvrOkzEaeRK89VNUJzpTe6p0mE13ItzRe5eot9WLBQOxvXLtL9f6E+g==}
|
||||
'@github/copilot-linux-arm64@1.0.43':
|
||||
resolution: {integrity: sha512-Jr1rvt/Syz5oVSiU53keRKEV8f5xTLkmiB2qasAV6Emk7K82/BZW5HfW8cDadfHnlAS1+UVPpoO+8ykgx4dikw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
hasBin: true
|
||||
|
||||
'@github/copilot-linux-x64@1.0.36':
|
||||
resolution: {integrity: sha512-wBtCdR3ITZcq07BJbkwHfwI6ayiwbH5pF1ex+Ycl4UI+Lf1vP9eQD6wJppPgsrjwFcdeWRThaYTPCRTkSGHv5g==}
|
||||
'@github/copilot-linux-x64@1.0.43':
|
||||
resolution: {integrity: sha512-uyWyPpcwMC1tIHJTA1OPzIm1i/Eeku0NjFzPYnFvpfGug9pkL4xcUr8A2UNl8cVvxq/VQMwtYckV3G12ySJTFw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
hasBin: true
|
||||
|
||||
'@github/copilot-win32-arm64@1.0.36':
|
||||
resolution: {integrity: sha512-0GzZUZQn07alI8BgbzK0NlR5+ta/Rd0sWmd8kbRCns7oybAIkSALy6BKVwJmVHtXUi6h4iUE8oiFhkn0spymvw==}
|
||||
'@github/copilot-win32-arm64@1.0.43':
|
||||
resolution: {integrity: sha512-vY3rwkW1h7ixSqYd9XT/xGjxkepvQVJK2q1sYhSPBN4Wvuk37fRjN7ox3QU1lhpxlYQMc/g+ZR58u5cx31n1lw==}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
hasBin: true
|
||||
|
||||
'@github/copilot-win32-x64@1.0.36':
|
||||
resolution: {integrity: sha512-UBX9qj0McCK/SLq93XIr1i80fj3b3XmE3befVFrzxQuTeOoxLURN35vi7W+4x+4ZfsDHQpRTlJNjZw9w0fPr+Q==}
|
||||
'@github/copilot-win32-x64@1.0.43':
|
||||
resolution: {integrity: sha512-AjGsebDbJmBxe9FFf2McSN5r2Joi8cFY879lxaQaXxfGjzOHblzvbhflbsX9x2GnvCfDdDiow413WvOGqKDH8g==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
hasBin: true
|
||||
|
||||
'@github/copilot@1.0.36':
|
||||
resolution: {integrity: sha512-x0N5wLzw+tANzb+vCFYLHn3BV3qii2oyn14wC20RO7SsS8/YeBH8olvwlDLJ4PB0mL17QOiytNCdkvjvprm28w==}
|
||||
'@github/copilot@1.0.43':
|
||||
resolution: {integrity: sha512-2FO825Aq4bwmHcXVyplW+CpZaJFUYjqtqjBGnueM31gu4ufn6ReurzB2swBQ6bn4Pquyy2KeodMRPpT6JaLMhw==}
|
||||
hasBin: true
|
||||
|
||||
'@types/node@25.6.0':
|
||||
@ -227,67 +227,67 @@ packages:
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
opencode-ai@1.14.28:
|
||||
resolution: {integrity: sha512-ZPukJNvujSVa+LVoXvj2ciUV57UcnuxmMtzpFQBYd6fbhjeT1vMC6jCurO/5mIp76fiPmGM7ilzRXVeY6bIwPw==}
|
||||
opencode-ai@1.14.40:
|
||||
resolution: {integrity: sha512-2Iwqe3wLdAGWxWGnTAPeOv7QNcm4stuWL2VQ7FM6OxKRE0h9zGoKtJ/bnX193hR6/QH81goOrocKfamRk4pM/A==}
|
||||
hasBin: true
|
||||
|
||||
opencode-darwin-arm64@1.14.28:
|
||||
resolution: {integrity: sha512-Gu2vZYACAeoewfPhgJDAaScwRo1K5YZq7tVpPKw2rpul34OpOPLk4oB4Pmr539iWiagK+DLuUxnbJIbRRYCS5g==}
|
||||
opencode-darwin-arm64@1.14.40:
|
||||
resolution: {integrity: sha512-dVItdZeaJw9xRtpVKQ0K9sigcaf8p/3nr3hW/NxJY3I/L+op29Wly5jnH0sdxq05gV/cGmJ0BTJzoYizXDDoKg==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
opencode-darwin-x64-baseline@1.14.28:
|
||||
resolution: {integrity: sha512-/KsZkZh5oh6urHWwIHJudS6sedBil59E/4o7/7TuxPy/pOdRlSlSWVkMJd20AmqM4G/qILF/GthXy3D2+f99Tg==}
|
||||
opencode-darwin-x64-baseline@1.14.40:
|
||||
resolution: {integrity: sha512-ab2vqJmTPG48U2/6xZvPcZ+HdQBHFt6rpS0w2gURJobXHWgbW+uytfX4MCBprunS3K1DL7qoVWIgJRRu3YEMzQ==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
opencode-darwin-x64@1.14.28:
|
||||
resolution: {integrity: sha512-D6BnAXlSdQDRtZgAg6OxWT6CEzbbONnlYof9hdPbaIIaNyBLjqK+Er2O1rrbiXFhSbs7YHBiDoGd+nNUymx4Ng==}
|
||||
opencode-darwin-x64@1.14.40:
|
||||
resolution: {integrity: sha512-Mcfh2LP/QA3LJ03m6OF1FNCMO/O0PsT94Mw7NXbcmi6g+nQ6I2vHensIjKtTJM3LSmosOlTduxdv/b82AVFt+w==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
opencode-linux-arm64-musl@1.14.28:
|
||||
resolution: {integrity: sha512-7R1GHqSg/UuT9r77GF2skh8r66WkZcphmDWAWaV2dmptJlxEJeV9I2jbE2i8Ctp4BzPUexFqfSoBA82S9Dcf+A==}
|
||||
opencode-linux-arm64-musl@1.14.40:
|
||||
resolution: {integrity: sha512-5COW6bDCCbM/BZcrkiVR91VqrP9vD8J4uS7cHLPbdQHaDYETxOhHWgsYnp9JXWEbcDi6lBaxapj11RZPyUJziw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
opencode-linux-arm64@1.14.28:
|
||||
resolution: {integrity: sha512-jdTrs4YpPGFGZOMLuiaSfOUzkjAA+lnIEaW6HYLvaey3WsBnu3S4utaBhXURincp20H1JPQcahDOe+jjGZH7xw==}
|
||||
opencode-linux-arm64@1.14.40:
|
||||
resolution: {integrity: sha512-nqsnGxV237XWCbaMtFXOiN1ndukNAhAmbe5dnDThSFOKcgwMs6VKuhHa+iOpQL1r6FZ5eE3SU6IKgscVM/eAAQ==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
opencode-linux-x64-baseline-musl@1.14.28:
|
||||
resolution: {integrity: sha512-GKxZXj8/Mbutfs1DW4v0/rEWcAQrD/RUI9kV9VhMoNA8vUt0nuA3H9UvbFXh9EJj2C+RBSPLlMGal++oCH4c4w==}
|
||||
opencode-linux-x64-baseline-musl@1.14.40:
|
||||
resolution: {integrity: sha512-l0xfAHVy2klD181UbKUjeMVltqEn+9DTP/bZ9ZyDuiFbVNeKfwiU7HMkPw5GFnSnlQTUrDHniXMwgBcI5yrRcA==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
opencode-linux-x64-baseline@1.14.28:
|
||||
resolution: {integrity: sha512-Dtl+xjEAKaWNk2l3iC9ebwi79BkChHIdtx97ksZKTLjAeR424Zh3vnjuWjpMYk9YAnesVlwL8y4kHs2Y736Zpw==}
|
||||
opencode-linux-x64-baseline@1.14.40:
|
||||
resolution: {integrity: sha512-XmXJHicRNylnGz/nzJCmsXy6mozjmrrXHntrrS3DWgq8UiSdg9wB2Hsq+1EDbPjzF2NzQaVi6/9/AyERuxW2iA==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
opencode-linux-x64-musl@1.14.28:
|
||||
resolution: {integrity: sha512-XyzWl35L8N6El/hxAM28bDUHLCY0aujMtprDTCYXVckeNxBkN3idM4EfdLtJaUHkE6bqMr+m6wXQl4oYDoOtpg==}
|
||||
opencode-linux-x64-musl@1.14.40:
|
||||
resolution: {integrity: sha512-AU8SAE4aOaGxzTDcv95GU8vrWeiYOzPVEt5LfcqhmOmhRiuN0jnDBO/gl62SITkGyu/sSes554L4UqkqkglC7g==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
opencode-linux-x64@1.14.28:
|
||||
resolution: {integrity: sha512-XnpQrud15bvUBvOI58tOGUBTrwqKHl6bYQ3eoy5HhGa2spUnRv3B/HU8QiS6QuNbmkPxRPR+vuTGtBYQvtRGPw==}
|
||||
opencode-linux-x64@1.14.40:
|
||||
resolution: {integrity: sha512-Cb+keGDsjo1wyOJ2Kf+KOZWlJUs1fD/VmjYepl9Fv3KGqWQNhSOH/4kiwj3KHWwEMWroCtw5KnAFykiOgbsz8A==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
opencode-windows-arm64@1.14.28:
|
||||
resolution: {integrity: sha512-emR1oEoLe6soASahJNX6IwR9x8rJkbwBXDnXNTWQcGdSxKBMD4/cLkq84k/5zqLfB7dbUChTw7eFz7u8Sa5VQw==}
|
||||
opencode-windows-arm64@1.14.40:
|
||||
resolution: {integrity: sha512-049+Z8G4o10OO7s5dGIVIZ31DMR+0JlAWCM/XR+VmBlbPeZYRKL/9Q2dM0Ljw11ekZ1qbZfGlK7xUEHkwvy8fQ==}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
opencode-windows-x64-baseline@1.14.28:
|
||||
resolution: {integrity: sha512-ARKHTThHezib44QPLiivYI8c71iNE9iNDubwV5XxUhM2FtzMJkZGma+EgbcCsXwY5r0lAsarzzDMqYB0YfCZ1A==}
|
||||
opencode-windows-x64-baseline@1.14.40:
|
||||
resolution: {integrity: sha512-6JKSyc8oyCw4ZLMZ38l9/M7iQYfMF7qnc3d+P9dpfx80FiqHwWBxgYaGEaNHkQmoiTPVc39EsjaOiiBOLGZOQA==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
opencode-windows-x64@1.14.28:
|
||||
resolution: {integrity: sha512-tEpblIEdmlJ7npo5Bq+1O7uup9jCOyqnnA63t+3JQiNQ1et3UTjNb5ruAjb7sudUer6i5MlQCwNXBjitjuU4Kg==}
|
||||
opencode-windows-x64@1.14.40:
|
||||
resolution: {integrity: sha512-0+La4i8fj+E+kj6hu8sQKfjbHm6VgbbSiZ6vo3fYqU4+Ex2UIyNa31A3rWOQ9EFbmKQ8jb2PmfBDvDE0ihJvRw==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
@ -374,32 +374,32 @@ snapshots:
|
||||
'@esbuild/win32-x64@0.28.0':
|
||||
optional: true
|
||||
|
||||
'@github/copilot-darwin-arm64@1.0.36':
|
||||
'@github/copilot-darwin-arm64@1.0.43':
|
||||
optional: true
|
||||
|
||||
'@github/copilot-darwin-x64@1.0.36':
|
||||
'@github/copilot-darwin-x64@1.0.43':
|
||||
optional: true
|
||||
|
||||
'@github/copilot-linux-arm64@1.0.36':
|
||||
'@github/copilot-linux-arm64@1.0.43':
|
||||
optional: true
|
||||
|
||||
'@github/copilot-linux-x64@1.0.36':
|
||||
'@github/copilot-linux-x64@1.0.43':
|
||||
optional: true
|
||||
|
||||
'@github/copilot-win32-arm64@1.0.36':
|
||||
'@github/copilot-win32-arm64@1.0.43':
|
||||
optional: true
|
||||
|
||||
'@github/copilot-win32-x64@1.0.36':
|
||||
'@github/copilot-win32-x64@1.0.43':
|
||||
optional: true
|
||||
|
||||
'@github/copilot@1.0.36':
|
||||
'@github/copilot@1.0.43':
|
||||
optionalDependencies:
|
||||
'@github/copilot-darwin-arm64': 1.0.36
|
||||
'@github/copilot-darwin-x64': 1.0.36
|
||||
'@github/copilot-linux-arm64': 1.0.36
|
||||
'@github/copilot-linux-x64': 1.0.36
|
||||
'@github/copilot-win32-arm64': 1.0.36
|
||||
'@github/copilot-win32-x64': 1.0.36
|
||||
'@github/copilot-darwin-arm64': 1.0.43
|
||||
'@github/copilot-darwin-x64': 1.0.43
|
||||
'@github/copilot-linux-arm64': 1.0.43
|
||||
'@github/copilot-linux-x64': 1.0.43
|
||||
'@github/copilot-win32-arm64': 1.0.43
|
||||
'@github/copilot-win32-x64': 1.0.43
|
||||
|
||||
'@types/node@25.6.0':
|
||||
dependencies:
|
||||
@ -434,55 +434,55 @@ snapshots:
|
||||
'@esbuild/win32-ia32': 0.28.0
|
||||
'@esbuild/win32-x64': 0.28.0
|
||||
|
||||
opencode-ai@1.14.28:
|
||||
opencode-ai@1.14.40:
|
||||
optionalDependencies:
|
||||
opencode-darwin-arm64: 1.14.28
|
||||
opencode-darwin-x64: 1.14.28
|
||||
opencode-darwin-x64-baseline: 1.14.28
|
||||
opencode-linux-arm64: 1.14.28
|
||||
opencode-linux-arm64-musl: 1.14.28
|
||||
opencode-linux-x64: 1.14.28
|
||||
opencode-linux-x64-baseline: 1.14.28
|
||||
opencode-linux-x64-baseline-musl: 1.14.28
|
||||
opencode-linux-x64-musl: 1.14.28
|
||||
opencode-windows-arm64: 1.14.28
|
||||
opencode-windows-x64: 1.14.28
|
||||
opencode-windows-x64-baseline: 1.14.28
|
||||
opencode-darwin-arm64: 1.14.40
|
||||
opencode-darwin-x64: 1.14.40
|
||||
opencode-darwin-x64-baseline: 1.14.40
|
||||
opencode-linux-arm64: 1.14.40
|
||||
opencode-linux-arm64-musl: 1.14.40
|
||||
opencode-linux-x64: 1.14.40
|
||||
opencode-linux-x64-baseline: 1.14.40
|
||||
opencode-linux-x64-baseline-musl: 1.14.40
|
||||
opencode-linux-x64-musl: 1.14.40
|
||||
opencode-windows-arm64: 1.14.40
|
||||
opencode-windows-x64: 1.14.40
|
||||
opencode-windows-x64-baseline: 1.14.40
|
||||
|
||||
opencode-darwin-arm64@1.14.28:
|
||||
opencode-darwin-arm64@1.14.40:
|
||||
optional: true
|
||||
|
||||
opencode-darwin-x64-baseline@1.14.28:
|
||||
opencode-darwin-x64-baseline@1.14.40:
|
||||
optional: true
|
||||
|
||||
opencode-darwin-x64@1.14.28:
|
||||
opencode-darwin-x64@1.14.40:
|
||||
optional: true
|
||||
|
||||
opencode-linux-arm64-musl@1.14.28:
|
||||
opencode-linux-arm64-musl@1.14.40:
|
||||
optional: true
|
||||
|
||||
opencode-linux-arm64@1.14.28:
|
||||
opencode-linux-arm64@1.14.40:
|
||||
optional: true
|
||||
|
||||
opencode-linux-x64-baseline-musl@1.14.28:
|
||||
opencode-linux-x64-baseline-musl@1.14.40:
|
||||
optional: true
|
||||
|
||||
opencode-linux-x64-baseline@1.14.28:
|
||||
opencode-linux-x64-baseline@1.14.40:
|
||||
optional: true
|
||||
|
||||
opencode-linux-x64-musl@1.14.28:
|
||||
opencode-linux-x64-musl@1.14.40:
|
||||
optional: true
|
||||
|
||||
opencode-linux-x64@1.14.28:
|
||||
opencode-linux-x64@1.14.40:
|
||||
optional: true
|
||||
|
||||
opencode-windows-arm64@1.14.28:
|
||||
opencode-windows-arm64@1.14.40:
|
||||
optional: true
|
||||
|
||||
opencode-windows-x64-baseline@1.14.28:
|
||||
opencode-windows-x64-baseline@1.14.40:
|
||||
optional: true
|
||||
|
||||
opencode-windows-x64@1.14.28:
|
||||
opencode-windows-x64@1.14.40:
|
||||
optional: true
|
||||
|
||||
undici-types@7.19.2: {}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user