From 1c2796b978a5ee1fff6b6cecddb11d4a9f1cba14 Mon Sep 17 00:00:00 2001 From: Lailson Henrique Date: Thu, 3 Sep 2026 23:27:49 -0300 Subject: [PATCH] =?UTF-8?q?=F0=9F=93=AE=20fix:=20Validate=20Invite=20Email?= =?UTF-8?q?=20Claims=20(#15542)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🔒 fix: Require the email getInvite claims to validate `findToken` builds its query from the fields it is given, so an absent email is a lookup by token alone: the invite matches, `checkInviteUser` deletes it, and the registration then fails on the missing field. The invite is consumed and no account is created. * 🎨 style: Sort spec imports by line length --------- Co-authored-by: lailson henrique Co-authored-by: Danny Avila --- packages/api/src/auth/invite.spec.ts | 51 ++++++++++++++++++++++++++++ packages/api/src/auth/invite.ts | 7 ++++ 2 files changed, 58 insertions(+) create mode 100644 packages/api/src/auth/invite.spec.ts diff --git a/packages/api/src/auth/invite.spec.ts b/packages/api/src/auth/invite.spec.ts new file mode 100644 index 0000000000..6acd59e821 --- /dev/null +++ b/packages/api/src/auth/invite.spec.ts @@ -0,0 +1,51 @@ +import mongoose from 'mongoose'; +import { MongoMemoryServer } from 'mongodb-memory-server'; +import { createMethods, createModels } from '@librechat/data-schemas'; +import { createInvite, getInvite } from './invite'; + +let mongoServer: MongoMemoryServer; +let deps: Parameters[2]; + +beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); + createModels(mongoose); + + const { createToken, findToken } = createMethods(mongoose); + deps = { createToken, findToken } as Parameters[2]; +}); + +afterAll(async () => { + await mongoose.disconnect(); + await mongoServer.stop(); +}); + +beforeEach(async () => { + await mongoose.models.Token.deleteMany({}); +}); + +describe('getInvite', () => { + it('returns the invite for the address it was issued to', async () => { + const token = (await createInvite('pedro@example.com', deps)) as string; + + await expect(getInvite(token, 'pedro@example.com', deps)).resolves.toMatchObject({ + email: 'pedro@example.com', + }); + }); + + it('refuses an address the invite was not issued to', async () => { + const token = (await createInvite('pedro@example.com', deps)) as string; + + await expect(getInvite(token, 'someone.else@example.com', deps)).resolves.toMatchObject({ + error: true, + }); + }); + + it('refuses a lookup carrying no address at all', async () => { + const token = (await createInvite('pedro@example.com', deps)) as string; + + await expect(getInvite(token, undefined as unknown as string, deps)).resolves.toMatchObject({ + error: true, + }); + }); +}); diff --git a/packages/api/src/auth/invite.ts b/packages/api/src/auth/invite.ts index 19e1e54b46..2806b8cdd8 100644 --- a/packages/api/src/auth/invite.ts +++ b/packages/api/src/auth/invite.ts @@ -45,6 +45,13 @@ export async function getInvite( deps: InviteDeps, ): Promise { try { + /** `findToken` builds its query from the fields it is given, so an absent email + * is a lookup by token alone — which would match the invite and let the caller + * consume it without ever proving the address. */ + if (!email) { + throw new Error('Invite not found or email does not match'); + } + const token = decodeURIComponent(encodedToken); const hash = await hashToken(token); const invite = await deps.findToken({ token: hash, email });