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:
Summary
APIKeyAuthenforces the configured rate limit only after a bearer token has already matched a stored API key. Invalid bearer-token attempts return403before they are recorded in_request_log, so an attacker can make unlimited guesses without ever hitting the existing429path.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-73returns401/403for missing or invalid Authorization before the rate-limit block.server.py:75-88tracks requests by the valid API key string only after_is_valid_key()succeeds.server.py:92-96correctly avoids short-circuiting comparisons, but invalid candidates are never counted/throttled.Evidence
Baseline on
mainis healthy:No-hardware reproduction using the middleware on a dummy aiohttp route:
Actual output:
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
401/403._request_logor logs.Acceptance criteria
APIKeyAuth(api_keys=[...], rate_limit=2), repeated invalid Authorization attempts hit429after the configured threshold.429after the configured threshold.Verification gates
Duplicate check
I did not find an existing open issue/PR for invalid-token rate limiting. Searches used:
Nearby existing work that is related but distinct:
server.pystartup.