diff --git a/dashboard/auth_server.py b/dashboard/auth_server.py index 825ab93..0546d58 100644 --- a/dashboard/auth_server.py +++ b/dashboard/auth_server.py @@ -104,6 +104,8 @@ async def login(request: Request) -> Response: keep_blank_values=True, strict_parsing=True, max_num_fields=3, + encoding="utf-8", + errors="strict", ) except (UnicodeDecodeError, ValueError): authentication.log_auth_event("login_request_rejected", correlation_id) diff --git a/dashboard/authentication.py b/dashboard/authentication.py index fe09bcd..006d3c9 100644 --- a/dashboard/authentication.py +++ b/dashboard/authentication.py @@ -113,8 +113,12 @@ def verify_credentials( ) -> bool: if not isinstance(submitted_id, str) or not isinstance(submitted_password, str): return False - id_matches = hmac.compare_digest(submitted_id, credentials.admin_id) - password_matches = hmac.compare_digest(submitted_password, credentials.password) + id_matches = hmac.compare_digest( + submitted_id.encode("utf-8"), credentials.admin_id.encode("utf-8") + ) + password_matches = hmac.compare_digest( + submitted_password.encode("utf-8"), credentials.password.encode("utf-8") + ) return id_matches and password_matches diff --git a/tests/test_dashboard_auth_server.py b/tests/test_dashboard_auth_server.py index c68ebec..0508f54 100644 --- a/tests/test_dashboard_auth_server.py +++ b/tests/test_dashboard_auth_server.py @@ -87,6 +87,50 @@ def test_remember_me_sets_30_day_cookie(client: TestClient) -> None: ) +def test_unicode_credentials_can_login_through_adapter(client: TestClient, monkeypatch) -> None: + unicode_id = "管理员" + unicode_password = "päss🔐" + monkeypatch.setenv(authentication.ADMIN_ID_ENV, unicode_id) + monkeypatch.setenv(authentication.ADMIN_PASSWORD_ENV, unicode_password) + + response = client.post( + "/api/auth/login", + data={"admin_id": unicode_id, "admin_password": unicode_password}, + headers={"origin": "https://testserver"}, + follow_redirects=False, + ) + + assert response.status_code == 303 + assert response.headers["location"] == "/" + assert f"{authentication.AUTH_COOKIE_NAME}=" in response.headers["set-cookie"] + + +def test_unicode_credential_mismatch_is_generic_and_delayed( + client: TestClient, monkeypatch +) -> None: + import time as real_time + + unicode_id = "管理员" + unicode_password = "päss🔐" + monkeypatch.setenv(authentication.ADMIN_ID_ENV, unicode_id) + monkeypatch.setenv(authentication.ADMIN_PASSWORD_ENV, unicode_password) + monkeypatch.setattr(authentication.time, "sleep", real_time.sleep) + + started = real_time.monotonic() + response = client.post( + "/api/auth/login", + data={"admin_id": unicode_id, "admin_password": "неверный🔒"}, + headers={"origin": "https://testserver"}, + follow_redirects=False, + ) + elapsed = real_time.monotonic() - started + + assert response.status_code == 303 + assert response.headers["location"] == "/?auth_error=invalid" + assert "set-cookie" not in response.headers + assert elapsed >= authentication.MINIMUM_FAILURE_DELAY_SECONDS * 0.9 + + def test_invalid_credentials_do_not_set_cookie_and_use_general_error( client: TestClient, ) -> None: @@ -104,6 +148,23 @@ def test_invalid_credentials_do_not_set_cookie_and_use_general_error( assert ADMIN_ID not in response.headers["location"] +def test_login_rejects_malformed_percent_encoded_utf8_without_cookie( + client: TestClient, +) -> None: + response = client.post( + "/api/auth/login", + content=b"admin_id=%FF&admin_password=wrong", + headers={ + "origin": "https://testserver", + "content-type": "application/x-www-form-urlencoded", + }, + follow_redirects=False, + ) + + assert response.status_code == 400 + assert "set-cookie" not in response.headers + + def test_missing_configuration_fails_closed(client: TestClient, monkeypatch) -> None: monkeypatch.delenv(authentication.SIGNING_SECRET_ENV) diff --git a/tests/test_dashboard_authentication.py b/tests/test_dashboard_authentication.py index 47e85ee..4d16765 100644 --- a/tests/test_dashboard_authentication.py +++ b/tests/test_dashboard_authentication.py @@ -63,20 +63,26 @@ def test_load_admin_credentials_fails_closed_when_environment_cannot_be_read(): def test_verify_credentials_uses_constant_time_comparison_for_both_fields(monkeypatch): credentials = authentication.AdminCredentials(ADMIN_ID, ADMIN_PASSWORD) - comparisons: list[tuple[str, str]] = [] + comparisons: list[tuple[bytes, bytes]] = [] - def record_compare(left: str, right: str) -> bool: + def record_compare(left: bytes, right: bytes) -> bool: comparisons.append((left, right)) return left == right monkeypatch.setattr(authentication.hmac, "compare_digest", record_compare) assert authentication.verify_credentials(credentials, ADMIN_ID, ADMIN_PASSWORD) is True - assert comparisons == [(ADMIN_ID, ADMIN_ID), (ADMIN_PASSWORD, ADMIN_PASSWORD)] + assert comparisons == [ + (ADMIN_ID.encode("utf-8"), ADMIN_ID.encode("utf-8")), + (ADMIN_PASSWORD.encode("utf-8"), ADMIN_PASSWORD.encode("utf-8")), + ] comparisons.clear() assert authentication.verify_credentials(credentials, "wrong-id", "wrong-password") is False - assert comparisons == [("wrong-id", ADMIN_ID), ("wrong-password", ADMIN_PASSWORD)] + assert comparisons == [ + (b"wrong-id", ADMIN_ID.encode("utf-8")), + (b"wrong-password", ADMIN_PASSWORD.encode("utf-8")), + ] def test_failed_login_delay_waits_only_for_remaining_minimum_duration():