Skip to content

fix(security): close a set of small auth and hardening gaps - #4635

Open
sdornan wants to merge 7 commits into
masterfrom
claude/app-security-audit-jo76x6
Open

sdornan wants to merge 7 commits into
masterfrom
claude/app-security-audit-jo76x6

Conversation

@sdornan

@sdornan sdornan commented Sep 19, 2026

Copy link
Copy Markdown
Contributor

Description

Findings from a security audit of the current tree, plus the fixes that came out of review on this PR. Each change is small and independent.

Area Fix
csrf_middleware.py An Authorization header exempted a request from CSRF on presence alone. HybridAuthBackend resolves the session cookie first, so a victim's cookie plus any header value at all (Authorization: Bearer x) authenticated as the cookie's owner with the check skipped. Now exempt only when no session cookie is present.
endpoints/user.py A credential change now revokes every session of the user it was applied to, not just the caller's. Previously an admin resetting a compromised account left the attacker's sessions intact.
redis_session_middleware.py An existing session is refreshed with SET ... XX, so a request holding a copy of a session revoked mid-flight cannot write it back. Previously any in-flight request undid a revocation.
redis_session_middleware.py clear_user_sessions decoded its smembers members before building the delete key. A bytes member interpolated into an f-string produced session:b'<id>', so the real session survived the delete.
endpoints/user.py POST /users/{id}/ra/refresh took an arbitrary id under me.write with no ownership or admin check.
endpoints/user.py Invite-registration uniqueness checks ran before the token was validated, letting an anonymous caller enumerate usernames and emails with a token they never had.
base_handler.py Invites are consumed with a single GETDEL, so two registrations racing one invite cannot both create an account. The non-consuming precheck keeps a plain GET, and both raise the same error.
base_handler.py verify_password returns False on a hash passlib can't identify. OIDC accounts hold a placeholder, which raised and turned a 401 into a 500 that identified them.
base_handler.py Log the invite token's jti rather than the link, which already goes back to the caller in the response.
utils/archives.py -- before the 7-Zip member argument, so an archive entry named -x or @file stays a member name. Member names come from the archive's own listing.
docker/init_scripts/init Trust X-Forwarded-For only from the bundled nginx. With --forwarded-allow-ips="*", uvicorn reads the leftmost entry, which the caller supplies.

The CSRF change

The only cross-layer change. The Session cookie present? branch is new.

flowchart TD
    req[Request with a bearer or basic Authorization header] --> cookie{Session cookie present?}
    cookie -->|No| skip[Skip CSRF: the header is the credential]
    cookie -->|Yes| check[Enforce CSRF: HybridAuthBackend authenticates the cookie, not the header]
    skip --> app[Endpoint]
    check --> app
Loading

Known residual, for the merge decision

Session revocation still has one gap this PR does not close, raised by Copilot and confirmed: a /login that authenticated against the old password before a reset lands has no existing session id, so it writes unconditionally — it must, or nobody could log in — and its SET/SADD can land after clear_user_sessions has read its member list. That session survives.

It needs an attacker to already hold valid old credentials and to overlap a login with the reset window, so it is narrower than the resurrection case fixed here, which needed only any in-flight request. Relative to master this is strictly an improvement: master has both races.

Closing it properly means a per-user revocation generation — stamp sessions with an issue time, record a valid_from per user on revoke, reject anything older at load — which subsumes the SET XX guard rather than sitting beside it. That changes the session validity model and payload schema, so it is scheduled as a follow-up PR rather than bolted on here.

Also deliberately not included

  • Reset-link logging. generate_password_reset_token returns nothing and there is no email support, so that log line is the only delivery channel for a reset link. Removing it would break password reset.
  • CORS default of * with allow_credentials=True. Real, but changing the default is breaking for existing deployments and deserves its own PR. The CSRF fix above is the robust half of that pair.
  • The larger audit findings — the unauthenticated /netplay Socket.IO namespace, provider credentials baked into the published image via ENV, and unbounded decompression on the ROM hashing path.

Behaviour change worth a reviewer's eye

FORWARDED_ALLOW_IPS (new, documented in env.template, defaults to 127.0.0.1). Deployments behind a further reverse proxy append that proxy's address rather than replacing the bundled nginx's, since 127.0.0.1 is the peer uvicorn checks.

Checklist

  • I've tested the changes locally
  • I've updated relevant comments
  • I've assigned reviewers for this PR
  • I've added unit tests that cover the changes

On testing — I could not run pytest or Trunk in my environment (a pre-existing pydantic / Python 3.14.0rc2 collection failure, and a blocked Trunk plugin download), so CI has been the first execution throughout. That is not academic: CI caught the clear_user_sessions decoding bug when a test I added failed, and review caught that the admin-reset test was sending a stale session cookie alongside the bearer and so never exercised the admin path. Both are fixed. "Tested locally" stays unchecked because it would not be true.

AI assistance disclosure

Per CONTRIBUTING.md: written with AI assistance (Claude Code). The audit, the code changes, the tests and this description are AI-generated, each claim verified against the source.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AZF3cyuYh96LK2h3j25DPC

sdornan and others added 2 commits September 19, 2026 12:03
Findings from a security audit of the current tree. Each is independent
and small; the larger findings (unauthenticated netplay socket, image-baked
provider credentials, decompression bombs) are left for separate changes.

- csrf: an Authorization header exempted a request on presence alone, but
  HybridAuthBackend resolves the session cookie first, so a cookie plus any
  header value authenticated as the cookie's owner with the check skipped.
  Exempt only when no session cookie is present.
- users: revoke every session of the user a credential change was applied
  to, not just the caller's, so an admin resetting a compromised account
  can lock the attacker out. Sessions are keyed by username, so the name is
  captured before the update renames them.
- users: gate POST /{id}/ra/refresh on ownership. It took an arbitrary id
  under me.write and wrote to that user's row.
- users: verify the invite token before the uniqueness checks, which let an
  anonymous caller enumerate accounts with a token they never had. Split
  verify from consume so a rejected username does not burn the invite.
- auth: fail password verification on a hash passlib cannot identify.
  OIDC-provisioned accounts hold a placeholder, which raised and turned a
  401 into a 500 that identified them.
- auth: log the invite token's jti rather than the link. The link goes back
  to the caller in the response, and the log has a wider audience.
- archives: pass "--" before the 7-Zip member argument, matching the bsdtar
  branch, so an entry named "-x" or "@file" stays a member name.
- init: trust X-Forwarded-For only from the bundled nginx. Trusting every
  hop makes uvicorn read the leftmost entry, which the caller supplies, and
  the per-IP rate limits read that address. FORWARDED_ALLOW_IPS overrides
  it for deployments behind a further proxy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AZF3cyuYh96LK2h3j25DPC
Review-polish pass over the previous commit.

Comments cut to the one non-obvious fact each: the cross-middleware
ordering, passlib's behaviour on an unidentifiable hash, and what the
leftmost X-Forwarded-For entry means. The rest was restating the code or
defending the choice.

verify_invite_link_token renamed to assert_invite_link_token_valid. The
adjacent verify_password_reset_token verifies *and* consumes, so two
verify_* methods on one class would have read as the same thing while
differing on whether the token survives; assert_* is the idiom already
used for raising checks (assert_rom_visible, assert_session_owner). It
returned a role nothing read, so it returns None.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AZF3cyuYh96LK2h3j25DPC
@sdornan
sdornan marked this pull request as ready for review September 19, 2026 16:25
Copilot AI lite review requested due to automatic review settings September 19, 2026 16:25
@greptile-apps

greptile-apps Bot commented Sep 19, 2026

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 5/5

The PR appears safe to merge, with no outstanding correctness or repository-rule findings.

Summary

This PR closes several authentication and request-hardening gaps.

  • Enforces CSRF validation when session cookies accompany authorization headers.
  • Revokes all target-user sessions after credential changes and prevents in-flight requests from restoring revoked sessions.
  • Adds ownership enforcement to RetroAchievements refreshes and validates invite tokens before account uniqueness checks.
  • Makes invite consumption atomic, safely handles unrecognized password hashes, and removes invite secrets from logs.
  • Terminates 7-Zip option parsing before archive-controlled member names.
  • Restricts trusted forwarded headers to configured peers.
  • Changes since the previous review simplify the session invariant comment and reuse the canonical session-cookie constant in tests.

Reviews (3) · Last reviewed commit: "style: trim two comments and reuse the s..."

Comment thread backend/endpoints/user.py
Comment thread env.template Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Critical issues remain in session revocation, invite-token atomicity, and forwarded-IP trust.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 3 High severity · 4 Low severity

Open (7)
What changed in this PR

Hardens authentication, session handling, invite validation, archive extraction, and proxy IP handling.

Changes:

  • Tightens CSRF, password, invite-token, and ownership checks.
  • Improves session revocation and trusted forwarded-IP configuration.
  • Adds focused security regression tests.
File Description
env.template Documents forwarded-IP configuration.
docker/​init_scripts/​init Configures the trusted proxy allowlist.
backend/​utils/​archives.py Prevents 7-Zip member names from being parsed as options.
backend/​tests/​handler/​auth/​test_csrf_middleware.py Tests CSRF behavior with session cookies.
backend/​tests/​handler/​auth/​test_auth.py Tests invalid password-hash handling.
backend/​tests/​endpoints/​test_identity.py Tests RA-refresh ownership enforcement.
backend/​handler/​auth/​middleware/​csrf_middleware.py Enforces CSRF when auth headers accompany session cookies.
backend/​handler/​auth/​base_handler.py Hardens password verification and invite-token handling.
backend/​endpoints/​user.py Adds session revocation, ownership checks, and invite validation.

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread backend/endpoints/user.py
Comment thread backend/handler/auth/base_handler.py
Comment thread docker/init_scripts/init
Comment thread backend/endpoints/user.py
Comment thread backend/endpoints/user.py
Comment thread backend/utils/archives.py
Comment thread env.template Outdated
sdornan and others added 2 commits September 19, 2026 16:34
…xy guidance

Both from Greptile's review of this PR.

A session loaded before `clear_user_sessions` ran was re-persisted when
its request responded: the middleware writes any non-empty session back
unconditionally, so an attacker holding one in flight restored it and the
credential change locked nobody out. An existing record is now refreshed
only while it is still in Redis, and a request whose session went away
mid-flight has its cookie expired instead. A new session still writes
unconditionally, so login is unaffected.

The FORWARDED_ALLOW_IPS note told deployments behind a further reverse
proxy to set the variable to that proxy's address. Gunicorn's immediate
peer is always the bundled nginx on 127.0.0.1, so replacing it makes
uvicorn distrust nginx and ignore the forwarded chain entirely, leaving
every request on the loopback address and in one rate-limit bucket. The
upstream proxy is appended, not substituted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AZF3cyuYh96LK2h3j25DPC
From Copilot's review of this PR.

`consume_invite_link_token` read the jti and deleted it as two operations,
so two registrations racing on one invite could both see it valid and both
create an account. It now reads and invalidates with a single GETDEL,
matching how refresh tokens are already spent in this file. The
non-consuming check keeps a plain read, and both raise the same response so
neither distinguishes a spent invite from an unusable one.

Tests for the boundaries the review noted were uncovered:

- An admin resetting another user's password ends that user's sessions
  while leaving the admin's own usable. The existing test only changed the
  caller's own password, which the previous implementation already handled.
- Registration rejects a bad token without disclosing whether an account
  exists, and a registration rejected on a duplicate username leaves the
  invite usable, then spent once it registers one.
- The 7-Zip and bsdtar member commands put "--" before the member, for
  members shaped like "-x", "@ListFile" and "-so".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AZF3cyuYh96LK2h3j25DPC
Comment thread backend/handler/auth/middleware/redis_session_middleware.py Outdated
Comment thread backend/tests/handler/auth/test_redis_session_middleware.py
Comment thread backend/tests/handler/auth/test_redis_session_middleware.py
sdornan and others added 2 commits September 19, 2026 16:43
The new middleware test failed in CI, and the cause was in
clear_user_sessions rather than the test: `smembers` answers with bytes
on a client that does not decode, and the member went straight into an
f-string key name, so the delete targeted `session:b'<id>'` and the real
session stayed live. The write-back guard then found the key still there
and refreshed it, which is exactly what the test asserts must not happen.

Production is unaffected: `async_cache` is built with
`decode_responses=True` there, so members arrive as strings. Only the
pytest client, a bare `FakeAsyncRedis`, returns bytes, which is why this
surfaced the moment a test exercised the helper end to end.

Members are now decoded when they arrive as bytes, so the helper is
correct under either client, and the keys go in one delete call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AZF3cyuYh96LK2h3j25DPC
From Greptile's review. The write-back and key-decoding comments had
grown past the two lines the repo allows and were narrating the failure
they prevent; they now state the invariant only. The middleware test
imported SESSION_COOKIE_NAME rather than repeating its value, which would
have drifted if the cookie were ever renamed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AZF3cyuYh96LK2h3j25DPC

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

f"session:{sid.decode() if isinstance(sid, bytes) else sid}"
for sid in session_ids
]
await async_cache.delete(*keys, f"user_sessions:{user_id}")

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified, and you're right that the XX guard cannot cover this case. A /login that authenticated against the old password before the reset lands has existing_id is None, so it writes unconditionally — it has to, or no one could ever log in — and its SET plus SADD land after SMEMBERS has already been read. That session survives.

Two things about its shape, for whoever picks this up:

  • The precondition is that the attacker already holds valid old credentials and overlaps a login with the reset window. It is a genuine hole, not a theoretical one, but it is narrower than the case fixed in 6942651, which needed only any in-flight request.
  • Relative to master this is strictly an improvement rather than a regression: master has both this race and the much wider resurrection one. Nothing here makes revocation weaker than it was.

I'm not fixing it in this PR. Closing it properly means the per-user revocation generation you and Greptile both suggested — stamp sessions with an issue time, record a valid_from per user on revoke, and reject anything older at load. That subsumes the XX guard rather than sitting beside it, and it changes the session validity model and payload schema, which is a design decision for a maintainer rather than something I should bolt onto a PR that started as a set of small hardening fixes. I've raised it with the repo owner to sequence as a follow-up.


Generated by Claude Code

Comment thread backend/tests/endpoints/test_identity.py
The client kept the target's session cookie from its Basic login, and
HybridAuthBackend resolves a cookie ahead of the Authorization header, so
the reset authenticated as the target and took the self-update path. The
test passed without ever exercising an admin resetting another account.
The jar is cleared before the reset, leaving the bearer as the only
credential.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AZF3cyuYh96LK2h3j25DPC
@gantoine
gantoine self-requested a review September 19, 2026 17:41
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.

2 participants