Repository navigation
[Fix] Invite link allows multiple password resets - #22462
Conversation
Moves is_accepted=True from GET /onboarding/get_token to POST /onboarding/claim_token, so the flag accurately reflects that a password has been set. Both endpoints now reject already-used links, with get_token rejecting before any user data is returned. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Greptile SummaryThis PR fixes a security vulnerability where invite links could be reused indefinitely to overwrite a user's password. The root cause was that
Confidence Score: 4/5
|
| Filename | Overview |
|---|---|
| litellm/proxy/proxy_server.py | Security fix: moves is_accepted=True from GET to POST endpoint so invite links can only set a password once. Minor TOCTOU race window exists between check and update, and the GET endpoint docstring is stale. |
| tests/test_litellm/proxy/auth/test_onboarding.py | New test file with 8 well-structured mock-only unit tests covering both endpoints for rejection of used/expired/missing links, user-id mismatch, and correct flag-setting order. No real network calls. |
Sequence Diagram
sequenceDiagram
participant U as User (Browser)
participant GET as GET /onboarding/get_token
participant POST as POST /onboarding/claim_token
participant DB as Database
U->>GET: Visit invite link
GET->>DB: Find invite by ID
DB-->>GET: invite_obj
GET->>GET: Check expired? Check is_accepted?
alt is_accepted = True
GET-->>U: 401 "already been used"
else valid & unused
GET->>DB: Find user by user_id
DB-->>GET: user_obj
GET-->>U: {token, login_url, user_email}
end
U->>POST: Submit password (invitation_link, user_id, password)
POST->>DB: Find invite by ID
DB-->>POST: invite_obj
POST->>POST: Check expired? Check is_accepted? Check user_id match?
alt is_accepted = True
POST-->>U: 401 "already been used"
else valid & unused
POST->>DB: Update user password (hashed)
DB-->>POST: user_obj
POST->>DB: Set is_accepted = True on invite
POST-->>U: user_obj (success)
end
Last reviewed commit: c2e7cf1
| @@ -10976,6 +10962,18 @@ async def claim_onboarding_link(data: InvitationClaim): | |||
| status_code=401, detail={"error": "User does not exist in db."} | |||
| ) | |||
|
|
|||
| #### MARK LINK AS USED | |||
| current_time = litellm.utils.get_utc_datetime() | |||
| await prisma_client.db.litellm_invitationlink.update( | |||
| where={"id": data.invitation_link}, | |||
| data={ | |||
| "accepted_at": current_time, | |||
| "updated_at": current_time, | |||
| "is_accepted": True, | |||
| "updated_by": invite_obj.user_id, # type: ignore | |||
| }, | |||
| ) | |||
There was a problem hiding this comment.
TOCTOU race on is_accepted flag
There is a time-of-check-time-of-use window between reading is_accepted (line 10938) and setting it to True (line 10967–10975). Because there are multiple await points in between (the user table update on line 10956), two concurrent POST /onboarding/claim_token requests for the same invite link could both pass the is_accepted check and both successfully overwrite the password.
This is a significant improvement over the previous unlimited-reuse bug, but for a security-critical path you could tighten this further by marking the link as used before writing the password (and rolling back if the password write fails), or by using an atomic conditional update like:
# Atomically set is_accepted = True only if currently False
result = await prisma_client.db.litellm_invitationlink.update_many(
where={"id": data.invitation_link, "is_accepted": False},
data={"is_accepted": True, "accepted_at": current_time, ...},
)
if result.count == 0:
raise HTTPException(status_code=401, detail={"error": "Invitation link has already been used."})This eliminates the race window entirely.
Additional Comments (1)
The docstring still says "Invalidate the link (prevents abuse)", but this endpoint no longer invalidates the link — that responsibility moved to |
…se_fix [Fix] Invite link allows multiple password resets
Fixes #13906
Summary
Failure Path (Before Fix)
Invite links could be reused indefinitely to overwrite a user's password. The
GET /onboarding/get_tokenendpoint setis_accepted = Truewhen the link was first visited, butPOST /onboarding/claim_tokentreatedis_accepted = Trueas the allow condition rather than a block condition. Since the flag was never reset after a password was set, any subsequent visit to the same link allowed a new password to be submitted, invalidating the previous one.Fix
Moved
is_accepted = TruefromGET /onboarding/get_tokentoPOST /onboarding/claim_token, so the flag now accurately means "a password has been set via this link." Both endpoints check the flag and reject the request with 401 if it is alreadyTrue. TheGETendpoint rejects before querying the user table, so no user data (email, role) is ever returned for an already-used link.Testing
Added
tests/test_litellm/proxy/auth/test_onboarding.pywith 8 unit tests covering:GET /onboarding/get_token: rejects already-used links (401 before user data is read), expired links, and missing links; confirmsis_acceptedis not written on valid first visitPOST /onboarding/claim_token: rejects already-used links (password never written), expired links, mismatched user IDs; confirms password is written andis_accepted = Trueis set only after a successful first claimType
🐛 Bug Fix
✅ Test