Dev.to Security 🔐 Cybersecurity 👁 0 📖 3 min read

API Pentesting: Rate-Limit Testing and Safe Enumeration the Defensible Way

Day 25: Rate Limiting and Safe Enumeration on an Authorized API Test This is day 25 of my 60-day pentesting challenge. The engagement was a scoped, authorized assessment of an orders and users REST API, with signed rul

Day 25: Rate Limiting and Safe Enumeration on an Authorized API Test

This is day 25 of my 60-day pentesting challenge. The engagement was a scoped, authorized assessment of an orders and users REST API, with signed rules of engagement and two provisioned accounts. The OWASP API Security Top 10 was the checklist. In this post I want to focus on two of the checks that people automate badly: rate limiting on authentication (API4), and safe read-only enumeration -- the part that has to stay defensible and never look like an attack tool.

Detecting a missing rate limit without hammering anything

You do not need a credential-stuffing script to prove a missing rate limit. You need a handful of deliberately-wrong requests and a look at the status codes. On this API I sent ten wrong logins to the auth endpoint and every one returned 401 with no 429, no backoff, no lockout. A documented, read-only check is enough to write the finding.

# Authorized check: confirm the login endpoint throttles repeated failures.
# Ten wrong passwords for a test account, reading only the status code.
for n in 1 2 3 4 5 6 7 8 9 10; do
  curl -s -o /dev/null -w "attempt $n -> %{http_code}\n" \
    -X POST https://api.staging.example/api/v1/login \
    -H 'Content-Type: application/json' \
    -d '{"email":"[email protected]","password":"wrong-on-purpose"}'
done
# Expected after the fix: the first few return 401, then 429 (Too Many Requests).

The vulnerable login handler

Here is the kind of unprotected authentication route that produces that result. It validates credentials and returns a token, with nothing in front of it counting failures:

@app.post("/api/v1/login")
def login():
    body = request.get_json() or {}
    user = User.query.filter_by(email=body.get("email")).first()
    if user and user.check_password(body.get("password")):
        return jsonify({"token": issue_token(user)})
    # BUG: unlimited attempts, no per-account or per-IP throttle
    return jsonify({"error": "invalid credentials"}), 401

The fix: a deny-by-default limiter on auth routes

Put a limiter in front, keyed on both the client IP and the submitted account, so one attacker cannot rotate either dimension to escape it. After a small threshold the route returns 429 and stops doing work:

from time import time

WINDOW_SECONDS = 300
MAX_FAILURES = 5
_failures = {}   # (ip, email) -> [timestamps]

def too_many_failures(ip, email):
    key = (ip, email)
    now = time()
    recent = [t for t in _failures.get(key, []) if now - t < WINDOW_SECONDS]
    _failures[key] = recent
    return len(recent) >= MAX_FAILURES

@app.post("/api/v1/login")
def login():
    body = request.get_json() or {}
    email = body.get("email", "")
    ip = request.remote_addr
    if too_many_failures(ip, email):
        return jsonify({"error": "too many attempts"}), 429
    user = User.query.filter_by(email=email).first()
    if user and user.check_password(body.get("password")):
        return jsonify({"token": issue_token(user)})
    _failures.setdefault((ip, email), []).append(time())
    return jsonify({"error": "invalid credentials"}), 401

Keeping enumeration read-only and in scope

The other check on this engagement was endpoint discovery, and the rule I hold to is that recon stays read-only. I read what the service volunteers -- its documented OpenAPI spec -- rather than fuzzing blindly. A documented GET against the spec is reading, not attacking:

# Read the published OpenAPI document and list the routes it declares.
curl -s https://api.staging.example/api/v1/openapi.json \
  | python3 -c "import sys, json; d = json.load(sys.stdin); [print(p) for p in sorted(d.get('paths', {}))]"

That single request hands you the surface to test -- every declared path -- without sending a single payload. From there the BOLA and excessive-data-exposure checks are just careful reading of each endpoint's response.

The throughline for the whole assessment: authentication endpoints need rate limits, recon stays read-only and within the signed scope, and the proof of a finding is a documented request and its response. No exploit, no shell, no scanner running wild -- just observation a developer can replay in staging.

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.