diff --git a/server.py b/server.py index 6aee04a..4d0fa98 100644 --- a/server.py +++ b/server.py @@ -92,6 +92,8 @@ async def middleware(self, request: web.Request, handler) -> web.Response: return await handler(request) def _is_valid_key(self, candidate_key: str) -> bool: + if not candidate_key.isascii(): + return False valid = False for stored_key in self._keys: valid |= secrets.compare_digest(candidate_key, stored_key) @@ -101,6 +103,8 @@ def _is_valid_key(self, candidate_key: str) -> bool: def _validated_key(key: str) -> str: if not key.strip(): raise ValueError("API key must not be blank") + if not key.isascii(): + raise ValueError("API key must contain only ASCII characters") return key def add_key(self, key: str) -> None: diff --git a/tests/test_api_auth.py b/tests/test_api_auth.py index 4bbe94f..3038663 100644 --- a/tests/test_api_auth.py +++ b/tests/test_api_auth.py @@ -79,6 +79,15 @@ async def test_malformed_header_returns_401(self, aiohttp_client, auth): resp = await client.get("/test", headers={"Authorization": "Basic abc123"}) assert resp.status == 401 + async def test_non_ascii_bearer_token_fails_closed_without_rate_log_entry(self, aiohttp_client, auth): + client = await aiohttp_client(_make_app(auth)) + + resp = await client.get("/test", headers={"Authorization": "Bearer é"}) + + assert resp.status == 403 + assert await resp.json() == {"error": "Invalid API key."} + assert "é" not in auth._request_log + def test_blank_configured_key_is_rejected(self): with pytest.raises(ValueError, match="API key must not be blank"): APIKeyAuth(api_keys=[""]) @@ -87,6 +96,10 @@ def test_whitespace_configured_key_is_rejected(self): with pytest.raises(ValueError, match="API key must not be blank"): APIKeyAuth(api_keys=[" \t"]) + def test_non_ascii_configured_key_is_rejected(self): + with pytest.raises(ValueError, match="API key must contain only ASCII characters"): + APIKeyAuth(api_keys=["é"]) + class TestAPIKeyConstantTimeComparison: def test_helper_compares_candidate_against_each_stored_key_without_self_compare(self, monkeypatch): @@ -126,6 +139,19 @@ def fake_compare_digest(left, right): ("matching-key", "other-key"), ] + def test_non_ascii_candidate_key_returns_false_without_comparing(self, monkeypatch): + auth = APIKeyAuth(api_keys=["test-key-123", "another-key"]) + calls = [] + + def fake_compare_digest(left, right): + calls.append((left, right)) + return left == right + + monkeypatch.setattr(server.secrets, "compare_digest", fake_compare_digest) + + assert auth._is_valid_key("é") is False + assert calls == [] + class TestAPIKeyManagement: def test_add_key(self): @@ -147,6 +173,12 @@ def test_add_key_rejects_whitespace_key(self): auth.add_key(" \n") assert auth.enabled is False + def test_add_key_rejects_non_ascii_key(self): + auth = APIKeyAuth() + with pytest.raises(ValueError, match="API key must contain only ASCII characters"): + auth.add_key("é") + assert auth.enabled is False + def test_revoke_key(self): auth = APIKeyAuth(api_keys=["only-key"]) assert auth.enabled is True