ForcePilot/backend/test/bruteforce_simulation.py

123 lines
3.9 KiB
Python
Raw Normal View History

"""
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")
parser.add_argument("--attempts", type=int, default=20, help="Total number of attempts to issue (default: 20)")
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
detail = (
response.json().get("detail")
if response.headers.get("content-type", "").startswith("application/json")
else response.text
)
print(
f"[{attempt_no:02d}] {response.status_code} in {elapsed * 1000:.1f} ms (pwd={password!r}) detail={detail!r}"
)
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(
asyncio.create_task(attempt_login(client, semaphore, attempt_no, args.username, args.password))
)
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()