mirror of
https://github.com/penpot/penpot.git
synced 2026-09-25 21:36:15 +00:00
🐛 Restore rate-limit headers and add Retry-After on 429 (#11895)
* 🐛 Restore rate-limit headers and add Retry-After The account-lockout change replaced the header-forwarding 429 handler with a body-only one, so existing RPC rate-limit responses lost their x-rate-limit-remaining and x-rate-limit-reset headers. Account lockout never sent Retry-After either. Make handle-error :rate-limit preserve ::http/headers and add a retry-after header when the exception carries a non-nil :ttl in seconds, keeping the current JSON body. Add focused tests for both the lockout and the RPC limiter paths. Document activation, defaults, password/LDAP scope, Redis fail-open behavior, and the lockout risk, and record the final HTTP contract in the backend auth memory. Refs #11397 AI-assisted-by: deepseek-v4.1-flash * 🐛 Add Retry-After to RPC 429 and expose headers in CORS Address review follow-ups on the account-lockout 429 contract: - The RPC limiter now sets retry-after in its 429 headers (seconds until the longest rejecting limit resets), so it matches the account-lockout response and the HTTP standard. - CORS exposes retry-after, x-rate-limit-remaining, and x-rate-limit-reset so browser clients can read them. - Use backticks for Retry-After and account-locked in the docs for consistency with nearby sections. Refs #11397 AI-assisted-by: deepseek-v4.1-flash
This commit is contained in:
parent
fe8a305811
commit
acd146f6f4
@ -9,6 +9,7 @@
|
|||||||
- LDAP login validates credentials against the external directory, fetches identity data, then logs in or registers a matching Penpot profile. LDAP registration is not a separate Penpot signup flow.
|
- LDAP login validates credentials against the external directory, fetches identity data, then logs in or registers a matching Penpot profile. LDAP registration is not a separate Penpot signup flow.
|
||||||
- LDAP session identity MUST come from the directory-returned email (`info.email`): the profile matching the typed email can differ (aliases, UPNs, multi-valued `mail` attributes) and is used only for lockout checks, never to bind the session.
|
- LDAP session identity MUST come from the directory-returned email (`info.email`): the profile matching the typed email can differ (aliases, UPNs, multi-valued `mail` attributes) and is used only for lockout checks, never to bind the session.
|
||||||
- Account lockout (flag `:account-lockout`, `app.auth.login-lockout`) is Redis-backed and keyed per profile id. Password and LDAP flows check/increment on the profile derived from the typed email and clear on the profile that actually logs in.
|
- Account lockout (flag `:account-lockout`, `app.auth.login-lockout`) is Redis-backed and keyed per profile id. Password and LDAP flows check/increment on the profile derived from the typed email and clear on the profile that actually logs in.
|
||||||
|
- Lockout and RPC rate limits share the `app.http.errors/handle-error :rate-limit` HTTP path: status 429, body `{:type :rate-limit :code ... :hint ... :ttl ...}`, and any supplied `::http/headers` preserved. Account lockout raises `:code :account-locked` and, when it carries a non-nil `:ttl` (seconds), the handler adds `retry-after`. The RPC limiter (`app.rpc.rlimit`) raises `:code :request-blocked` and sets `retry-after` itself in `::http/headers` (seconds until the longest rejecting limit resets), alongside `x-rate-limit-remaining`/`x-rate-limit-reset`. CORS (`app.http.middleware/with-cors-headers`) exposes `content-type`, `retry-after`, and both `x-rate-limit-*` headers. External flags: `enable-account-lockout`, `enable-rpc-rlimit`.
|
||||||
- Logout may return an OIDC provider redirect URI when the session claims include provider/session data and the provider has a logout URI.
|
- Logout may return an OIDC provider redirect URI when the session claims include provider/session data and the provider has a logout URI.
|
||||||
- Invitation tokens are verified through token issuers and only accepted when the token member id/email matches the authenticated profile; otherwise login proceeds without consuming the invitation.
|
- Invitation tokens are verified through token issuers and only accepted when the token member id/email matches the authenticated profile; otherwise login proceeds without consuming the invitation.
|
||||||
- HTTP/session parsing details such as cookie/header precedence, JWT session token versions, and SameSite behavior are in `mem:backend/subtleties`.
|
- HTTP/session parsing details such as cookie/header precedence, JWT session token versions, and SameSite behavior are in `mem:backend/subtleties`.
|
||||||
|
|||||||
@ -77,12 +77,16 @@
|
|||||||
|
|
||||||
(defmethod handle-error :rate-limit
|
(defmethod handle-error :rate-limit
|
||||||
[err _ _]
|
[err _ _]
|
||||||
(let [data (ex-data err)]
|
(let [data (ex-data err)
|
||||||
{::yres/status 429
|
headers (cond-> (::http/headers data)
|
||||||
::yres/body {:type :rate-limit
|
(some? (:ttl data))
|
||||||
:code (:code data)
|
(assoc "retry-after" (str (:ttl data))))]
|
||||||
:hint (:hint data)
|
{::yres/status 429
|
||||||
:ttl (:ttl data)}}))
|
::yres/headers headers
|
||||||
|
::yres/body {:type :rate-limit
|
||||||
|
:code (:code data)
|
||||||
|
:hint (:hint data)
|
||||||
|
:ttl (:ttl data)}}))
|
||||||
|
|
||||||
(defmethod handle-error :concurrency-limit
|
(defmethod handle-error :concurrency-limit
|
||||||
[err _ _]
|
[err _ _]
|
||||||
|
|||||||
@ -237,7 +237,7 @@
|
|||||||
(-> (assoc "access-control-allow-origin" origin)
|
(-> (assoc "access-control-allow-origin" origin)
|
||||||
(assoc "access-control-allow-credentials" "true")
|
(assoc "access-control-allow-credentials" "true")
|
||||||
(assoc "access-control-allow-methods" "GET,POST,DELETE,OPTIONS,PUT,HEAD,PATCH")
|
(assoc "access-control-allow-methods" "GET,POST,DELETE,OPTIONS,PUT,HEAD,PATCH")
|
||||||
(assoc "access-control-expose-headers" "content-type")
|
(assoc "access-control-expose-headers" "content-type, retry-after, x-rate-limit-remaining, x-rate-limit-reset")
|
||||||
(assoc "access-control-allow-headers" "x-frontend-version, x-client, content-type, accept"))))
|
(assoc "access-control-allow-headers" "x-frontend-version, x-client, content-type, accept"))))
|
||||||
|
|
||||||
(defn wrap-cors
|
(defn wrap-cors
|
||||||
|
|||||||
@ -222,15 +222,22 @@
|
|||||||
|
|
||||||
(defn- process-limits
|
(defn- process-limits
|
||||||
[{:keys [::rds/conn] :as cfg} uid limits now]
|
[{:keys [::rds/conn] :as cfg} uid limits now]
|
||||||
(let [results (into [] (map (partial process-limit conn uid now)) limits)
|
(let [results (into [] (map (partial process-limit conn uid now)) limits)
|
||||||
remaining (->> results
|
remaining (->> results
|
||||||
(d/index-by ::name ::lresult/remaining)
|
(d/index-by ::name ::lresult/remaining)
|
||||||
(uri/map->query-string))
|
(uri/map->query-string))
|
||||||
reset (->> results
|
reset (->> results
|
||||||
(d/index-by ::name (comp ->seconds ::lresult/reset))
|
(d/index-by ::name (comp ->seconds ::lresult/reset))
|
||||||
(uri/map->query-string))
|
(uri/map->query-string))
|
||||||
|
|
||||||
rejected (d/seek (complement ::lresult/allowed) results)]
|
rejected (d/seek (complement ::lresult/allowed) results)
|
||||||
|
|
||||||
|
;; Seconds until the client can retry: the longest reset among the
|
||||||
|
;; limits that currently reject the request. Only emitted on 429.
|
||||||
|
retry-after (->> results
|
||||||
|
(remove ::lresult/allowed)
|
||||||
|
(map #(->seconds (ct/diff now (::lresult/reset %))))
|
||||||
|
(reduce max 0))]
|
||||||
|
|
||||||
(when rejected
|
(when rejected
|
||||||
(let [event {::id (uuid/next)
|
(let [event {::id (uuid/next)
|
||||||
@ -254,8 +261,10 @@
|
|||||||
::allowed (not (some? rejected))
|
::allowed (not (some? rejected))
|
||||||
::remaingin remaining
|
::remaingin remaining
|
||||||
::reset reset
|
::reset reset
|
||||||
::headers {"x-rate-limit-remaining" remaining
|
::headers (cond-> {"x-rate-limit-remaining" remaining
|
||||||
"x-rate-limit-reset" reset}}))
|
"x-rate-limit-reset" reset}
|
||||||
|
(some? rejected)
|
||||||
|
(assoc "retry-after" (str retry-after)))}))
|
||||||
|
|
||||||
(defn- get-limits
|
(defn- get-limits
|
||||||
[state skey sname]
|
[state skey sname]
|
||||||
|
|||||||
70
backend/test/backend_tests/http_errors_test.clj
Normal file
70
backend/test/backend_tests/http_errors_test.clj
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
;; 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 SUBSIDIARY SL
|
||||||
|
|
||||||
|
(ns backend-tests.http-errors-test
|
||||||
|
"Focused tests for the HTTP error mapping in `app.http.errors`."
|
||||||
|
(:require
|
||||||
|
[app.http :as-alias http]
|
||||||
|
[app.http.errors :as http-errors]
|
||||||
|
[clojure.test :as t]
|
||||||
|
[yetti.response :as yres]))
|
||||||
|
|
||||||
|
(t/deftest account-lockout-returns-429-with-retry-after
|
||||||
|
(let [cause (ex-info "account locked"
|
||||||
|
{:type :rate-limit
|
||||||
|
:code :account-locked
|
||||||
|
:hint "account locked due to too many failed login attempts"
|
||||||
|
:ttl 900})
|
||||||
|
response (http-errors/handle cause {})
|
||||||
|
headers (::yres/headers response)
|
||||||
|
body (::yres/body response)]
|
||||||
|
(t/is (= 429 (::yres/status response)))
|
||||||
|
(t/is (= "900" (get headers "retry-after")))
|
||||||
|
(t/is (= :rate-limit (:type body)))
|
||||||
|
(t/is (= :account-locked (:code body)))
|
||||||
|
(t/is (= 900 (:ttl body)))))
|
||||||
|
|
||||||
|
(t/deftest rpc-rate-limit-preserves-headers
|
||||||
|
(let [cause (ex-info "rate limit reached"
|
||||||
|
{:type :rate-limit
|
||||||
|
:code :request-blocked
|
||||||
|
:hint "rate limit reached"
|
||||||
|
::http/headers {"x-rate-limit-remaining" "3"
|
||||||
|
"x-rate-limit-reset" "60"}})
|
||||||
|
response (http-errors/handle cause {})
|
||||||
|
headers (::yres/headers response)
|
||||||
|
body (::yres/body response)]
|
||||||
|
(t/is (= 429 (::yres/status response)))
|
||||||
|
(t/is (= "3" (get headers "x-rate-limit-remaining")))
|
||||||
|
(t/is (= "60" (get headers "x-rate-limit-reset")))
|
||||||
|
(t/is (nil? (get headers "retry-after")))
|
||||||
|
(t/is (= :rate-limit (:type body)))
|
||||||
|
(t/is (= :request-blocked (:code body)))))
|
||||||
|
|
||||||
|
(t/deftest rate-limit-with-ttl-keeps-headers-and-adds-retry-after
|
||||||
|
(let [cause (ex-info "rate limit reached"
|
||||||
|
{:type :rate-limit
|
||||||
|
:code :request-blocked
|
||||||
|
:hint "rate limit reached"
|
||||||
|
:ttl 120
|
||||||
|
::http/headers {"x-rate-limit-remaining" "0"
|
||||||
|
"x-rate-limit-reset" "120"}})
|
||||||
|
response (http-errors/handle cause {})
|
||||||
|
headers (::yres/headers response)]
|
||||||
|
(t/is (= 429 (::yres/status response)))
|
||||||
|
(t/is (= "0" (get headers "x-rate-limit-remaining")))
|
||||||
|
(t/is (= "120" (get headers "x-rate-limit-reset")))
|
||||||
|
(t/is (= "120" (get headers "retry-after")))))
|
||||||
|
|
||||||
|
(t/deftest rate-limit-without-ttl-has-no-retry-after
|
||||||
|
(let [cause (ex-info "rate limit reached"
|
||||||
|
{:type :rate-limit
|
||||||
|
:code :request-blocked
|
||||||
|
:hint "rate limit reached"})
|
||||||
|
response (http-errors/handle cause {})
|
||||||
|
headers (::yres/headers response)]
|
||||||
|
(t/is (= 429 (::yres/status response)))
|
||||||
|
(t/is (nil? (get headers "retry-after")))))
|
||||||
@ -139,7 +139,8 @@
|
|||||||
(t/is (= "https://trusted.example" (get headers "access-control-allow-origin")))
|
(t/is (= "https://trusted.example" (get headers "access-control-allow-origin")))
|
||||||
(t/is (= "true" (get headers "access-control-allow-credentials")))
|
(t/is (= "true" (get headers "access-control-allow-credentials")))
|
||||||
(t/is (= "Origin" (get headers "vary")))
|
(t/is (= "Origin" (get headers "vary")))
|
||||||
(t/is (= "content-type" (get headers "access-control-expose-headers")))
|
(t/is (= "content-type, retry-after, x-rate-limit-remaining, x-rate-limit-reset"
|
||||||
|
(get headers "access-control-expose-headers")))
|
||||||
(t/is (not (str/includes?
|
(t/is (not (str/includes?
|
||||||
(get headers "access-control-allow-headers" "")
|
(get headers "access-control-allow-headers" "")
|
||||||
"cookie")))))
|
"cookie")))))
|
||||||
|
|||||||
@ -7,6 +7,8 @@
|
|||||||
(ns backend-tests.rpc-rlimit-test
|
(ns backend-tests.rpc-rlimit-test
|
||||||
(:require
|
(:require
|
||||||
[app.common.time :as ct]
|
[app.common.time :as ct]
|
||||||
|
[app.loggers.database]
|
||||||
|
[app.loggers.mattermost]
|
||||||
[app.redis :as rds]
|
[app.redis :as rds]
|
||||||
[app.rpc.rlimit :as rlimit]
|
[app.rpc.rlimit :as rlimit]
|
||||||
[clojure.test :as t]))
|
[clojure.test :as t]))
|
||||||
@ -26,3 +28,39 @@
|
|||||||
(let [result (rlimit/process-limit nil "profile" now limit)]
|
(let [result (rlimit/process-limit nil "profile" now limit)]
|
||||||
(t/is (= (ct/inst 334)
|
(t/is (= (ct/inst 334)
|
||||||
(:app.rpc.rlimit.result/reset result)))))))
|
(:app.rpc.rlimit.result/reset result)))))))
|
||||||
|
|
||||||
|
(defn- test-limit
|
||||||
|
[]
|
||||||
|
{::rlimit/name :test
|
||||||
|
::rlimit/strategy :bucket
|
||||||
|
::rlimit/key "test"
|
||||||
|
::rlimit/method "main.test"
|
||||||
|
::rlimit/capacity 5
|
||||||
|
::rlimit/rate 3
|
||||||
|
::rlimit/interval (ct/duration 1000)
|
||||||
|
::rlimit/params [1 3 5]
|
||||||
|
::rlimit/opts "5/3/1s"})
|
||||||
|
|
||||||
|
(t/deftest rejected-rate-limit-emits-retry-after-header
|
||||||
|
;; When a limit rejects the request the 429 headers must carry a
|
||||||
|
;; Retry-After with the seconds until the client can retry.
|
||||||
|
(with-redefs [rds/eval (fn [_ _] [false 2])
|
||||||
|
app.loggers.mattermost/emit (fn [_ _] nil)
|
||||||
|
app.loggers.database/emit (fn [_ _] nil)]
|
||||||
|
(let [result (#'rlimit/process-limits {::rds/conn nil} "profile" [(test-limit)] (ct/inst 0))
|
||||||
|
headers (::rlimit/headers result)]
|
||||||
|
(t/is (false? (::rlimit/allowed result)))
|
||||||
|
(t/is (= "1" (get headers "retry-after")))
|
||||||
|
(t/is (some? (get headers "x-rate-limit-remaining")))
|
||||||
|
(t/is (some? (get headers "x-rate-limit-reset"))))))
|
||||||
|
|
||||||
|
(t/deftest allowed-rate-limit-omits-retry-after-header
|
||||||
|
;; Retry-After is only meaningful on a rejected 429; allowed responses
|
||||||
|
;; must not carry it.
|
||||||
|
(with-redefs [rds/eval (fn [_ _] [true 4])
|
||||||
|
app.loggers.mattermost/emit (fn [_ _] nil)
|
||||||
|
app.loggers.database/emit (fn [_ _] nil)]
|
||||||
|
(let [result (#'rlimit/process-limits {::rds/conn nil} "profile" [(test-limit)] (ct/inst 0))
|
||||||
|
headers (::rlimit/headers result)]
|
||||||
|
(t/is (true? (::rlimit/allowed result)))
|
||||||
|
(t/is (nil? (get headers "retry-after"))))))
|
||||||
|
|||||||
@ -310,6 +310,37 @@ PENPOT_LDAP_ATTRS_FULLNAME: cn
|
|||||||
PENPOT_LDAP_ATTRS_PHOTO: jpegPhoto
|
PENPOT_LDAP_ATTRS_PHOTO: jpegPhoto
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Account lockout
|
||||||
|
|
||||||
|
__Since version 2.19.0__
|
||||||
|
|
||||||
|
Account lockout is disabled by default. Backend administrators can enable it by
|
||||||
|
adding the <code class="language-bash">enable-account-lockout</code> flag:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PENPOT_FLAGS: [...] enable-account-lockout
|
||||||
|
```
|
||||||
|
|
||||||
|
When enabled, Penpot locks an existing account after 5 failed password or LDAP
|
||||||
|
login attempts within 15 minutes. It only applies to the password and LDAP
|
||||||
|
logins; OIDC and the other authentication providers are not affected. The
|
||||||
|
defaults can be changed with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backend
|
||||||
|
PENPOT_LOGIN_LOCKOUT_MAX_ATTEMPTS: 5
|
||||||
|
PENPOT_LOGIN_LOCKOUT_WINDOW: 15m
|
||||||
|
```
|
||||||
|
|
||||||
|
While the account is locked, login returns HTTP 429 with a `Retry-After`
|
||||||
|
header and a JSON error with the code `account-locked` and the remaining
|
||||||
|
seconds in `ttl`.
|
||||||
|
|
||||||
|
Redis must be available. If Redis fails, login continues without lockout
|
||||||
|
checks. This feature prevents repeated password guessing, but anyone who knows
|
||||||
|
an email address can lock that account by failing the configured number of
|
||||||
|
attempts.
|
||||||
|
|
||||||
## Penpot URI
|
## Penpot URI
|
||||||
|
|
||||||
You will need to set the <code class="language-bash">PENPOT_PUBLIC_URI</code> environment variable in case you go to serve Penpot to the users;
|
You will need to set the <code class="language-bash">PENPOT_PUBLIC_URI</code> environment variable in case you go to serve Penpot to the users;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user