Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .Jules/sentinel.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
51 changes: 41 additions & 10 deletions backend/src/routes/auth.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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',
Expand All @@ -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]);
Comment on lines +93 to +94

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Enforce invite usage limit atomically

The invite limit is validated before the transaction and the update here is unconditional, so two concurrent registrations can both pass the pre-check and then increment current_uses, allowing more users than max_uses. This shows up when an invite has only one remaining use (or is at its limit) and two requests arrive close together. Consider moving the invite lookup into the same transaction with SELECT ... FOR UPDATE, or make this UPDATE conditional on current_uses < max_uses and check the affected row count before completing registration.

Useful? React with 👍 / 👎.


return createdUser;
});

const jwtSecret = process.env.JWT_SECRET;
if (!jwtSecret) {
throw new Error('JWT_SECRET não configurado');
Expand Down
184 changes: 184 additions & 0 deletions backend/src/tests/routes/auth-invite.test.ts
Original file line number Diff line number Diff line change
@@ -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']);
});
});
Loading