diff --git a/server/src/instant/reactive/session.clj b/server/src/instant/reactive/session.clj index 3aa14318fd..e43de3d8d7 100644 --- a/server/src/instant/reactive/session.clj +++ b/server/src/instant/reactive/session.clj @@ -976,6 +976,12 @@ {:keys [session/socket]} session {:keys [id]} socket] (tracer/add-data! {:attributes (event-attributes store id event)}) + ;; Rate-limit at the op level so the error rides back on the original event + ;; and surfaces through the client's per-query / per-mutation error paths. + (when-let [app-id (and (not (contains? #{:init :sse-init :error} op)) + (-> session :session/auth :app :id))] + (when (flags/app-rate-limited? app-id) + (ex/throw-rate-limited!))) (case op :init (handle-init! store id event) :sse-init (handle-sse-init! store id event) @@ -1047,6 +1053,8 @@ ::ex/app-read-only ::ex/app-disabled + ::ex/rate-limited + ::ex/param-missing ::ex/param-malformed diff --git a/server/src/instant/runtime/routes.clj b/server/src/instant/runtime/routes.clj index 60cded4520..6b2d36f854 100644 --- a/server/src/instant/runtime/routes.clj +++ b/server/src/instant/runtime/routes.clj @@ -743,23 +743,32 @@ (response/ok {:result result :attrs attrs}))) (defroutes routes - (POST "/runtime/auth/send_magic_code" [] send-magic-code-post) - (POST "/runtime/auth/verify_magic_code" [] verify-magic-code-post) - (POST "/runtime/auth/verify_refresh_token" [] verify-refresh-token-post) - (POST "/runtime/auth/sign_in_guest" [] sign-in-guest-post) - (GET "/runtime/oauth/start" [] (wrap-cookies oauth-start - {:decoder parse-cookie})) - (GET "/runtime/:app_id/oauth/start" [] (wrap-cookies oauth-start - {:decoder parse-cookie})) + (POST "/runtime/auth/send_magic_code" [] + (http-util/with-rate-limiting send-magic-code-post)) + (POST "/runtime/auth/verify_magic_code" [] + (http-util/with-rate-limiting verify-magic-code-post)) + (POST "/runtime/auth/verify_refresh_token" [] + (http-util/with-rate-limiting verify-refresh-token-post)) + (POST "/runtime/auth/sign_in_guest" [] + (http-util/with-rate-limiting sign-in-guest-post)) + (GET "/runtime/oauth/start" [] (http-util/with-rate-limiting + (wrap-cookies oauth-start + {:decoder parse-cookie}))) + (GET "/runtime/:app_id/oauth/start" [] (http-util/with-rate-limiting + (wrap-cookies oauth-start + {:decoder parse-cookie}))) (GET "/runtime/oauth/callback" [] (wrap-cookies oauth-callback {:decoder parse-cookie})) (POST "/runtime/oauth/callback" [] (wrap-cookies oauth-callback {:decoder parse-cookie})) - (POST "/runtime/framework/query" [] framework-query-triples) + (POST "/runtime/framework/query" [] + (http-util/with-rate-limiting framework-query-triples)) (POST "/runtime/oauth/token" [] oauth-token-callback) - (POST "/runtime/:app_id/oauth/token" [] oauth-token-callback) + (POST "/runtime/:app_id/oauth/token" [] (http-util/with-rate-limiting oauth-token-callback)) (POST "/runtime/oauth/id_token" [] oauth-id-token-callback) + ;; The realtime transports (`/runtime/session` and `/runtime/sse`) are gated + ;; by the rate-limit check in `session.clj`, so we leave off route wrapper (GET "/runtime/session" [] session-get) (GET "/runtime/sse" [] sse-get) (POST "/runtime/sse" [] sse-post) diff --git a/server/src/instant/storage/routes.clj b/server/src/instant/storage/routes.clj index bc776e4826..d6bb684129 100644 --- a/server/src/instant/storage/routes.clj +++ b/server/src/instant/storage/routes.clj @@ -69,8 +69,8 @@ (response/ok {:data data}))) (defroutes routes - (PUT "/storage/upload" [] upload-put) - (DELETE "/storage/files" [] file-delete) - (POST "/storage/signed-upload-url" [] create-upload-url-post) + (PUT "/storage/upload" [] (http-util/with-rate-limiting upload-put)) + (DELETE "/storage/files" [] (http-util/with-rate-limiting file-delete)) + (POST "/storage/signed-upload-url" [] (http-util/with-rate-limiting create-upload-url-post)) (PUT "/storage/:upload-id/consume-upload-url" [] consume-upload-url-put) - (GET "/storage/signed-download-url" [] signed-download-url-get)) + (GET "/storage/signed-download-url" [] (http-util/with-rate-limiting signed-download-url-get))) diff --git a/server/src/instant/util/http.clj b/server/src/instant/util/http.clj index 1d67badff1..3dfaee01bc 100644 --- a/server/src/instant/util/http.clj +++ b/server/src/instant/util/http.clj @@ -1,6 +1,7 @@ (ns instant.util.http (:require [clojure.string :as string] + [instant.flags :as flags] [instant.model.instant-user :as instant-user-model] [instant.util.exception :as ex] [instant.util.token :as token-util] @@ -28,6 +29,32 @@ (coerce-bearer-token header) nil)) +(defn req-rate-limited? + "True if any app-id the request carries (header, query/path param, or json + body) is rate limited. Checks every location so a spoofed header can't + shadow the real app-id a handler consumes." + [req] + (reduce (fn [_ path] + (if (some-> (get-in req path) + uuid-util/coerce + flags/app-rate-limited?) + (reduced true) + false)) + false + [[:headers "app-id"] + [:headers "app_id"] + [:params :app_id] + [:params :app-id] + [:query-params "app_id"] + [:body :app-id] + [:body :app_id]])) + +(defn with-rate-limiting [handler] + (fn [req] + (when (req-rate-limited? req) + (ex/throw-rate-limited!)) + (handler req))) + (defn req->auth-user "Extracts authenticated user from request. Returns nil if unauthenticated." [req]