Skip to content

Rate-limit invalid API key attempts #120

Description

@Justinabox

Summary

APIKeyAuth enforces the configured rate limit only after a bearer token has already matched a stored API key. Invalid bearer-token attempts return 403 before they are recorded in _request_log, so an attacker can make unlimited guesses without ever hitting the existing 429 path.

This is distinct from the existing constant-time comparison fix: the compare itself is fail-closed, but the request admission path does not throttle failed guesses. For a Pi modem HTTP server that can send SMS/USSD, authenticated deployments should rate-limit both valid-key traffic and repeated invalid credentials.

Affected code

  • server.py:64-73 returns 401/403 for missing or invalid Authorization before the rate-limit block.
  • server.py:75-88 tracks requests by the valid API key string only after _is_valid_key() succeeds.
  • server.py:92-96 correctly avoids short-circuiting comparisons, but invalid candidates are never counted/throttled.

Evidence

Baseline on main is healthy:

$ git diff --check
# exit 0

$ PYTHONPATH=. uv run --no-project --with pytest --with pytest-asyncio --with pytest-aiohttp --with pyserial-asyncio --with aiosqlite pytest tests/ -q
560 passed in 6.26s

No-hardware reproduction using the middleware on a dummy aiohttp route:

PYTHONPATH=. uv run --no-project --with aiohttp --with pyserial-asyncio python - <<'PY'
import asyncio
from aiohttp import web
from aiohttp.test_utils import TestClient, TestServer
from server import APIKeyAuth

async def main():
    auth = APIKeyAuth(api_keys=['secret'], rate_limit=2, rate_window=60)
    app = web.Application(middlewares=[auth.middleware])
    app.router.add_get('/private', lambda request: web.json_response({'ok': True}))
    server = TestServer(app)
    client = TestClient(server)
    await client.start_server()
    try:
        invalid_statuses = []
        for i in range(4):
            resp = await client.get('/private', headers={'Authorization': f'Bearer wrong-{i}'})
            invalid_statuses.append(resp.status)
            await resp.text()
        valid_statuses = []
        for i in range(3):
            resp = await client.get('/private', headers={'Authorization': 'Bearer secret'})
            valid_statuses.append(resp.status)
            await resp.text()
        print('invalid_statuses:', invalid_statuses)
        print('valid_statuses:', valid_statuses)
        print('tracked_keys:', sorted(auth._request_log.keys()))
        print('invalid_keys_tracked:', [k for k in auth._request_log if k.startswith('wrong-')])
        print('valid_key_count:', len(auth._request_log['secret']))
    finally:
        await client.close()

asyncio.run(main())
PY

Actual output:

invalid_statuses: [403, 403, 403, 403]
valid_statuses: [200, 200, 429]
tracked_keys: ['secret']
invalid_keys_tracked: []
valid_key_count: 2

The valid token is rate-limited after two requests, but four invalid guesses are all unthrottled and not tracked.

Expected behavior

When auth is enabled, failed bearer-token attempts should be bounded by a rate limit too. A deployment should be able to withstand repeated invalid-key guesses without letting attackers bypass the only built-in request throttle.

Suggested fix direction

  • Add a pre-auth rate-limit bucket keyed by a privacy-safe client identifier (for example remote peer/IP when available, plus a generic bucket when unavailable in tests), and apply it before returning 401/403.
  • Do not store raw candidate bearer tokens in _request_log or logs.
  • Keep the existing per-valid-key rate limit so successful API clients remain independently bounded.
  • Consider using generic auth failure responses if that helps avoid credential probing signals, but the key requirement is throttling invalid attempts.

Acceptance criteria

  • With APIKeyAuth(api_keys=[...], rate_limit=2), repeated invalid Authorization attempts hit 429 after the configured threshold.
  • Missing/invalid Authorization attempts are counted without storing raw bearer-token values.
  • Valid-key requests still hit 429 after the configured threshold.
  • Existing constant-time comparison tests remain green.

Verification gates

git diff --check
PYTHONPATH=. uv run --no-project --with pytest --with pytest-asyncio --with pytest-aiohttp --with pyserial-asyncio --with aiosqlite pytest tests/test_api_auth.py -q
PYTHONPATH=. uv run --no-project --with pytest --with pytest-asyncio --with pytest-aiohttp --with pyserial-asyncio --with aiosqlite pytest tests/ -q

Duplicate check

I did not find an existing open issue/PR for invalid-token rate limiting. Searches used:

gh issue list --repo Justinabox/Callstack --state all --search 'API key brute force invalid keys rate limit in:title,body'
gh issue list --repo Justinabox/Callstack --state all --search 'rate limit invalid Authorization APIKeyAuth in:title,body'
gh issue list --repo Justinabox/Callstack --state all --search 'invalid bearer token rate limit brute force in:title,body'
gh pr list --repo Justinabox/Callstack --state all --search 'APIKeyAuth invalid key rate limit'

Nearby existing work that is related but distinct:

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions