Close two follow-up viewer authz gaps (session-gate history reads, fail-closed expiry) (#819)

* Session-gate historical run reads; fail closed on missing expiry

* Accept epoch-seconds relay expiry so valid verification is not misread

* Reject OTP verify responses lacking a usable expiry

---------

Co-authored-by: yoni <yoni@usestrix.com>
This commit is contained in:
devin-ai-integration[bot]
2026-07-21 00:22:19 +00:00
committed by GitHub
co-authored by yoni
parent 7b412ac3b1
commit 000a243861
4 changed files with 103 additions and 28 deletions
+18 -9
View File
@@ -213,9 +213,11 @@ def _session_cookie(url: str, token: str) -> str:
return raw.split(";", 1)[0]
def _get_status(url: str) -> int:
def _get_status(url: str, *, cookie: str | None = None) -> int:
headers = {"Cookie": cookie} if cookie else {}
req = urllib.request.Request(url, headers=headers) # noqa: S310 - localhost test server
try:
with urllib.request.urlopen(url) as resp: # noqa: S310 - localhost test server
with urllib.request.urlopen(req) as resp: # noqa: S310
return int(resp.status)
except urllib.error.HTTPError as exc:
return int(exc.code)
@@ -378,19 +380,26 @@ def test_historical_run_data_requires_verification(
verified = {"value": False}
monkeypatch.setattr("strix.viewer.auth.is_verified", lambda: verified["value"])
httpd, url, _ = serve(launched, open_browser=False)
httpd, url, token = serve(launched, open_browser=False)
try:
# The launched run is always viewable, no verification required.
# The launched run is always viewable, no verification and no cookie.
status, _, _ = _get(f"{url}/api/run")
assert status == 200
# A different run's data is gated behind verification.
assert _get_status(f"{url}/api/run?run=other") == 401
cookie = _session_cookie(url, token)
# Once verified, the historical run resolves.
# A different run needs the session capability first: a cookie-less
# caller is forbidden even once the machine is verified.
verified["value"] = True
status, _, _ = _get(f"{url}/api/run?run=other")
assert status == 200
assert _get_status(f"{url}/api/run?run=other") == 403
# With the cookie but not verified, the history gate returns 401.
verified["value"] = False
assert _get_status(f"{url}/api/run?run=other", cookie=cookie) == 401
# With both the cookie and verification, the historical run resolves.
verified["value"] = True
assert _get_status(f"{url}/api/run?run=other", cookie=cookie) == 200
finally:
httpd.shutdown()
httpd.server_close()
+36 -5
View File
@@ -56,13 +56,33 @@ def test_is_verified_enforces_expiry() -> None:
assert auth.is_verified() is True
def test_is_verified_when_expiry_absent_or_unparseable() -> None:
# No/blank expiry: cannot enforce locally, so treat as valid.
def test_is_verified_fails_closed_when_expiry_absent_or_unparseable() -> None:
# No/blank expiry: fail closed rather than unlocking history forever.
auth.write_auth(email="a@b.com", token="t", verified_at="")
assert auth.is_verified() is True
assert auth.read_auth() is not None
assert auth.is_verified() is False
# Garbage expiry is ignored rather than locking the user out.
# Garbage expiry likewise requires re-verification.
auth.write_auth(email="a@b.com", token="t", verified_at="not-a-date")
assert auth.is_verified() is False
def test_is_verified_accepts_epoch_expiry() -> None:
# A relay expiry expressed as epoch seconds must not be misread as missing.
future = (datetime.now(UTC) + timedelta(hours=1)).timestamp()
past = (datetime.now(UTC) - timedelta(hours=1)).timestamp()
# As a numeric string (how write_auth persists it).
auth.write_auth(email="a@b.com", token="t", verified_at=str(future))
assert auth.is_verified() is True
auth.write_auth(email="a@b.com", token="t", verified_at=str(past))
assert auth.is_verified() is False
# As a raw JSON number, if a record is written that way.
auth.AUTH_PATH.parent.mkdir(parents=True, exist_ok=True)
auth.AUTH_PATH.write_text(
f'{{"email": "a@b.com", "token": "t", "verified_at": {future}}}', encoding="utf-8"
)
assert auth.is_verified() is True
@@ -102,7 +122,8 @@ def test_otp_start_maps_errors(monkeypatch: pytest.MonkeyPatch) -> None:
def test_otp_verify_success_and_invalid(monkeypatch: pytest.MonkeyPatch) -> None:
_stub_post(monkeypatch, 200, {"token": "t", "email": "a@b.com", "expires_at": "later"})
expires = _iso(timedelta(hours=1))
_stub_post(monkeypatch, 200, {"token": "t", "email": "a@b.com", "expires_at": expires})
result = auth.otp_verify("a@b.com", "123456")
assert result["token"] == "t"
@@ -112,6 +133,16 @@ def test_otp_verify_success_and_invalid(monkeypatch: pytest.MonkeyPatch) -> None
assert exc.value.code == "invalid_code"
def test_otp_verify_rejects_token_without_usable_expiry(monkeypatch: pytest.MonkeyPatch) -> None:
# A 200 with a token but no valid expiry must not be reported as success,
# otherwise the caller would store a record that immediately reads unverified.
for expires in (None, "", "later"):
_stub_post(monkeypatch, 200, {"token": "t", "email": "a@b.com", "expires_at": expires})
with pytest.raises(auth.RelayError) as exc:
auth.otp_verify("a@b.com", "123456")
assert exc.value.code == "unavailable"
def test_report_send_never_includes_password(monkeypatch: pytest.MonkeyPatch) -> None:
captured: dict[str, Any] = {}