This is an automated email from the ASF dual-hosted git repository.

arm pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git


The following commit(s) were added to refs/heads/main by this push:
     new d995df0  #535 - Add specific rate limits to security-focused 
endpoints. Make sure user ID is logged in more cases (including 429s)
d995df0 is described below

commit d995df02dfc1c5816e75d15bdce302249f994149
Author: Alastair McFarlane <[email protected]>
AuthorDate: Fri Jan 23 12:15:36 2026 +0000

    #535 - Add specific rate limits to security-focused endpoints. Make sure 
user ID is logged in more cases (including 429s)
---
 atr/api/__init__.py | 18 ++++++++++++++++--
 atr/server.py       | 35 ++++++++++++++++++++++++++++-------
 2 files changed, 44 insertions(+), 9 deletions(-)

diff --git a/atr/api/__init__.py b/atr/api/__init__.py
index e253a14..79260cf 100644
--- a/atr/api/__init__.py
+++ b/atr/api/__init__.py
@@ -14,8 +14,7 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
-
-
+import datetime
 import hashlib
 import pathlib
 from typing import Any, Final, Literal
@@ -24,6 +23,7 @@ import aiofiles.os
 import asfquart.base as base
 import pgpy
 import quart
+import quart_rate_limiter as rate_limiter
 import quart_schema
 import sqlalchemy
 import sqlmodel
@@ -34,6 +34,7 @@ import atr.config as config
 import atr.db as db
 import atr.db.interaction as interaction
 import atr.jwtoken as jwtoken
+import atr.log as log
 import atr.models as models
 import atr.models.sql as sql
 import atr.principal as principal
@@ -450,6 +451,7 @@ async def ignore_list(committee_name: str) -> DictResponse:
 
 
 @api.route("/jwt/create", methods=["POST"])
+@rate_limiter.rate_limit(10, datetime.timedelta(hours=1))
 @quart_schema.validate_request(models.api.JwtCreateArgs)
 async def jwt_create(data: models.api.JwtCreateArgs) -> DictResponse:
     """
@@ -464,6 +466,8 @@ async def jwt_create(data: models.api.JwtCreateArgs) -> 
DictResponse:
         wafc = write.as_foundation_committer()
         jwt = await wafc.tokens.issue_jwt(data.pat)
 
+    log.add_context(user_id=asf_uid)
+
     return models.api.JwtCreateResults(
         endpoint="/jwt/create",
         asfuid=data.asfuid,
@@ -472,6 +476,7 @@ async def jwt_create(data: models.api.JwtCreateArgs) -> 
DictResponse:
 
 
 @api.route("/key/add", methods=["POST"])
+@rate_limiter.rate_limit(10, datetime.timedelta(hours=1))
 @jwtoken.require
 @quart_schema.security_scheme([{"BearerAuth": []}])
 @quart_schema.validate_request(models.api.KeyAddArgs)
@@ -538,6 +543,7 @@ async def key_delete(data: models.api.KeyDeleteArgs) -> 
DictResponse:
 
 
 @api.route("/key/get/<fingerprint>")
+@rate_limiter.rate_limit(10, datetime.timedelta(hours=1))
 @quart_schema.validate_response(models.api.KeyGetResults, 200)
 async def key_get(fingerprint: str) -> DictResponse:
     """
@@ -557,6 +563,7 @@ async def key_get(fingerprint: str) -> DictResponse:
 
 
 @api.route("/keys/upload", methods=["POST"])
+@rate_limiter.rate_limit(10, datetime.timedelta(hours=1))
 @jwtoken.require
 @quart_schema.security_scheme([{"BearerAuth": []}])
 @quart_schema.validate_request(models.api.KeysUploadArgs)
@@ -614,6 +621,7 @@ async def keys_upload(data: models.api.KeysUploadArgs) -> 
DictResponse:
 
 
 @api.route("/keys/user/<asf_uid>")
+@rate_limiter.rate_limit(10, datetime.timedelta(hours=1))
 @quart_schema.validate_response(models.api.KeysUserResults, 200)
 async def keys_user(asf_uid: str) -> DictResponse:
     """
@@ -793,6 +801,7 @@ async def publisher_release_announce(data: 
models.api.PublisherReleaseAnnounceAr
 
 
 @api.route("/publisher/ssh/register", methods=["POST"])
+@rate_limiter.rate_limit(10, datetime.timedelta(hours=1))
 @quart_schema.validate_request(models.api.PublisherSshRegisterArgs)
 async def publisher_ssh_register(data: models.api.PublisherSshRegisterArgs) -> 
DictResponse:
     """
@@ -1126,6 +1135,7 @@ async def signature_provenance(data: 
models.api.SignatureProvenanceArgs) -> Dict
 
 
 @api.route("/ssh-key/add", methods=["POST"])
+@rate_limiter.rate_limit(10, datetime.timedelta(hours=1))
 @jwtoken.require
 @quart_schema.security_scheme([{"BearerAuth": []}])
 @quart_schema.validate_request(models.api.SshKeyAddArgs)
@@ -1147,6 +1157,7 @@ async def ssh_key_add(data: models.api.SshKeyAddArgs) -> 
DictResponse:
 
 
 @api.route("/ssh-key/delete", methods=["POST"])
+@rate_limiter.rate_limit(10, datetime.timedelta(hours=1))
 @jwtoken.require
 @quart_schema.security_scheme([{"BearerAuth": []}])
 @quart_schema.validate_request(models.api.SshKeyDeleteArgs)
@@ -1168,6 +1179,7 @@ async def ssh_key_delete(data: 
models.api.SshKeyDeleteArgs) -> DictResponse:
 
 
 @api.route("/ssh-keys/list/<asf_uid>")
+@rate_limiter.rate_limit(10, datetime.timedelta(hours=1))
 @quart_schema.validate_querystring(models.api.SshKeysListQuery)
 async def ssh_keys_list(asf_uid: str, query_args: models.api.SshKeysListQuery) 
-> DictResponse:
     """
@@ -1224,6 +1236,7 @@ async def tasks_list(query_args: 
models.api.TasksListQuery) -> DictResponse:
 
 
 @api.route("/user/info")
+@rate_limiter.rate_limit(10, datetime.timedelta(hours=1))
 @jwtoken.require
 @quart_schema.security_scheme([{"BearerAuth": []}])
 @quart_schema.validate_response(models.api.UserInfoResults, 200)
@@ -1243,6 +1256,7 @@ async def user_info() -> DictResponse:
 
 
 @api.route("/users/list")
+@rate_limiter.rate_limit(10, datetime.timedelta(hours=1))
 @quart_schema.validate_response(models.api.UsersListResults, 200)
 async def users_list() -> DictResponse:
     """
diff --git a/atr/server.py b/atr/server.py
index 85529b1..d954264 100644
--- a/atr/server.py
+++ b/atr/server.py
@@ -412,13 +412,7 @@ def _app_setup_request_lifecycle(app: base.QuartApp) -> 
None:
 
     @app.before_request
     async def bind_request_context_vars() -> None:
-        log.clear_context()
-        log.add_context(request_id=str(uuid.uuid4()))
-
-        # Bind user_id if authenticated
-        session = await asfquart.session.read()
-        if session is not None:
-            log.add_context(user_id=session.uid)
+        await _reset_request_log_context()
 
     @app.after_request
     async def log_request(response: quart.Response) -> quart.Response:
@@ -817,6 +811,33 @@ def _register_routes(app: base.QuartApp) -> None:  # noqa: 
C901
             return quart.jsonify({"error": "404 Not Found"}), 404
         return await template.render("notfound.html", error="404 Not Found", 
traceback="", status_code=404), 404
 
+    @app.errorhandler(429)
+    async def handle_rate_limit(e):
+        # Set up logging context since before_request doesn't run for 
rate-limited requests
+        await _reset_request_log_context()
+
+        if quart.request.path.startswith("/api"):
+            return quart.jsonify(
+                {
+                    "error": "rate_limit_exceeded",
+                    "detail": "Too many requests, please retry later.",
+                    "retry_after": getattr(e, "retry_after", None),
+                }
+            ), 429
+        return await template.render("error.html", error="429 Too Many 
Requests", traceback="", status_code=429), 429
+
+
+async def _reset_request_log_context():
+    log.clear_context()
+    log.add_context(request_id=str(uuid.uuid4()))
+    session = await asfquart.session.read()
+    if session is not None:
+        log.add_context(user_id=session.uid)
+    elif hasattr(quart.g, "jwt_claims"):
+        claims = getattr(quart.g, "jwt_claims", {})
+        asf_uid = claims.get("sub")
+        log.add_context(user_id=asf_uid)
+
 
 def _set_file_permissions_to_read_only() -> None:
     """Set permissions of all files in the unfinished and finished directories 
to read only."""


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to