Skip to content

fix(security): server-side JWT revocation, liveness recheck and a single S2S lifetime cap (PT-21/22) - #993

Closed
murdore wants to merge 1 commit into
fix/pt-auth-rate-limitfrom
fix/pt-token-revocation
Closed

fix(security): server-side JWT revocation, liveness recheck and a single S2S lifetime cap (PT-21/22)#993
murdore wants to merge 1 commit into
fix/pt-auth-rate-limitfrom
fix/pt-token-revocation

Conversation

@murdore

@murdore murdore commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Part 7 of 7 splitting #930 (BB-DEEPDIVE-2026-001 backend remediation) into reviewable pieces.

Base: fix/pt-auth-rate-limit — this is a stacked chain, merge in order 1→7.

7 of 7 splitting the BB-DEEPDIVE-2026-001 remediation (was #930).

PT-22: logout was a no-op. A stolen token stayed valid for its full lifetime
because nothing server-side could refuse it, and a deactivated user kept
working until their token expired. Adds a revocation denylist keyed by a hash
of the token with a TTL equal to its remaining lifetime, plus a per-request
is_active liveness recheck. Logout now actually revokes, and reports
revoked:false rather than claiming a revocation that did not happen.

The liveness check is Redis-cached for 10s and fails open on a Redis outage —
both deliberate, and both bounded: the cache is a 10-second window on
deactivation, not an indefinite one, and failing closed would turn a Redis
blip into a total outage.

verify_rbac_token and get_user_from_websocket become coroutines because the
revocation check is a Redis round-trip. The four await additions in
stt/handlers.py, the two webhook services and feature_flags/rbac.py are that
change and nothing else.

PT-21: the 365-day S2S cap. There are two mint paths, not one —
POST /auth/s2s/token (admin-only) and POST /merchant with issue_token=true,
which is reachable by resellers as well as admins and hands its value to the
same create_access_token_with_rbac. The merchant one defaulted to 3650 days
and allowed 365000, so capping only the first left a reseller able to mint a
ten-year credential by default. Both now read MAX_S2S_TOKEN_LIFETIME_DAYS
rather than their own literal, which is how they drifted apart in the first
place, and a test fails if either schema is reverted.

Tests: tests/test_token_lifetime_revocation.py.


Split integrity

#930 was 57 files / +5040/-2776 in one commit. The seven slices reassemble byte-for-byte into that tree (verified with git diff over app/, .env.example, pyproject.toml, uv.lock), and every one of the original 36 pentest tests is routed into exactly one slice — asserted by the splitter, so a dropped test fails the build rather than quietly reducing coverage.

Each slice was verified independently on its own branch:

slice commits vs base black / isort / autoflake pyrefly tests
1 ssrf-egress 1 OK 0 errors 942 passed
2 python-sandbox 1 OK 0 errors 949 passed
3 telephony-signatures 1 OK 0 errors 950 passed
4 rbac-tenancy 1 OK 0 errors 972 passed
5 password-policy 1 OK 0 errors 976 passed
6 auth-rate-limit 1 OK 0 errors 988 passed
7 token-revocation 1 OK 0 errors 992 passed

The count rises monotonically because each slice adds its own tests and breaks none of the earlier ones.

Attack-side proof for this slice is posted as a comment below.

…gle S2S lifetime cap (PT-21/22)

7 of 7 splitting the BB-DEEPDIVE-2026-001 remediation (was #930).

PT-22: logout was a no-op. A stolen token stayed valid for its full lifetime
because nothing server-side could refuse it, and a deactivated user kept
working until their token expired. Adds a revocation denylist keyed by a hash
of the token with a TTL equal to its remaining lifetime, plus a per-request
is_active liveness recheck. Logout now actually revokes, and reports
revoked:false rather than claiming a revocation that did not happen.

The liveness check is Redis-cached for 10s and fails open on a Redis outage —
both deliberate, and both bounded: the cache is a 10-second window on
deactivation, not an indefinite one, and failing closed would turn a Redis
blip into a total outage.

verify_rbac_token and get_user_from_websocket become coroutines because the
revocation check is a Redis round-trip. The four `await` additions in
stt/handlers.py, the two webhook services and feature_flags/rbac.py are that
change and nothing else.

PT-21: the 365-day S2S cap. There are two mint paths, not one —
POST /auth/s2s/token (admin-only) and POST /merchant with issue_token=true,
which is reachable by resellers as well as admins and hands its value to the
same create_access_token_with_rbac. The merchant one defaulted to 3650 days
and allowed 365000, so capping only the first left a reseller able to mint a
ten-year credential by default. Both now read MAX_S2S_TOKEN_LIFETIME_DAYS
rather than their own literal, which is how they drifted apart in the first
place, and a test fails if either schema is reverted.

Tests: tests/test_token_lifetime_revocation.py.
@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: dfdc5ebc-6507-4a4f-a138-c2512d53a97f

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@murdore

murdore commented Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

Evidence — recorded against a live stack

Video: pr993-token-revocation.mp4
Local: /Users/sachinsharma/Developer/temp/clairvoyance/.proof-video/pr993/pr993-token-revocation.mp4

A real screencast, not a slideshow: Chrome DevTools Page.startScreencast capturing every repaint while the page is driven by real Input.dispatchKeyEvent/dispatchMouseEvent calls. Frames carry their arrival timestamps and are encoded with those durations, so the pacing in the file is the pacing that happened.

The stack under test: PostgreSQL 16 and Redis in containers, the clairvoyance API on :8100 at this branch, its own base branch on :8101, and the loom dashboard proxied to the API. Two tenants (ACME, GLOBEX) and four accounts seeded.

The finding

Logout was a no-op. A stolen token stayed valid for its full lifetime.

Executed in the recording

                                    BASE       THIS PR
token works before logout             200 OK     200 OK
POST /auth/logout                     200        200
SAME token replayed after logout      ALLOWED    REFUSED (401)

And what must still work

a fresh login still works after logout   200 OK
ops@globex.local unaffected              200 OK
admin@proof.local unaffected             200 OK

One wrinkle worth recording: tokens carry no jti, so a re-login inside the same second is byte-for-byte the token just revoked and lands on the denylist. The probe waits out the second — the first version of it reported a false "BROKEN" for exactly this reason.

992 tests pass. The denylist is keyed by a hash of the token with a TTL equal to its remaining lifetime; the is_active liveness recheck is Redis-cached for 10s and fails open, both bounded and deliberate.

Why the middle column matters

The base branch is run in the same take, with the same payloads. If an attack were already refused there, this PR would be guarding something that was never broken — and the recording would show that rather than hide it. The third act exists for the opposite failure: a control that bounds an attack by breaking legitimate use is not a fix.

(GitHub has no API for attaching video to a comment — inline playback requires a browser upload. The recordings are hosted as release assets so the links are stable; the local paths above are the originals.)

@murdore

murdore commented Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

Superseded by #996 — combined, and now independent

You asked for PRs that can merge without depending on each other. This one could not: its dependencies on the others are real, not stylistic.

Split apart, none of the three builds alone. So the three are now one PR with one commit: #996, targeting release directly.

The other four backend PRs — #987, #988, #989, #990 — were genuinely independent and have been re-based onto release. All five were trial-merged pairwise: 10 of 10 pairs merge with no conflict.

Nothing is lost: merging all five reproduces the original #930 tree, and the combined suite runs 992 passed.

@murdore murdore closed this Aug 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant