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]