From d4ebd76ae255ae0385cca29a1ba5ffcf8c6c7d79 Mon Sep 17 00:00:00 2001 From: Daniel Woelfel Date: Mon, 17 Aug 2026 14:17:22 -0700 Subject: [PATCH 1/3] rate-limit all of the routes for a rate-limited app --- server/src/instant/reactive/session.clj | 8 +++++++ server/src/instant/runtime/routes.clj | 29 ++++++++++++++++--------- server/src/instant/storage/routes.clj | 10 ++++----- server/src/instant/util/http.clj | 23 ++++++++++++++++++++ 4 files changed, 55 insertions(+), 15 deletions(-) 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..8b99f9388f 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 `handle-init!`, 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..1b7bd3d886 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-id/consume-upload-url" [] consume-upload-url-put) - (GET "/storage/signed-download-url" [] signed-download-url-get)) + (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" [] (http-util/with-rate-limiting consume-upload-url-put)) + (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..1bee312eed 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,28 @@ (coerce-bearer-token header) nil)) +(defn req->rate-limit-app-id + "Best-effort, non-throwing lookup of the app-id from the places routes carry + it (header, query/path param, or json body). Returns nil when we can't find + one, so handlers that derive the app-id later still pass through." + [req] + (some (fn [path] + (some-> (get-in req path) uuid-util/coerce)) + [[: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-let [app-id (req->rate-limit-app-id req)] + (when (flags/app-rate-limited? app-id) + (ex/throw-rate-limited!))) + (handler req))) + (defn req->auth-user "Extracts authenticated user from request. Returns nil if unauthenticated." [req] From 7712cf465a3ba586b8af83aca2d204f3f4586408 Mon Sep 17 00:00:00 2001 From: Daniel Woelfel Date: Mon, 17 Aug 2026 14:31:37 -0700 Subject: [PATCH 2/3] check all provided app ids --- server/src/instant/storage/routes.clj | 2 +- server/src/instant/util/http.clj | 36 +++++++++++++++------------ 2 files changed, 21 insertions(+), 17 deletions(-) diff --git a/server/src/instant/storage/routes.clj b/server/src/instant/storage/routes.clj index 1b7bd3d886..d6bb684129 100644 --- a/server/src/instant/storage/routes.clj +++ b/server/src/instant/storage/routes.clj @@ -72,5 +72,5 @@ (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" [] (http-util/with-rate-limiting consume-upload-url-put)) + (PUT "/storage/:upload-id/consume-upload-url" [] consume-upload-url-put) (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 1bee312eed..3dfaee01bc 100644 --- a/server/src/instant/util/http.clj +++ b/server/src/instant/util/http.clj @@ -29,26 +29,30 @@ (coerce-bearer-token header) nil)) -(defn req->rate-limit-app-id - "Best-effort, non-throwing lookup of the app-id from the places routes carry - it (header, query/path param, or json body). Returns nil when we can't find - one, so handlers that derive the app-id later still pass through." +(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] - (some (fn [path] - (some-> (get-in req path) uuid-util/coerce)) - [[:headers "app-id"] - [:headers "app_id"] - [:params :app_id] - [:params :app-id] - [:query-params "app_id"] - [:body :app-id] - [:body :app_id]])) + (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-let [app-id (req->rate-limit-app-id req)] - (when (flags/app-rate-limited? app-id) - (ex/throw-rate-limited!))) + (when (req-rate-limited? req) + (ex/throw-rate-limited!)) (handler req))) (defn req->auth-user From 7ef39ce6540025245eb1337120baf3a267b8e283 Mon Sep 17 00:00:00 2001 From: Daniel Woelfel Date: Mon, 17 Aug 2026 14:49:53 -0700 Subject: [PATCH 3/3] fix outdated doc string --- server/src/instant/runtime/routes.clj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/instant/runtime/routes.clj b/server/src/instant/runtime/routes.clj index 8b99f9388f..6b2d36f854 100644 --- a/server/src/instant/runtime/routes.clj +++ b/server/src/instant/runtime/routes.clj @@ -768,7 +768,7 @@ (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 `handle-init!`, so we leave off route wrapper + ;; 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)