Skip to content

fix(security): deleting a user re-armed their spent invitation - #243

Open
Waiel5 wants to merge 2 commits into
choyiny:mainfrom
Waiel5:fix/spent-invite-rearm
Open

fix(security): deleting a user re-armed their spent invitation#243
Waiel5 wants to merge 2 commits into
choyiny:mainfrom
Waiel5:fix/spent-invite-rearm

Conversation

@Waiel5

@Waiel5 Waiel5 commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Independent of my other open PRs — branches from main, one commit.

The bug

invitations.used_by is declared ON DELETE SET NULL (migrations/0004_material_shiver_man.sql:12), and both redeem paths decide whether an invite is spent by testing that column:

if (invite.usedBy || expiresAt < new Date()) return c.json({ valid: false }, 200);  // invites-router.ts:62
if (invite.usedBy) return c.json({ error: "Invitation has already been used" }, 400);  // invites-router.ts:113

DELETE /api/admin/users/{id} issues a bare DELETE FROM users, so the foreign key nulls used_by and the invitation the deleted user signed up with becomes valid again.

Why it is reachable

Every precondition is already true:

  • The token is still sitting in whatever inbox it was originally mailed to.
  • POST /api/invites/accept is in isUnauthenticatedPath — no session, no API key, no rate limit.
  • The invitation carries the role it was minted with, so an admin invite re-opens as an admin signup link.
  • The window is whatever is left of the original expiry, up to 30 days.

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_by so 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 making used_by ON DELETE RESTRICT and 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:

× does not let the spent token create a new admin account afterwards

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.

Test Files  3 passed (3)
     Tests  35 passed (35)

(this suite, admin-router, invites-router.) yarn tsc --noEmit clean.

Related, not fixed here

While reading this path I also noticed PATCH /api/admin/users/{id}/role has 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.

Waiel5 added 2 commits August 5, 2026 23:19
`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`.
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