Skip to content

Commit ce4f44b

Browse files
mattwobertsclaude
andcommitted
fix(security): scope invite token verification to tenant
getVerificationByKey looked up email verification tokens by key + kind only, never checking the tenant. In a multi-tenant deployment an invite token issued for tenant A could be redeemed on tenant B, granting the attacker Member-level access to B (cross-tenant IDOR). Anchor the lookup to the request's tenant by adding `tenant_id` to the WHERE clause, mirroring the sibling getVerificationByEmailAndCode which already does this. A mismatched tenant now yields ErrNotFound and the redemption is rejected. Adds a regression test asserting a key saved on one tenant is not retrievable from another. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 279e9a9 commit ce4f44b

2 files changed

Lines changed: 30 additions & 2 deletions

File tree

app/services/sqlstore/postgres/tenant.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,8 +130,8 @@ func getVerificationByKey(ctx context.Context, q *query.GetVerificationByKey) er
130130
return using(ctx, func(trx *dbx.Trx, tenant *entity.Tenant, user *entity.User) error {
131131
verification := dbEntities.EmailVerification{}
132132

133-
query := "SELECT id, email, name, key, code, created_at, verified_at, expires_at, kind, user_id, attempts FROM email_verifications WHERE key = $1 AND kind = $2 LIMIT 1"
134-
err := trx.Get(&verification, query, q.Key, q.Kind)
133+
query := "SELECT id, email, name, key, code, created_at, verified_at, expires_at, kind, user_id, attempts FROM email_verifications WHERE key = $1 AND kind = $2 AND tenant_id = $3 LIMIT 1"
134+
err := trx.Get(&verification, query, q.Key, q.Kind, tenant.ID)
135135
if err != nil {
136136
return errors.Wrap(err, "failed to get email verification by its key")
137137
}

app/services/sqlstore/postgres/tenant_test.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,34 @@ func TestTenantStorage_SaveFindSet_ChangeEmailVerificationKey(t *testing.T) {
316316
Expect(getKey.Result.ExpiresAt).TemporarilySimilar(getKey.Result.CreatedAt.Add(15*time.Minute), 1*time.Second)
317317
}
318318

319+
func TestTenantStorage_VerificationKey_IsTenantScoped(t *testing.T) {
320+
SetupDatabaseTest(t)
321+
defer TeardownDatabaseTest()
322+
323+
//Save new Key on the demo tenant
324+
err := bus.Dispatch(demoTenantCtx, &cmd.SaveVerificationKey{
325+
Key: "s3cr3tk3y",
326+
Duration: 15 * time.Minute,
327+
Request: &actions.CreateTenant{
328+
Email: "jon.snow@got.com",
329+
Name: "Jon Snow",
330+
},
331+
})
332+
Expect(err).IsNil()
333+
334+
//Same key must NOT be retrievable from a different tenant
335+
getKeyFromOtherTenant := &query.GetVerificationByKey{Kind: enum.EmailVerificationKindSignUp, Key: "s3cr3tk3y"}
336+
err = bus.Dispatch(avengersTenantCtx, getKeyFromOtherTenant)
337+
Expect(errors.Cause(err)).Equals(app.ErrNotFound)
338+
Expect(getKeyFromOtherTenant.Result).IsNil()
339+
340+
//But it is retrievable from the tenant it belongs to
341+
getKey := &query.GetVerificationByKey{Kind: enum.EmailVerificationKindSignUp, Key: "s3cr3tk3y"}
342+
err = bus.Dispatch(demoTenantCtx, getKey)
343+
Expect(err).IsNil()
344+
Expect(getKey.Result.Email).Equals("jon.snow@got.com")
345+
}
346+
319347
func TestTenantStorage_FindUnknownVerificationKey(t *testing.T) {
320348
SetupDatabaseTest(t)
321349
defer TeardownDatabaseTest()

0 commit comments

Comments
 (0)