diff --git a/CHANGES.md b/CHANGES.md index e2d49530dd..6ca692c1fc 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -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) diff --git a/backend/src/app/auth/oidc.clj b/backend/src/app/auth/oidc.clj index fa819c5e0c..3995a056e0 100644 --- a/backend/src/app/auth/oidc.clj +++ b/backend/src/app/auth/oidc.clj @@ -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) diff --git a/backend/src/app/config.clj b/backend/src/app/config.clj index 7415430f6f..1a2a6c6bd9 100644 --- a/backend/src/app/config.clj +++ b/backend/src/app/config.clj @@ -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] diff --git a/backend/src/app/http/assets.clj b/backend/src/app/http/assets.clj index c716ce8b85..d07090e074 100644 --- a/backend/src/app/http/assets.clj +++ b/backend/src/app/http/assets.clj @@ -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)}]]) diff --git a/backend/src/app/http/awsns.clj b/backend/src/app/http/awsns.clj index 051a2ecf8b..c90b0d7a82 100644 --- a/backend/src/app/http/awsns.clj +++ b/backend/src/app/http/awsns.clj @@ -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"))] diff --git a/backend/src/app/http/client.clj b/backend/src/app/http/client.clj index 7fcbd3ff00..5e10ee4c8f 100644 --- a/backend/src/app/http/client.clj +++ b/backend/src/app/http/client.clj @@ -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)))))) diff --git a/backend/src/app/loggers/audit/archive_task.clj b/backend/src/app/loggers/audit/archive_task.clj index 62024e573b..55a119263d 100644 --- a/backend/src/app/loggers/audit/archive_task.clj +++ b/backend/src/app/loggers/audit/archive_task.clj @@ -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 diff --git a/backend/src/app/loggers/mattermost.clj b/backend/src/app/loggers/mattermost.clj index c2fd3d1dfb..52fd2edce3 100644 --- a/backend/src/app/loggers/mattermost.clj +++ b/backend/src/app/loggers/mattermost.clj @@ -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" diff --git a/backend/src/app/loggers/webhooks.clj b/backend/src/app/loggers/webhooks.clj index 8edb646260..eab1344047 100644 --- a/backend/src/app/loggers/webhooks.clj +++ b/backend/src/app/loggers/webhooks.clj @@ -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)))) diff --git a/backend/src/app/main.clj b/backend/src/app/main.clj index a1501e3ca0..9bcc96519a 100644 --- a/backend/src/app/main.clj +++ b/backend/src/app/main.clj @@ -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) diff --git a/backend/src/app/media.clj b/backend/src/app/media.clj index 863d2e9df2..58151a800c 100644 --- a/backend/src/app/media.clj +++ b/backend/src/app/media.clj @@ -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 diff --git a/backend/src/app/media/sanitize.clj b/backend/src/app/media/sanitize.clj new file mode 100644 index 0000000000..4905f5b603 --- /dev/null +++ b/backend/src/app/media/sanitize.clj @@ -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))))) diff --git a/backend/src/app/nitrate.clj b/backend/src/app/nitrate.clj index 14c1da0e99..bc649ca3cc 100644 --- a/backend/src/app/nitrate.clj +++ b/backend/src/app/nitrate.clj @@ -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 diff --git a/backend/src/app/rpc/commands/webhooks.clj b/backend/src/app/rpc/commands/webhooks.clj index 5b64541824..7a3c441335 100644 --- a/backend/src/app/rpc/commands/webhooks.clj +++ b/backend/src/app/rpc/commands/webhooks.clj @@ -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]}] diff --git a/backend/src/app/setup/templates.clj b/backend/src/app/setup/templates.clj index 476ec25f58..5c86bc485d 100644 --- a/backend/src/app/setup/templates.clj +++ b/backend/src/app/setup/templates.clj @@ -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 diff --git a/backend/src/app/tasks/telemetry.clj b/backend/src/app/tasks/telemetry.clj index aa2cae58e0..c6d989694b 100644 --- a/backend/src/app/tasks/telemetry.clj +++ b/backend/src/app/tasks/telemetry.clj @@ -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 diff --git a/backend/src/app/util/nio.clj b/backend/src/app/util/nio.clj new file mode 100644 index 0000000000..676d95aea3 --- /dev/null +++ b/backend/src/app/util/nio.clj @@ -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) diff --git a/backend/src/app/util/ssrf.clj b/backend/src/app/util/ssrf.clj new file mode 100644 index 0000000000..5348f1eaf7 --- /dev/null +++ b/backend/src/app/util/ssrf.clj @@ -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))) diff --git a/backend/test/backend_tests/loggers_webhooks_test.clj b/backend/test/backend_tests/loggers_webhooks_test.clj index c34df71543..d77b3b6be0 100644 --- a/backend/test/backend_tests/loggers_webhooks_test.clj +++ b/backend/test/backend_tests/loggers_webhooks_test.clj @@ -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" diff --git a/backend/test/backend_tests/media_sanitize_test.clj b/backend/test/backend_tests/media_sanitize_test.clj new file mode 100644 index 0000000000..0f8629a029 --- /dev/null +++ b/backend/test/backend_tests/media_sanitize_test.clj @@ -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 "") + 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 "") + 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)))))) diff --git a/backend/test/backend_tests/rpc_media_test.clj b/backend/test/backend_tests/rpc_media_test.clj index 070a105a1b..2c2bea19e9 100644 --- a/backend/test/backend_tests/rpc_media_test.clj +++ b/backend/test/backend_tests/rpc_media_test.clj @@ -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)) diff --git a/backend/test/backend_tests/rpc_webhooks_test.clj b/backend/test/backend_tests/rpc_webhooks_test.clj index bc1da4c64f..703c9b0643 100644 --- a/backend/test/backend_tests/rpc_webhooks_test.clj +++ b/backend/test/backend_tests/rpc_webhooks_test.clj @@ -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}) diff --git a/backend/test/backend_tests/test_files/sample.png b/backend/test/backend_tests/test_files/sample.png new file mode 100644 index 0000000000..586da94a96 Binary files /dev/null and b/backend/test/backend_tests/test_files/sample.png differ diff --git a/backend/test/backend_tests/test_files/sample.webp b/backend/test/backend_tests/test_files/sample.webp new file mode 100644 index 0000000000..be1eb528b5 Binary files /dev/null and b/backend/test/backend_tests/test_files/sample.webp differ diff --git a/backend/test/backend_tests/util_ssrf_test.clj b/backend/test/backend_tests/util_ssrf_test.clj new file mode 100644 index 0000000000..04e86291fd --- /dev/null +++ b/backend/test/backend_tests/util_ssrf_test.clj @@ -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,