fix(security): deleting a user re-armed their spent invitation - #243
Open
Waiel5 wants to merge 2 commits into
Open
fix(security): deleting a user re-armed their spent invitation#243Waiel5 wants to merge 2 commits into
Waiel5 wants to merge 2 commits into
Conversation
`invitations.used_by` is declared ON DELETE SET NULL (migration 0004),
and both redeem paths test `if (invite.usedBy)` to decide whether an
invite has been consumed. `DELETE /api/admin/users/{id}` issues a bare
`DELETE FROM users`, so the foreign key nulls that column and the invite
the deleted user signed up with becomes valid again.
Everything needed to exploit it is already in place. The token is sitting
in whatever inbox it was originally mailed to. `POST /api/invites/accept`
is in `isUnauthenticatedPath` — no session, no key, no rate limit. And
the invitation carries the role it was minted with, so removing an admin
re-opens an admin-role signup link for whatever is left of the original
expiry, up to thirty days.
The shape of it is what makes it bad: an operator removes someone's
access precisely when they want that access gone, and this hands part of
it back at exactly that moment, silently, to the person they just
removed.
Redeemed invitations are now deleted along with their user. Deleted
rather than tombstoned because the schema has nowhere to record "spent
but its user is gone" — the only marker of consumption *is* the foreign
key the cascade nulls. A spent invite that outlives its user is an open
credential, and keeping it for audit is not worth that.
Five tests. Four fail without the fix, including the one that matters:
the public accept endpoint is called with the resurrected token and a new
admin account is created. The fifth is the negative control — an invite
nobody has redeemed must survive an unrelated user being deleted — and it
passes either way, as it should. Other users' spent invitations are also
asserted untouched, since the delete is scoped by `used_by`.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Independent of my other open PRs — branches from
main, one commit.The bug
invitations.used_byis declaredON DELETE SET NULL(migrations/0004_material_shiver_man.sql:12), and both redeem paths decide whether an invite is spent by testing that column:DELETE /api/admin/users/{id}issues a bareDELETE FROM users, so the foreign key nullsused_byand the invitation the deleted user signed up with becomes valid again.Why it is reachable
Every precondition is already true:
POST /api/invites/acceptis inisUnauthenticatedPath— no session, no API key, no rate limit.roleit was minted with, so an admin invite re-opens as an admin signup link.The shape is what makes it worth fixing rather than noting: an operator removes someone precisely when they want that access gone, and this hands part of it back at exactly that moment — silently, and to the person just removed, who is the one most likely to still have the link.
The fix
Delete the user's redeemed invitations before deleting the user, scoped by
used_byso unredeemed invites and other users' invites are untouched.Deleted rather than tombstoned because the schema has nowhere to record "spent, but its user is gone" — the only marker of consumption is the foreign key the cascade nulls. A spent invite that outlives its user is an open credential, and keeping the row for audit is not worth that. If you would rather preserve the audit trail, the alternative is a migration adding a
revoked_at(or makingused_byON DELETE RESTRICTand cleaning up explicitly) — happy to redo it that way, but that is a schema change and this is not.Tests
worker/src/__tests__/spent-invite-rearm.test.ts, 5 tests. 4 fail without the fix, including the one that matters:That test calls the public accept endpoint with the resurrected token and asserts no user is created. Without the fix it returns 200 and mints a fresh admin.
The 5th — an invite nobody has redeemed must survive an unrelated user being deleted — passes either way, as a negative control should.
(this suite,
admin-router,invites-router.)yarn tsc --noEmitclean.Related, not fixed here
While reading this path I also noticed
PATCH /api/admin/users/{id}/rolehas no last-admin guard, and combined with the existing self-edit block ("Cannot change your own role") an instance can be left with no reachable admin. Separate concern, separate PR — say the word if you want it.