Harden invite-accept: bind token to its org + rate-limit the endpointNeeds reviewkordra/node-4790$1.86 / $6.0029elevated

Harden invite-accept: bind token to its org + rate-limit the endpoint

Security review flagged that invite accept trusted a caller-supplied org and had no throttle. Bind to the invite's own org and rate-limit by IP.

GitHub · PR #482
Dispatched to fleet · Frontier · 3 workers, independent · cap $6.00 · runner ci-runner-02
Kordra Fleet
installnpm cipassed
migratenpm run migrate:testpassed
repronpm test -- routes/invites.crosstenant.repropassed
Best-of-3

Verified against the repro

2 of 3 candidates passed the security repro (replaying a token against a foreign org now 403s) and the suite. Selected Worker A (node-4790). This change touches auth + a migration — evidence is gathered, but blast radius is high; read it closely.

behavioral gateFrontier$1.86 of $6.002 files · +14 1Elevated
The changekordra/node-4790
services/kordra-hub/src/routes/invites.ts+8 1
@@ -41,10 +41,19 @@ export function registerInvites(app, pool) {
4141 app.post('/invites/:token/accept', async (c) => {
4242 const token = c.req.param('token');
43 // Token-authorized accept runs on the raw pool (bypasses RLS), so throttle by
44 // IP to blunt token-guessing before we ever touch the row (decision #88).
45 if (!(await allowAttempt(pool, clientIp(c)))) {
46 return c.json({ error: 'too many attempts, slow down' }, 429);
47 }
4348 const invite = await findInvite(pool, token);
4449 if (!invite || invite.expires_at < new Date()) {
4550 return c.json({ error: 'unknown or expired invite' }, 404);
4651 }
47 const org = c.req.query('org') ?? invite.organization_id;
52 // Bind the accept to the invite's OWN org — never a caller-supplied one, so a
53 // stolen token can't be replayed to join a different tenant.
54 const org = invite.organization_id;
4855 await addMember(pool, org, c.get('user').id, invite.role);
4956 return c.json({ ok: true, organization_id: org });
5057 });
5158
services/kordra-hub/migrations/20260701090000_invite_accept_throttle.sqldatabase migrationSQL+6 0
@@ -0,0 +1,7 @@
1CREATE TABLE invite_accept_attempt (
2 ip inet NOT NULL,
3 attempted_at timestamptz NOT NULL DEFAULT now()
4);
5CREATE INDEX invite_accept_attempt_ip_time
6 ON invite_accept_attempt (ip, attempted_at DESC);
07
Tests
repropassednpm test -- routes/invites.crosstenant.repro
testspassednpm test -- routes/invites
Groundinggrounded in 2 org decisions
▤ document
Security audit 2026-06 — machine plane

Finding H-3: invite accept accepted a caller-supplied ?org=, enabling cross-tenant join.

Proposed for the brainthe run wants 2 decisions remembered — your call

Rate-limit token-authorized accept endpoints to 10 attempts/min/IP

Concrete throttle for guessable-token endpoints, implementing the general rule in decision #88.

Token-authorized handlers must re-derive org from the record, never the request

Reaffirms decision #10 at the invite-accept boundary after finding a cross-tenant path.

You are the gate. Approving pushes kordra/node-4790 and opens a PR — and ratifies 2 proposed decisions. Never your default branch.
⌘⏎ to send · Enter for a new line