2025-10-13 15:26:36 +08:00
|
|
|
"""
|
|
|
|
|
Quick script to hammer the login endpoint with invalid credentials and observe rate limiting.
|
|
|
|
|
|
|
|
|
|
Usage:
|
|
|
|
|
uv run python test/bruteforce_simulation.py --username demo --attempts 20
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import argparse
|
|
|
|
|
import asyncio
|
|
|
|
|
import os
|
|
|
|
|
import random
|
|
|
|
|
import string
|
|
|
|
|
import sys
|
|
|
|
|
import time
|
|
|
|
|
from collections import Counter
|
|
|
|
|
|
|
|
|
|
import httpx
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
|
|
|
parser = argparse.ArgumentParser(description="Simulate brute-force login attempts.")
|
|
|
|
|
parser.add_argument("--base-url", default=os.getenv("TEST_BASE_URL", "http://localhost:5050"), help="API base URL")
|
|
|
|
|
parser.add_argument("--username", default=os.getenv("TEST_USERNAME", "admin"), help="Login identifier to attack")
|
2025-10-14 02:27:15 +08:00
|
|
|
parser.add_argument("--attempts", type=int, default=20, help="Total number of attempts to issue (default: 20)")
|
2025-10-13 15:26:36 +08:00
|
|
|
parser.add_argument(
|
|
|
|
|
"--concurrency",
|
|
|
|
|
type=int,
|
|
|
|
|
default=4,
|
|
|
|
|
help="Concurrent request limit (default: 4)",
|
|
|
|
|
)
|
|
|
|
|
parser.add_argument(
|
|
|
|
|
"--delay",
|
|
|
|
|
type=float,
|
|
|
|
|
default=0.05,
|
|
|
|
|
help="Delay in seconds between scheduling attempts (default: 0.05)",
|
|
|
|
|
)
|
|
|
|
|
parser.add_argument(
|
|
|
|
|
"--password",
|
|
|
|
|
default=None,
|
|
|
|
|
help="Explicit password to reuse for each request; random values are used when omitted",
|
|
|
|
|
)
|
|
|
|
|
return parser.parse_args()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def build_payload(username: str, password: str) -> dict[str, str]:
|
|
|
|
|
return {"username": username, "password": password}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def random_password() -> str:
|
|
|
|
|
base = string.ascii_letters + string.digits + "!@#$%^&*"
|
|
|
|
|
return "".join(random.choices(base, k=12))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def attempt_login(
|
|
|
|
|
client: httpx.AsyncClient,
|
|
|
|
|
semaphore: asyncio.Semaphore,
|
|
|
|
|
attempt_no: int,
|
|
|
|
|
username: str,
|
|
|
|
|
static_password: str | None,
|
|
|
|
|
) -> tuple[int, float]:
|
|
|
|
|
async with semaphore:
|
|
|
|
|
password = static_password or random_password()
|
|
|
|
|
payload = build_payload(username, password)
|
|
|
|
|
started = time.perf_counter()
|
|
|
|
|
response = await client.post("/api/auth/token", data=payload)
|
|
|
|
|
elapsed = time.perf_counter() - started
|
2025-10-14 02:27:15 +08:00
|
|
|
detail = (
|
|
|
|
|
response.json().get("detail")
|
|
|
|
|
if response.headers.get("content-type", "").startswith("application/json")
|
|
|
|
|
else response.text
|
|
|
|
|
)
|
2025-10-13 15:26:36 +08:00
|
|
|
print(
|
2025-10-14 02:27:15 +08:00
|
|
|
f"[{attempt_no:02d}] {response.status_code} in {elapsed * 1000:.1f} ms (pwd={password!r}) detail={detail!r}"
|
2025-10-13 15:26:36 +08:00
|
|
|
)
|
|
|
|
|
return response.status_code, elapsed
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def run_simulation(args: argparse.Namespace) -> int:
|
|
|
|
|
timeout = httpx.Timeout(10.0, connect=3.0)
|
|
|
|
|
limits = httpx.Limits(max_connections=args.concurrency, max_keepalive_connections=args.concurrency)
|
|
|
|
|
semaphore = asyncio.Semaphore(args.concurrency)
|
|
|
|
|
status_counts: Counter[int] = Counter()
|
|
|
|
|
|
|
|
|
|
async with httpx.AsyncClient(base_url=args.base_url.rstrip("/"), timeout=timeout, limits=limits) as client:
|
|
|
|
|
tasks = []
|
|
|
|
|
for attempt_no in range(1, args.attempts + 1):
|
|
|
|
|
tasks.append(
|
2025-10-14 02:27:15 +08:00
|
|
|
asyncio.create_task(attempt_login(client, semaphore, attempt_no, args.username, args.password))
|
2025-10-13 15:26:36 +08:00
|
|
|
)
|
|
|
|
|
if args.delay:
|
|
|
|
|
await asyncio.sleep(args.delay)
|
|
|
|
|
|
|
|
|
|
for task in asyncio.as_completed(tasks):
|
|
|
|
|
status_code, _ = await task
|
|
|
|
|
status_counts[status_code] += 1
|
|
|
|
|
|
|
|
|
|
print("\nSummary:")
|
|
|
|
|
for code, total in sorted(status_counts.items()):
|
|
|
|
|
print(f" HTTP {code}: {total} hits")
|
|
|
|
|
|
|
|
|
|
if 429 in status_counts:
|
|
|
|
|
print("Rate limiting engaged (HTTP 429 encountered).")
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
print("No rate limiting observed; consider increasing attempt count or reducing delay.")
|
|
|
|
|
return 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def main() -> None:
|
|
|
|
|
args = parse_args()
|
|
|
|
|
try:
|
|
|
|
|
exit_code = asyncio.run(run_simulation(args))
|
|
|
|
|
except KeyboardInterrupt:
|
|
|
|
|
print("\nInterrupted.")
|
|
|
|
|
exit_code = 130
|
|
|
|
|
sys.exit(exit_code)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
main()
|