
Proof-of-concept lab demonstrating a critical Lago GraphQL acceptInvite authentication bypass that enables cross-organization account takeover via unauthenticated invite acceptance.
abraxaslabs.tech · github.com/abraxas · @abraxas_null · [email protected] · lago-invite-ato
Lago v1.53.0 - GetLago
Mutations::Invites::Accept has no AuthenticableApiUser. Create/revoke do. Accept does not. register_from_invite loads the user by invite email. If that account already has memberships, the posted password is discarded. AuthToken.encode(user:) still runs. The JWT is user-scoped. No org claim. The client sends x-lago-organization for whichever org that user already belongs to. Including the victim's. createInvite already returns the token to the inviter.
Invite cfo@victim into a throwaway org, accept it yourself, and you are that person on every org that email already sits in. Mailbox never involved. Victim password unchanged.
| ID | no CVE yet |
| CWE | CWE-287, CWE-306 |
| CVSS | Critical: 9.6 CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:N |
| Product | Lago / lago-api |
| Affected | through v1.53.0 email/password acceptInvite |
| Auth | org admin (organization:members:create); Cloud signup is enough |
| License | GNU Affero GPL v3.0 |
| Lab | 127.0.0.1 only |
You need an org you control. Cloud signup is enough (LAGO_DISABLE_SIGNUP defaults false). Invite a victim email, accept unauthenticated with a dummy password, then header-switch to Victim Corp. Invoices, API keys, Stripe, wallets.
Not a random visitor with no account. Not a password reset. Dummy login fails. Real victim password still works. Google/Okta accept binds IdP email and is not this bug. Single-org self-host with nobody else's email in the DB does not give you another merchant. Same database, two orgs, same email: it does.
Same tree as the Stripe one-time invoice IDOR. Different bug.
I read Accept (no AuthenticableApiUser), then find_or_initialize_by(email:), then the memberships-present branch that skips the password, then AuthToken.encode(user:). Then Google accept, which does check google_oidc["email"] == invite.email. Email/password accept does not bind the password of an existing user.
The first client that looks at this will invite an unused address, accept it, and get a brand-new Lago user with the password they posted. That is the feature. The witness is the existing victim user id coming back.
Wrong turns already recorded: accepting with Google/Okta; treating createInvite 401 without a JWT as the whole story (create is authenticated, accept is not); looking only at the attacker org on currentUser (the tell is Victim Corp in the list); dummy password logging in (that would mean the hash was overwritten - lab asserts it was not); a reverse shell. Theatre. No inbox.
Then: two registerUser calls plant Victim Corp and Attacker Corp. Fresh emails each run so a dirty volume does not 422 user_already_exists. createInvite on the victim email. Unauthenticated acceptInvite with a dummy password. Returned user id matches the victim. currentUser.organizations lists both. Header switch returns Victim Corp.
cd lab
./run.sh
Target only http://127.0.0.1:13000. Stripe IDOR used 13001. No UI port.
IOC victim_password_unchanged
IOC victim_real_password_still_works
SUCCESS LAGO-INVITE-ATO acceptInvite JWT is existing victim; lists Victim Corp; no inbox
If the user already exists, require they authenticate as themselves (or prove mailbox / IdP) before attaching the membership. Do not mint a JWT for an existing user from unauthenticated accept. Stop returning invite token to the inviter if it is a capability.
accept.rb · accept_service.rb · users_service.rb · create.rb · object.rb · google_service.rbGNU Affero GPL v3.0. See LICENSE. Loopback lab only. No warranty.