From 31c445a1ee56b21f9985541fcfa709e79d7185bd Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Thu, 5 Feb 2026 17:36:05 +0000 Subject: [PATCH] feat(auth): Enforce mandatory invite code validation on registration - Modified `POST /api/auth/register` to require `inviteCode`. - Added validation logic against `invitation_codes` table (existence, expiry, max uses). - Wrapped user creation and invite usage increment in a database transaction for data integrity. - Added regression test `backend/src/tests/routes/auth-invite.test.ts` to verify the fix and prevent regression. This fixes a critical vulnerability where the registration endpoint allowed bypassing the invite-only restriction. Co-authored-by: criptogus <128640021+criptogus@users.noreply.github.com> --- .Jules/sentinel.md | 5 + backend/src/routes/auth.ts | 51 ++++- backend/src/tests/routes/auth-invite.test.ts | 184 +++++++++++++++++++ 3 files changed, 230 insertions(+), 10 deletions(-) create mode 100644 backend/src/tests/routes/auth-invite.test.ts diff --git a/.Jules/sentinel.md b/.Jules/sentinel.md index f1766d577..fa3f2c617 100644 --- a/.Jules/sentinel.md +++ b/.Jules/sentinel.md @@ -17,3 +17,8 @@ **Vulnerability:** A Stored XSS vulnerability was identified in `src/components/preview/viewers/MarkdownViewer.tsx`. The component used a custom regex-based Markdown parser that failed to validate URL protocols in links, allowing payloads like `[click](javascript:alert(1))` to execute arbitrary JavaScript. **Learning:** Custom regex parsers for complex formats like Markdown are notoriously prone to security bypasses. Validating input structure via regex is insufficient for preventing XSS in HTML output; context-aware sanitization is required. **Prevention:** Integrated `DOMPurify` to sanitize the HTML output of the custom parser. Configured it to allow necessary tags and attributes (like `class` for styling) while stripping dangerous content (like `javascript:` URIs). + +## 2026-02-05 - [Auth Registration Bypass] +**Vulnerability:** The public registration endpoint (`POST /api/auth/register`) accepted an `inviteCode` parameter but failed to validate it against the database, effectively bypassing the invite-only restriction. Although the frontend UI hid the registration form, the API remained open to direct requests. +**Learning:** Security controls must be enforced on the backend, not just the frontend. The presence of a "Waiting List" UI on the frontend does not guarantee that the backend registration endpoint is disabled or protected. "Security by Obscurity" (hiding the form) is not security. +**Prevention:** Implemented mandatory server-side validation for `inviteCode` in `backend/src/routes/auth.ts` within a database transaction. The endpoint now verifies the code's existence, expiration, and usage limits before allowing user creation. diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 156aa094c..eefead948 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -1,5 +1,5 @@ import { Router, Response } from 'express'; -import { query, queryOne } from '../services/database'; +import { query, queryOne, transaction } from '../services/database'; import bcrypt from 'bcryptjs'; import jwt from 'jsonwebtoken'; import { authenticateToken, AuthRequest } from '../middleware/auth.js'; @@ -41,7 +41,7 @@ router.post('/register', asyncHandler(async (req, res: Response) => { throw new ValidationError(validationResult.error.errors[0]?.message || 'Dados inválidos'); } - const { email, password, full_name } = validationResult.data; + const { email, password, full_name, inviteCode } = validationResult.data; const existingUser = await queryOne( 'SELECT id FROM users WHERE email = $1', @@ -52,19 +52,50 @@ router.post('/register', asyncHandler(async (req, res: Response) => { throw new ValidationError('Email já cadastrado'); } - const hashedPassword = await bcrypt.hash(password, 10); + // 🛡️ SECURITY: Validate Invite Code + if (!inviteCode) { + throw new ValidationError('Código de convite é obrigatório'); + } - const user = await queryOne<{ id: string; email: string; full_name: string | null }>( - `INSERT INTO users (email, password_hash, full_name, created_at) - VALUES ($1, $2, $3, NOW()) - RETURNING id, email, full_name, created_at`, - [email, hashedPassword, full_name || null] + const invitation = await queryOne( + 'SELECT id, max_uses, current_uses, expires_at FROM invitation_codes WHERE code = $1', + [inviteCode] ); - if (!user) { - throw new Error('Falha ao criar usuário'); + if (!invitation) { + throw new ValidationError('Código de convite inválido'); + } + + if (invitation.expires_at && new Date(invitation.expires_at) < new Date()) { + throw new ValidationError('Código de convite expirado'); + } + + if (invitation.max_uses && invitation.current_uses >= invitation.max_uses) { + throw new ValidationError('Código de convite esgotado'); } + const hashedPassword = await bcrypt.hash(password, 10); + + const user = await transaction(async (client) => { + const userResult = await client.query( + `INSERT INTO users (email, password_hash, full_name, created_at) + VALUES ($1, $2, $3, NOW()) + RETURNING id, email, full_name, created_at`, + [email, hashedPassword, full_name || null] + ); + + const createdUser = userResult.rows[0]; + + if (!createdUser) { + throw new Error('Falha ao criar usuário'); + } + + // Increment invitation usage + await client.query('UPDATE invitation_codes SET current_uses = current_uses + 1 WHERE id = $1', [invitation.id]); + + return createdUser; + }); + const jwtSecret = process.env.JWT_SECRET; if (!jwtSecret) { throw new Error('JWT_SECRET não configurado'); diff --git a/backend/src/tests/routes/auth-invite.test.ts b/backend/src/tests/routes/auth-invite.test.ts new file mode 100644 index 000000000..a987dff3f --- /dev/null +++ b/backend/src/tests/routes/auth-invite.test.ts @@ -0,0 +1,184 @@ +import { jest } from '@jest/globals'; + +// Mock database service BEFORE importing routes +jest.unstable_mockModule('@/services/database', () => ({ + query: jest.fn(), + queryOne: jest.fn(), + transaction: jest.fn(), + pool: { connect: jest.fn() }, +})); + +// Mock bcryptjs to avoid CPU usage +jest.unstable_mockModule('bcryptjs', () => ({ + default: { + hash: jest.fn().mockResolvedValue('hashed_password'), + compare: jest.fn().mockResolvedValue(true), + }, +})); + +// Mock login attempts service to avoid side effects +jest.unstable_mockModule('@/services/login-attempts.service', () => ({ + isAccountLocked: jest.fn().mockReturnValue({ locked: false }), + recordFailedAttempt: jest.fn(), + recordSuccessfulLogin: jest.fn(), +})); + +const { queryOne, transaction } = await import('@/services/database'); +const request = (await import('supertest')).default; +const express = (await import('express')).default; +// Import the router +const { authRoutes } = await import('@/routes/auth'); +// Import error handler +const { errorHandler } = await import('@/middleware/errorHandler'); + +const app = express(); +app.use(express.json()); +app.use('/auth', authRoutes); +// Use error handler middleware +app.use(errorHandler); + +describe('Auth Registration Invite Code Protection', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should FAIL registration WITHOUT invite code', async () => { + // Mock email check (returns null = user does not exist) + (queryOne as jest.Mock).mockResolvedValueOnce(null); + + // Attempt registration without inviteCode + const res = await request(app) + .post('/auth/register') + .send({ + email: 'test@example.com', + password: 'password12345678', + full_name: 'Test User' + }); + + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/convite/i); + }); + + it('should FAIL registration with INVALID invite code', async () => { + // Mock email check (returns null = user does not exist) + (queryOne as jest.Mock).mockResolvedValueOnce(null); + + // Mock invite check (returns null = invalid code) + (queryOne as jest.Mock).mockResolvedValueOnce(null); + + const res = await request(app) + .post('/auth/register') + .send({ + email: 'test@example.com', + password: 'password12345678', + full_name: 'Test User', + inviteCode: 'INVALID' + }); + + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/inválido/i); + }); + + it('should FAIL registration with EXPIRED invite code', async () => { + // Mock email check + (queryOne as jest.Mock).mockResolvedValueOnce(null); + + // Mock invite check (expired) + (queryOne as jest.Mock).mockResolvedValueOnce({ + id: 'inv-1', + code: 'EXPIRED', + expires_at: new Date(Date.now() - 10000).toISOString(), // Expired + max_uses: 10, + current_uses: 0 + }); + + const res = await request(app) + .post('/auth/register') + .send({ + email: 'test@example.com', + password: 'password12345678', + full_name: 'Test User', + inviteCode: 'EXPIRED' + }); + + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/expirado/i); + }); + + it('should FAIL registration with EXHAUSTED invite code', async () => { + // Mock email check + (queryOne as jest.Mock).mockResolvedValueOnce(null); + + // Mock invite check (exhausted) + (queryOne as jest.Mock).mockResolvedValueOnce({ + id: 'inv-1', + code: 'FULL', + expires_at: null, + max_uses: 5, + current_uses: 5 // Full + }); + + const res = await request(app) + .post('/auth/register') + .send({ + email: 'test@example.com', + password: 'password12345678', + full_name: 'Test User', + inviteCode: 'FULL' + }); + + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/esgotado/i); + }); + + it('should SUCCEED registration with VALID invite code and increment usage', async () => { + // 1. Mock email check (null = ok) + (queryOne as jest.Mock).mockResolvedValueOnce(null); + + // 2. Mock invite check (valid) + const validInvite = { + id: 'inv-123', + code: 'VALID', + expires_at: null, + max_uses: 10, + current_uses: 0 + }; + (queryOne as jest.Mock).mockResolvedValueOnce(validInvite); + + // 3. Mock Transaction + const mockClient = { + query: jest.fn(), + release: jest.fn(), + }; + (transaction as jest.Mock).mockImplementation(async (handler: any) => handler(mockClient)); + + // 4. Mock Client Queries + // Call 1: Insert User + mockClient.query.mockResolvedValueOnce({ + rows: [{ + id: 'user-123', + email: 'test@example.com', + full_name: 'Test User' + }] + }); + // Call 2: Update Invite + mockClient.query.mockResolvedValueOnce({ rows: [] }); + + const res = await request(app) + .post('/auth/register') + .send({ + email: 'test@example.com', + password: 'password12345678', + full_name: 'Test User', + inviteCode: 'VALID' + }); + + expect(res.status).toBe(201); + expect(res.body).toHaveProperty('token'); + + // Verify invitation usage was updated via client + expect(mockClient.query).toHaveBeenCalledTimes(2); + expect(mockClient.query).toHaveBeenNthCalledWith(1, expect.stringMatching(/INSERT INTO users/i), expect.any(Array)); + expect(mockClient.query).toHaveBeenNthCalledWith(2, expect.stringMatching(/UPDATE invitation_codes/i), ['inv-123']); + }); +});