2 Commits
5 changed files with 306 additions and 6 deletions
+2
View File
@@ -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)
+6 -2
View File
@@ -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
@@ -0,0 +1,227 @@
{
"claims": [
{
"id": "f003-unicode-success",
"pointers": [
"commands[0].argv",
"commands[0].exit_code",
"commands[0].output",
"commands[0].started_at_utc",
"commands[0].finished_at_utc"
]
},
{
"id": "f003-generic-delayed-failure",
"pointers": [
"commands[0].argv",
"commands[0].exit_code",
"commands[0].output",
"commands[0].started_at_utc",
"commands[0].finished_at_utc"
]
},
{
"id": "f003-malformed-utf8-400-no-cookie",
"pointers": [
"commands[0].argv",
"commands[0].exit_code",
"commands[0].output",
"commands[0].started_at_utc",
"commands[0].finished_at_utc"
]
},
{
"id": "full-regression",
"pointers": [
"commands[1].argv",
"commands[1].exit_code",
"commands[1].output",
"commands[1].started_at_utc",
"commands[1].finished_at_utc"
]
},
{
"id": "static-and-source-integrity",
"pointers": [
"commands[2].argv",
"commands[2].exit_code",
"commands[2].output",
"commands[3].argv",
"commands[3].exit_code",
"commands[3].output",
"commands[4].argv",
"commands[4].exit_code",
"commands[4].output"
]
}
],
"commands": [
{
"argv": [
".venv/bin/python",
"-m",
"pytest",
"-o",
"addopts=",
"-q",
"-p",
"no:cacheprovider",
"--basetemp=/tmp/jarca-f003-evidence-focused",
"tests/test_dashboard_auth_server.py",
"tests/test_dashboard_authentication.py"
],
"duration_seconds": 11.436193,
"exit_code": 0,
"expected_exit_code": 0,
"finished_at_utc": "2026-10-04T03:55:21.516159Z",
"id": "focused-tests",
"output": "................................ [100%]\n=============================== warnings summary ===============================\ntests/test_dashboard_auth_server.py:11\n /opt/data/gitea-agent/repositories/jsfamily/jarca-ledger-mcp/worktrees/issue-6/checkout/tests/test_dashboard_auth_server.py:11: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.\n from starlette.testclient import TestClient\n\n-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html\n32 passed, 1 warning in 7.81s\n",
"purpose": "F-003 Unicode success, delayed generic mismatch, malformed percent-encoded UTF-8, and credential helper verification",
"started_at_utc": "2026-10-04T03:55:10.080020Z"
},
{
"argv": [
".venv/bin/python",
"-m",
"pytest",
"-o",
"addopts=",
"-q",
"-p",
"no:cacheprovider",
"--basetemp=/tmp/jarca-f003-evidence-full"
],
"duration_seconds": 54.086422,
"exit_code": 0,
"expected_exit_code": 0,
"finished_at_utc": "2026-10-04T03:56:15.670519Z",
"id": "full-tests",
"output": "........................................................................ [ 59%]\n...................ss........................ss... [100%]\n=============================== warnings summary ===============================\ntests/test_dashboard_auth_server.py:11\n /opt/data/gitea-agent/repositories/jsfamily/jarca-ledger-mcp/worktrees/issue-6/checkout/tests/test_dashboard_auth_server.py:11: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.\n from starlette.testclient import TestClient\n\n-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html\n118 passed, 4 skipped, 1 warning in 49.12s\n",
"purpose": "Complete repository regression suite",
"started_at_utc": "2026-10-04T03:55:21.584157Z"
},
{
"argv": [
".venv/bin/ruff",
"check",
"--no-cache",
"dashboard/authentication.py",
"dashboard/auth_server.py",
"tests/test_dashboard_auth_server.py",
"tests/test_dashboard_authentication.py"
],
"duration_seconds": 0.025974,
"exit_code": 0,
"expected_exit_code": 0,
"finished_at_utc": "2026-10-04T03:56:15.770278Z",
"id": "ruff",
"output": "All checks passed!\n",
"purpose": "Static lint for the exact F-003 source and test allowlist",
"started_at_utc": "2026-10-04T03:56:15.744356Z"
},
{
"argv": [
"uv",
"lock",
"--check",
"--offline"
],
"duration_seconds": 0.059792,
"exit_code": 0,
"expected_exit_code": 0,
"finished_at_utc": "2026-10-04T03:56:15.890498Z",
"id": "lock-check",
"output": "Resolved 72 packages in 3ms\n",
"purpose": "Offline dependency lock consistency",
"started_at_utc": "2026-10-04T03:56:15.830762Z"
},
{
"argv": [
"git",
"diff",
"--check",
"d84ed8ff2a53cd2735eceaaf0c54cb40c7c2c3ad^",
"d84ed8ff2a53cd2735eceaaf0c54cb40c7c2c3ad"
],
"duration_seconds": 0.033395,
"exit_code": 0,
"expected_exit_code": 0,
"finished_at_utc": "2026-10-04T03:56:15.992735Z",
"id": "source-diff-check",
"output": "",
"purpose": "Whitespace validation for the exact source commit",
"started_at_utc": "2026-10-04T03:56:15.959398Z"
}
],
"contract_sha256": "4f734169ec896747fe39fd7f3ab75d1fc0d8cdd4903045e665beba40d80eb1fe",
"environment": {
"architecture": "aarch64",
"child_environment_keys": [
"HOME",
"NO_COLOR",
"PATH",
"PYTHONDONTWRITEBYTECODE"
],
"credentials": "synthetic test-only values supplied by tests; not recorded",
"dependencies": {
"httpx": "0.28.1",
"itsdangerous": "2.2.0",
"pytest": "9.1.1",
"starlette": "1.3.1",
"streamlit": "1.58.0",
"uvicorn": "0.49.0"
},
"os": "Linux-6.18.34+rpt-rpi-v8-aarch64-with-glibc2.41",
"python": "3.13.5",
"signing_secret": "synthetic test-only value supplied by tests; not recorded"
},
"finding": "F-003",
"issue": 6,
"limitations": [
"The focused adapter tests use Starlette TestClient rather than a newly executed external browser or deployed Compose/Kubernetes environment.",
"The existing Starlette/httpx deprecation warning is retained and is unrelated to F-003."
],
"notes": [
"This evidence-only descendant binds verification to source_revision; the listed source bytes and Git blob OIDs were unchanged before, during, and after every command.",
"No command used credentials, signing secrets, authorization headers, raw cookies, or shell interpretation in argv."
],
"pull_request": 7,
"redaction": {
"credentials_recorded": false,
"raw_set_cookie_recorded": false,
"request_body_recorded": false,
"signing_secret_recorded": false,
"token_or_cookie_value_recorded": false
},
"schema": "jarca-ledger.review-evidence/v2",
"source_review": 53,
"source_revision": "d84ed8ff2a53cd2735eceaaf0c54cb40c7c2c3ad",
"source_tree_clean_before_evidence_write": true,
"sources": {
"dashboard/auth_server.py": {
"git_blob_oid": "0546d582805f495c86dcdbb45510431f7699ecfd",
"sha256": "94675feffbc0807a632d51a9feea8aa4d8834a37de2d8f32c50aa273d88ddab3"
},
"dashboard/authentication.py": {
"git_blob_oid": "006d3c95661b64924a89aa952ff531095d4684db",
"sha256": "260c3419040a883a76ced3fd1a1ff43f86763e608448fff57078c30a54c74213"
},
"pyproject.toml": {
"git_blob_oid": "2892966feaaa8dfa07bf330997afc8315364a672",
"sha256": "64100d3aa1c2272df2e2139ed435724901f7215ea72e48bee9d73f95e7e53a8d"
},
"tests/test_dashboard_auth_server.py": {
"git_blob_oid": "0508f54f74185fc4fda341fcdd2574cbadb0d203",
"sha256": "6b226b8d0d207a84f788b090de67fe6ddf9e7c8e05218ce18d97aa4badac95f5"
},
"tests/test_dashboard_authentication.py": {
"git_blob_oid": "4d16765c4ab64826144d4880ec27b489a02bc53d",
"sha256": "d6758e91b2e275cb22f55929ce0bf4a775feecb23f720fa05151da1ebb4da036"
},
"uv.lock": {
"git_blob_oid": "c92cadb5bf8ae12e603fa956177c5d7f12e58535",
"sha256": "9582546bc7c97a25b5d40d66ee814203ae46c13e7263942d02008403abd33c73"
}
},
"verified_at_utc": "2026-10-04T03:56:15.992735Z"
}
+61
View File
@@ -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)
+10 -4
View File
@@ -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():