Repository navigation
security(auth): fail closed when the password-epoch check cannot reach Redis (#16411) - #16433
Conversation
…h Redis (#16411) Owner decision on #16411: a revoked token is never honoured, so when the password-epoch check cannot run the backend denies with 401, the rule the SLM follows (#16387). get_password_epoch now raises RevocationCheckUnavailable, a ConnectionError, on a missing client or a store failure instead of reading 'could not check' as 'not revoked'; only store failures are converted, so a bug surfaces as itself. The backend gate moves to auth_revocation.py, which logs the check and error type (never the token) and answers 401. auth_middleware.py shrinks 1099 -> 1094. The whole-file function-length hook (#16191) blocked on _get_rs256_keypair, which this change never touches, so its nested key-file writer becomes the _write_jwt_key_file staticmethod, body verbatim.
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Advanced Run ID: Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
✅ SSOT Configuration Compliance: Passing🎉 No new hardcoded values of either class — Known backlog in |
…16422) Owner decision on #16422: a stored password-epoch marker or a token iat that is not an integer denies the token, like a store that cannot answer (#16411). get_password_epoch raises RevocationCheckUnavailable on a non-integer marker; is_token_revoked_by_password_change parses iat before the lookup and raises on a non-integer one, so it is denied whether or not a marker exists. The two warnings that printed the subject and raw value are gone; the backend gate logs the check and the helper's fixed reason only.
|
Code review: APPROVE, no defects, for both commits.
The one nit, keeping each raise site's reason unique, needs no action now. |
…proxy-check tests (#16413) Rebasing onto post-2a main (0abe0d4) surfaced two real defects, both from combining with #16386 (also editing services/auth.py): - THREAT_MODEL.md's decode_token_async anchor (:119) drifted to :121. Re-checked every anchor into services/auth.py, jwks_verifier.py and token_denylist.py by reading the current file; only this one moved. - #16386's tests/api/test_auth_proxy_check.py loads services.auth for REAL, so a minted token's jti reaches the real token_denylist.is_jti_revoked, which needs Redis -- and #16387 made that check fail CLOSED (raise) when Redis cannot answer, which CI's no-Redis environment cannot. Added an autouse fixture stubbing is_jti_revoked to "not revoked", the same patch.object pattern jwks_verifier_test.py's _stub_rs256_denylist already uses for the RS256 side. The password-epoch check right after it needs no stub: get_password_epoch already fails open internally on any Redis error, so auth.py's fail-closed wrapper around it never fires from that cause. Swept the rest of the SLM test tree for the same shape: test_auth_logout.py and test_saml_slo.py real-load services.auth too, but neither test file calls get_current_user/decode_token_async with a jti-bearing token -- test_auth_logout.py's Redis-touching test already mocks the redis client directly, and jwks_verifier_test.py's HS256 case hand-builds a payload with no jti claim at all, so is_jti_revoked is never reached by either.
…nto tmp-16433-stack # Conflicts: # .secrets.baseline # docs/developer/THREAT_MODEL.md
…l-closed # Conflicts: # autobot-slm-backend/tests/api/test_auth_proxy_check.py # docs/developer/THREAT_MODEL.md
|
Delta review at 3c3babf (from my last approval at 3d83a94): APPROVE. Verified directly, not just against the description:
CI at 3c3babf: 0 fail, 35 success, 26 pending. Good to merge once green — this stands alone per the decoupling, no longer gated on #16413's head. |
Closes #16411
Closes #16422
Closes #16659
Closes #16387
PR #16413 has landed (
e3d2d4d92), and this branch has mergedmainon top of it. The diff is now #16433's own.Thinking Path
iatthat isn't an integer denies the token. Log at error level with the check name only.mainthe check fails open four ways. A missing client, a store error, a non-integer marker and a non-integeriatare all read as "not revoked".get_password_epochswallows every Redis error and returns None, so the SLM's fail-closed wrapper from fix(security): deny an SLM token when its revocation check cannot run, HS256 and RS256 (#16387, #16412) #16413 never sees one. That is security(slm-auth): password-epoch revocation check still fails open on a real Redis outage, despite #16387's fail-closed decision #16659, which e5 found after fix(security): deny an SLM token when its revocation check cannot run, HS256 and RS256 (#16387, #16412) #16413 landed, and the reason security(slm-auth): token revocation checks fail open when Redis errors, so a revoked token is accepted #16387 was reopened. This PR is the fix, and its tests makeget_password_epochitself fail, not a mock one layer up.(asyncio.TimeoutError, ConnectionError, OSError, RedisError), returning 401. SoRevocationCheckUnavailablesubclassesConnectionError, which puts it inside that tuple.except Exception.iatis parsed before the epoch lookup, so an unreadableiatis denied whether or not its subject has a marker.auth_middleware.py. That file is grandfathered under the 600-line ratchet, so the gate is in the newauth_revocation.py._get_rs256_keypair. So its nested_write_key_fileclosure becomes a@staticmethodthat takeskey_fileas an argument. The body moves verbatim.What Changed
autobot_shared/user_management/password_epoch.py:RevocationCheckUnavailable(ConnectionError)and_epoch_store().get_password_epochraises it when there's no client, when acquiring a client fails, whengetfails with a store error (security(backend-auth): password-epoch revocation check fails open when Redis errors (backend equivalent of #16387) #16411), and when the marker isn't an integer (security(auth): password-epoch check still honours a token when the marker or iat can't be read (corrupt-data fail-open) #16422).is_token_revoked_by_password_changeraises it wheniatisn't an integer, checked before the lookup.iatany more. That covers the rejected-token warning andset_password_epoch's success line; only the subject is logged. This fixes CodeQL'spy/clear-text-logging-sensitive-dataalert Bug: Chat atomic write path creates invalid directory (.json/.tmp_chat_*) #1051, open onmain, at its source.autobot-backend/auth_revocation.py(new):reject_if_revoked_by_password_change(user_data).WWW-Authenticate: Bearer.autobot-backend/auth_middleware.py:_write_key_filebecomes_write_jwt_key_file.test_auth_proxy_check.py's autouse fixture, now_stub_revocation_checks, stubs both revocation checks. Every test in that module is a gate, decode or static test; none is about revocation.test_token_denylist.py::test_non_revoked_jti_decodes_correctlystubs the epoch check as well.docs/developer/THREAT_MODEL.md§4:verify_internal_api_keyanchor (:954). §3 hascheck_admin_permissionat :967.Verification
password_epoch_test.py:getraisingRedisError,ConnectionError,TimeoutErrororOSError, raisesRevocationCheckUnavailable, chained to its cause.RuntimeErroris not converted.iatraise.ConnectionErrorsubclass is pinned.test_password_revocation_fail_closed_16411.py: a Redis error, a missing client, a corrupt marker or a non-integeriatgives 401. A token from before a password change is refused, and one from after it is admitted. With no marker, the token is admitted.decode_token_async(test_token_denylist.py): the epoch store'sget()raisingConnectionErrorgives 401. This is security(slm-auth): password-epoch revocation check still fails open on a real Redis outage, despite #16387's fail-closed decision #16659's AC. Its control uses the same wiring with an empty store and admits the token, so the 401 can't come from the fake failing to reach the real helper.GET /api/auth/me(test_auth_me_16385.py,_FakeEpochRedis): no marker gives 2xx, a stale marker 401, a current marker 2xx, a Redis error 401, and no client 401.verify_internal_api_keyis atauth_middleware.py:954andcheck_admin_permissionat:967, out of 1094 lines. Every §4 anchor sits on the same line as its own link, and the anchor test attributes by line.f5f3f0883(the SLM test stubs and CodeQL fix): APPROVE, no defects. It confirmed the stubs patch the nameservices.authresolves at call time, that the autouse scope masks no revocation test, and that end-to-end coverage exists in both directions.556765b93(the full diff overe3d2d4d92): APPROVE. It confirmed several things:get_password_epochuses, in each test file.create_access_tokenkeeps a caller-suppliediat.iat, a token or claims.iatincluded, still 2 lines with the doc at 140;password_epoch_test.py::test_recording_an_epoch_never_logs_its_valuepins thatset_password_epoch's log line never prints the epoch.Model Used
Claude Opus 5 (
claude-opus-5).