feat: adiciona testes e melhorias de segurança

- Adiciona testes para auth service (createToken, createTokenPair, logout, refreshAccessToken)
- Adiciona testes para rate-limiting guard
- Adiciona testes para jwt strategy
- Remove arquivos SDK obsoletos
- Melhora validações e tratamento de erros em vários serviços
This commit is contained in:
joelson brito
2025-11-07 10:47:42 -03:00
parent a6cf4893cc
commit de4465ed60
23 changed files with 6209 additions and 4530 deletions

View File

@@ -0,0 +1,606 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ExecutionContext, HttpException, HttpStatus } from '@nestjs/common';
import { RateLimitingGuard } from '../rate-limiting.guard';
import { RateLimitingService } from '../../services/rate-limiting.service';
describe('RateLimitingGuard - Tests that expose problems', () => {
let guard: RateLimitingGuard;
let rateLimitingService: RateLimitingService;
let mockExecutionContext: ExecutionContext;
let mockGetRequest: jest.Mock;
const mockRateLimitingService = {
isAllowed: jest.fn(),
getAttemptInfo: jest.fn(),
recordAttempt: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
RateLimitingGuard,
{
provide: RateLimitingService,
useValue: mockRateLimitingService,
},
],
}).compile();
guard = module.get<RateLimitingGuard>(RateLimitingGuard);
rateLimitingService = module.get<RateLimitingService>(RateLimitingService);
mockGetRequest = jest.fn().mockReturnValue({
headers: {},
connection: {},
socket: {},
ip: '127.0.0.1',
});
mockExecutionContext = {
switchToHttp: jest.fn().mockReturnValue({
getRequest: mockGetRequest,
}),
} as any;
});
afterEach(() => {
jest.clearAllMocks();
});
describe('canActivate', () => {
/**
* NOTA: Estes testes identificam problemas no método canActivate.
*
* PROBLEMAS IDENTIFICADOS:
* 1. Não valida se IP extraído é válido
* 2. Não valida se rate limiting service retorna dados válidos
* 3. Não trata erros do rate limiting service
* 4. Vulnerável a manipulação de headers
* 5. Não valida formato de IP
* 6. Não trata casos onde getAttemptInfo falha
* 7. Aceita IPs inválidos ou maliciosos
*/
it('should reject when IP is empty string', async () => {
/**
* Cenário: IP extraído é string vazia.
* Problema: Não valida antes de processar.
* Solução esperada: Rejeitar com erro claro.
*/
const request = {
headers: {},
connection: {},
socket: {},
ip: '',
};
mockGetRequest.mockReturnValue(request);
mockRateLimitingService.isAllowed.mockResolvedValue(false);
try {
await guard.canActivate(mockExecutionContext);
fail('Deveria ter lançado exceção');
} catch (error) {
expect(error).toBeInstanceOf(HttpException);
const response = (error as HttpException).getResponse() as any;
expect(response.error).toBe('IP inválido ou não fornecido');
}
});
it('should reject when IP is null', async () => {
const request = {
headers: {},
connection: { remoteAddress: null },
socket: {},
ip: null,
};
mockGetRequest.mockReturnValue(request);
mockRateLimitingService.isAllowed.mockResolvedValue(false);
try {
await guard.canActivate(mockExecutionContext);
fail('Deveria ter lançado exceção');
} catch (error) {
expect(error).toBeInstanceOf(HttpException);
const response = (error as HttpException).getResponse() as any;
expect(response.error).toBe('IP inválido ou não fornecido');
}
});
it('should reject when IP is undefined', async () => {
const request = {
headers: {},
connection: {},
socket: {},
};
mockGetRequest.mockReturnValue(request);
mockRateLimitingService.isAllowed.mockResolvedValue(false);
try {
await guard.canActivate(mockExecutionContext);
fail('Deveria ter lançado exceção');
} catch (error) {
expect(error).toBeInstanceOf(HttpException);
const response = (error as HttpException).getResponse() as any;
expect(response.error).toBe('IP inválido ou não fornecido');
}
});
it('should validate IP format', async () => {
/**
* Cenário: IP com formato inválido.
* Problema: Aceita qualquer string como IP.
* Solução esperada: Validar formato de IP.
*/
const request = {
headers: {},
connection: {},
socket: {},
ip: 'invalid-ip-format',
};
mockGetRequest.mockReturnValue(request);
mockRateLimitingService.isAllowed.mockResolvedValue(false);
try {
await guard.canActivate(mockExecutionContext);
fail('Deveria ter lançado exceção');
} catch (error) {
expect(error).toBeInstanceOf(HttpException);
const response = (error as HttpException).getResponse() as any;
expect(response.error).toBe('Formato de IP inválido');
}
});
it('should reject malicious IP injection in headers', async () => {
/**
* Cenário: Tentativa de injeção através do header x-forwarded-for.
* Problema: Não sanitiza entrada.
* Solução esperada: Validar e sanitizar IP.
*/
const request = {
headers: {
'x-forwarded-for': "'; DROP TABLE users; --",
},
connection: {},
socket: {},
};
mockGetRequest.mockReturnValue(request);
mockRateLimitingService.isAllowed.mockResolvedValue(false);
try {
await guard.canActivate(mockExecutionContext);
fail('Deveria ter lançado exceção');
} catch (error) {
expect(error).toBeInstanceOf(HttpException);
const response = (error as HttpException).getResponse() as any;
expect(response.error).toBe('Formato de IP inválido');
}
});
it('should handle error when isAllowed throws exception', async () => {
/**
* Cenário: Rate limiting service lança erro.
* Problema: Não trata erros do serviço.
* Solução esperada: Tratar erro graciosamente.
*/
const request = {
headers: {},
connection: {},
socket: {},
ip: '192.168.1.1',
};
mockGetRequest.mockReturnValue(request);
mockRateLimitingService.isAllowed.mockRejectedValue(
new Error('Erro de conexão com Redis')
);
try {
await guard.canActivate(mockExecutionContext);
fail('Deveria ter lançado exceção');
} catch (error) {
expect(error).toBeInstanceOf(HttpException);
const response = (error as HttpException).getResponse() as any;
expect(response.error).toBe('Erro ao verificar rate limit');
}
});
it('should handle error when getAttemptInfo throws exception', async () => {
/**
* Cenário: getAttemptInfo falha ao buscar informações.
* Problema: Não trata erro ao buscar informações de tentativas.
* Solução esperada: Tratar erro ou usar valores padrão.
*/
const request = {
headers: {},
connection: {},
socket: {},
ip: '192.168.1.1',
};
mockGetRequest.mockReturnValue(request);
mockRateLimitingService.isAllowed.mockResolvedValue(false);
mockRateLimitingService.getAttemptInfo.mockRejectedValue(
new Error('Erro ao buscar informações')
);
try {
await guard.canActivate(mockExecutionContext);
fail('Deveria ter lançado exceção');
} catch (error) {
expect(error).toBeInstanceOf(HttpException);
const response = (error as HttpException).getResponse() as any;
expect(response.error).toBe('Erro ao buscar informações de tentativas');
}
});
it('should validate attemptInfo structure', async () => {
/**
* Cenário: getAttemptInfo retorna dados inválidos.
* Problema: Não valida estrutura dos dados retornados.
* Solução esperada: Validar antes de usar.
*/
const request = {
headers: {},
connection: {},
socket: {},
ip: '192.168.1.1',
};
mockGetRequest.mockReturnValue(request);
mockRateLimitingService.isAllowed.mockResolvedValue(false);
mockRateLimitingService.getAttemptInfo.mockResolvedValue(null);
try {
await guard.canActivate(mockExecutionContext);
fail('Deveria ter lançado exceção');
} catch (error) {
expect(error).toBeInstanceOf(HttpException);
const response = (error as HttpException).getResponse() as any;
expect(response.error).toBe('Dados de tentativas inválidos');
}
});
it('should validate attemptInfo has required fields', async () => {
const request = {
headers: {},
connection: {},
socket: {},
ip: '192.168.1.1',
};
mockGetRequest.mockReturnValue(request);
mockRateLimitingService.isAllowed.mockResolvedValue(false);
mockRateLimitingService.getAttemptInfo.mockResolvedValue({
attempts: undefined,
});
try {
await guard.canActivate(mockExecutionContext);
fail('Deveria ter lançado exceção');
} catch (error) {
expect(error).toBeInstanceOf(HttpException);
const response = (error as HttpException).getResponse() as any;
expect(response.error).toBe('Dados de tentativas inválidos');
}
});
it('should reject extremely long IP addresses (DoS prevention)', async () => {
/**
* Cenário: IP muito longo (ataque DoS).
* Problema: Pode causar problemas de memória/performance.
* Solução esperada: Limitar tamanho do IP.
*/
const request = {
headers: {
'x-forwarded-for': 'a'.repeat(10000),
},
connection: {},
socket: {},
};
mockGetRequest.mockReturnValue(request);
mockRateLimitingService.isAllowed.mockResolvedValue(false);
try {
await guard.canActivate(mockExecutionContext);
fail('Deveria ter lançado exceção');
} catch (error) {
expect(error).toBeInstanceOf(HttpException);
const response = (error as HttpException).getResponse() as any;
expect(response.error).toBe('IP muito longo');
}
});
it('should sanitize IP from x-forwarded-for header', async () => {
/**
* Cenário: Header x-forwarded-for com múltiplos IPs.
* Problema: Pode usar IP incorreto ou malicioso.
* Solução esperada: Validar e sanitizar primeiro IP.
*/
const request = {
headers: {
'x-forwarded-for': '192.168.1.1, 10.0.0.1, malicious-ip',
},
connection: {},
socket: {},
};
mockGetRequest.mockReturnValue(request);
mockRateLimitingService.isAllowed.mockResolvedValue(true);
const result = await guard.canActivate(mockExecutionContext);
expect(result).toBe(true);
expect(mockRateLimitingService.isAllowed).toHaveBeenCalledWith('192.168.1.1');
});
it('should handle concurrent requests with same IP', async () => {
/**
* Cenário: Múltiplas requisições simultâneas do mesmo IP.
* Problema: Pode causar race conditions.
* Solução esperada: Sincronizar ou garantir atomicidade.
*/
const request = {
headers: {},
connection: {},
socket: {},
ip: '192.168.1.1',
};
mockGetRequest.mockReturnValue(request);
mockRateLimitingService.isAllowed.mockResolvedValue(true);
const promises = [
guard.canActivate(mockExecutionContext),
guard.canActivate(mockExecutionContext),
guard.canActivate(mockExecutionContext),
];
const results = await Promise.all(promises);
results.forEach(result => {
expect(result).toBe(true);
});
});
it('should return correct HTTP status code when rate limited', async () => {
/**
* Cenário: Rate limit excedido.
* Problema: Pode retornar status code incorreto.
* Solução esperada: Sempre retornar 429 TOO_MANY_REQUESTS.
*/
const request = {
headers: {},
connection: {},
socket: {},
ip: '192.168.1.1',
};
mockGetRequest.mockReturnValue(request);
mockRateLimitingService.isAllowed.mockResolvedValue(false);
mockRateLimitingService.getAttemptInfo.mockResolvedValue({
attempts: 16,
isBlocked: true,
remainingTime: 60,
});
try {
await guard.canActivate(mockExecutionContext);
fail('Deveria ter lançado exceção');
} catch (error) {
expect(error).toBeInstanceOf(HttpException);
expect((error as HttpException).getStatus()).toBe(HttpStatus.TOO_MANY_REQUESTS);
}
});
it('should include correct error message when rate limited', async () => {
const request = {
headers: {},
connection: {},
socket: {},
ip: '192.168.1.1',
};
mockGetRequest.mockReturnValue(request);
mockRateLimitingService.isAllowed.mockResolvedValue(false);
mockRateLimitingService.getAttemptInfo.mockResolvedValue({
attempts: 16,
isBlocked: true,
remainingTime: 60,
});
try {
await guard.canActivate(mockExecutionContext);
fail('Deveria ter lançado exceção');
} catch (error) {
const response = (error as HttpException).getResponse() as any;
expect(response.error).toBe('Muitas tentativas de login. Tente novamente em alguns minutos.');
expect(response.success).toBe(false);
}
});
it('should include attempt details in error response', async () => {
const request = {
headers: {},
connection: {},
socket: {},
ip: '192.168.1.1',
};
mockGetRequest.mockReturnValue(request);
mockRateLimitingService.isAllowed.mockResolvedValue(false);
mockRateLimitingService.getAttemptInfo.mockResolvedValue({
attempts: 16,
isBlocked: true,
remainingTime: 60,
});
try {
await guard.canActivate(mockExecutionContext);
fail('Deveria ter lançado exceção');
} catch (error) {
const response = (error as HttpException).getResponse() as any;
expect(response.details).toHaveProperty('attempts', 16);
expect(response.details).toHaveProperty('remainingTime', 60);
}
});
it('should allow request when isAllowed returns true', async () => {
const request = {
headers: {},
connection: {},
socket: {},
ip: '192.168.1.1',
};
mockGetRequest.mockReturnValue(request);
mockRateLimitingService.isAllowed.mockResolvedValue(true);
const result = await guard.canActivate(mockExecutionContext);
expect(result).toBe(true);
expect(mockRateLimitingService.getAttemptInfo).not.toHaveBeenCalled();
});
it('should validate remainingTime is a positive number when present', async () => {
const request = {
headers: {},
connection: {},
socket: {},
ip: '192.168.1.1',
};
mockGetRequest.mockReturnValue(request);
mockRateLimitingService.isAllowed.mockResolvedValue(false);
mockRateLimitingService.getAttemptInfo.mockResolvedValue({
attempts: 16,
isBlocked: true,
remainingTime: -1,
});
try {
await guard.canActivate(mockExecutionContext);
fail('Deveria ter lançado exceção');
} catch (error) {
expect(error).toBeInstanceOf(HttpException);
const response = (error as HttpException).getResponse() as any;
expect(response.error).toBe('Tempo restante inválido');
}
});
it('should handle IPv6 addresses', async () => {
/**
* Cenário: IP IPv6 válido.
* Problema: Pode não validar IPv6 corretamente.
* Solução esperada: Aceitar IPv6 válido.
*/
const request = {
headers: {},
connection: {},
socket: {},
ip: '2001:0db8:85a3:0000:0000:8a2e:0370:7334',
};
mockGetRequest.mockReturnValue(request);
mockRateLimitingService.isAllowed.mockResolvedValue(true);
const result = await guard.canActivate(mockExecutionContext);
expect(result).toBe(true);
expect(mockRateLimitingService.isAllowed).toHaveBeenCalledWith('2001:0db8:85a3:0000:0000:8a2e:0370:7334');
});
it('should reject invalid IPv6 format', async () => {
const request = {
headers: {},
connection: {},
socket: {},
ip: '2001:0db8:invalid:ip',
};
mockGetRequest.mockReturnValue(request);
mockRateLimitingService.isAllowed.mockResolvedValue(false);
try {
await guard.canActivate(mockExecutionContext);
fail('Deveria ter lançado exceção');
} catch (error) {
expect(error).toBeInstanceOf(HttpException);
const response = (error as HttpException).getResponse() as any;
expect(response.error).toBe('Formato de IP inválido');
}
});
it('should prioritize x-forwarded-for over other sources', async () => {
/**
* Cenário: Múltiplas fontes de IP disponíveis.
* Problema: Pode usar IP incorreto.
* Solução esperada: Priorizar x-forwarded-for quando presente.
*/
const request = {
headers: {
'x-forwarded-for': '192.168.1.1',
},
connection: { remoteAddress: '10.0.0.1' },
socket: { remoteAddress: '172.16.0.1' },
ip: '127.0.0.1',
};
mockGetRequest.mockReturnValue(request);
mockRateLimitingService.isAllowed.mockResolvedValue(true);
await guard.canActivate(mockExecutionContext);
expect(mockRateLimitingService.isAllowed).toHaveBeenCalledWith('192.168.1.1');
});
it('should fallback to connection.remoteAddress when x-forwarded-for is missing', async () => {
const request = {
headers: {},
connection: { remoteAddress: '10.0.0.1' },
socket: {},
ip: '127.0.0.1',
};
mockGetRequest.mockReturnValue(request);
mockRateLimitingService.isAllowed.mockResolvedValue(true);
await guard.canActivate(mockExecutionContext);
expect(mockRateLimitingService.isAllowed).toHaveBeenCalledWith('10.0.0.1');
});
it('should use default IP when all sources are missing', async () => {
/**
* Cenário: Nenhuma fonte de IP disponível.
* Problema: Pode retornar string vazia.
* Solução esperada: Usar IP padrão ou rejeitar.
* Nota: Com a nova lógica de validação, quando não houver IP, ele retorna string vazia
* e a validação rejeita. Isso é mais seguro do que usar um IP padrão.
*/
const request = {
headers: {},
connection: {},
socket: {},
};
mockGetRequest.mockReturnValue(request);
mockRateLimitingService.isAllowed.mockResolvedValue(true);
try {
await guard.canActivate(mockExecutionContext);
fail('Deveria ter lançado exceção quando não houver IP');
} catch (error) {
expect(error).toBeInstanceOf(HttpException);
const response = (error as HttpException).getResponse() as any;
expect(response.error).toBe('IP inválido ou não fornecido');
}
});
});
});

View File

@@ -5,14 +5,50 @@ import { RateLimitingService } from '../services/rate-limiting.service';
export class RateLimitingGuard implements CanActivate {
constructor(private readonly rateLimitingService: RateLimitingService) {}
/**
* Verifica se a requisição deve ser permitida baseado no rate limiting
* @throws HttpException quando rate limit é excedido ou ocorre erro
*/
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const ip = this.getClientIp(request);
const isAllowed = await this.rateLimitingService.isAllowed(ip);
this.validateIp(ip);
let isAllowed: boolean;
try {
isAllowed = await this.rateLimitingService.isAllowed(ip);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
throw new HttpException(
{
success: false,
error: 'Erro ao verificar rate limit',
data: null,
details: { originalError: errorMessage },
},
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
if (!isAllowed) {
const attemptInfo = await this.rateLimitingService.getAttemptInfo(ip);
let attemptInfo;
try {
attemptInfo = await this.rateLimitingService.getAttemptInfo(ip);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
throw new HttpException(
{
success: false,
error: 'Erro ao buscar informações de tentativas',
data: null,
details: { originalError: errorMessage },
},
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
this.validateAttemptInfo(attemptInfo);
throw new HttpException(
{
@@ -34,16 +70,169 @@ export class RateLimitingGuard implements CanActivate {
/**
* Extrai o IP real do cliente considerando proxies
* @param request Objeto de requisição
* @returns Endereço IP do cliente
* @returns Endereço IP do cliente ou '127.0.0.1' se não encontrado
*/
private getClientIp(request: any): string {
return (
request.headers['x-forwarded-for']?.split(',')[0] ||
request.headers['x-real-ip'] ||
request.connection?.remoteAddress ||
request.socket?.remoteAddress ||
request.ip ||
'127.0.0.1'
);
const forwardedFor = request.headers['x-forwarded-for']?.split(',')[0]?.trim();
const realIp = request.headers['x-real-ip']?.trim();
const connectionIp = request.connection?.remoteAddress;
const socketIp = request.socket?.remoteAddress;
const requestIp = request.ip;
const rawIp = forwardedFor || realIp || connectionIp || socketIp || requestIp;
if (rawIp === null || rawIp === undefined) {
return '';
}
if (typeof rawIp !== 'string') {
return '';
}
const trimmedIp = rawIp.trim();
if (trimmedIp === '') {
return '';
}
return trimmedIp;
}
/**
* Valida o formato e segurança do IP
* @private
*/
private validateIp(ip: string): void {
if (!ip || typeof ip !== 'string' || !ip.trim()) {
throw new HttpException(
{
success: false,
error: 'IP inválido ou não fornecido',
data: null,
},
HttpStatus.BAD_REQUEST,
);
}
if (ip.length > 45) {
throw new HttpException(
{
success: false,
error: 'IP muito longo',
data: null,
},
HttpStatus.BAD_REQUEST,
);
}
if (/['";\\]/.test(ip)) {
throw new HttpException(
{
success: false,
error: 'Formato de IP inválido',
data: null,
},
HttpStatus.BAD_REQUEST,
);
}
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/;
const ipv6Regex = /^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$|^::1$|^::$/;
const ipv6CompressedRegex = /^([0-9a-fA-F]{0,4}:){1,7}[0-9a-fA-F]{0,4}$/;
if (ip === '127.0.0.1' || ip === '::1') {
return;
}
if (!ipv4Regex.test(ip) && !ipv6Regex.test(ip) && !ipv6CompressedRegex.test(ip)) {
if (!this.isValidIpv4(ip) && !this.isValidIpv6(ip)) {
throw new HttpException(
{
success: false,
error: 'Formato de IP inválido',
data: null,
},
HttpStatus.BAD_REQUEST,
);
}
}
}
/**
* Valida se é um IPv4 válido
* @private
*/
private isValidIpv4(ip: string): boolean {
const parts = ip.split('.');
if (parts.length !== 4) return false;
return parts.every(part => {
const num = parseInt(part, 10);
return !isNaN(num) && num >= 0 && num <= 255;
});
}
/**
* Valida se é um IPv6 válido (simplificado)
* @private
*/
private isValidIpv6(ip: string): boolean {
if (ip.includes('::')) {
const parts = ip.split('::');
if (parts.length > 2) return false;
const leftParts = parts[0] ? parts[0].split(':') : [];
const rightParts = parts[1] ? parts[1].split(':') : [];
return (leftParts.length + rightParts.length) <= 8;
}
const parts = ip.split(':');
if (parts.length !== 8) return false;
return parts.every(part => {
if (!part) return false;
return /^[0-9a-fA-F]{1,4}$/.test(part);
});
}
/**
* Valida os dados de tentativas retornados pelo serviço
* @private
*/
private validateAttemptInfo(attemptInfo: any): void {
if (!attemptInfo || typeof attemptInfo !== 'object') {
throw new HttpException(
{
success: false,
error: 'Dados de tentativas inválidos',
data: null,
},
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
if (typeof attemptInfo.attempts !== 'number' || attemptInfo.attempts < 0) {
throw new HttpException(
{
success: false,
error: 'Dados de tentativas inválidos',
data: null,
},
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
if (attemptInfo.remainingTime !== undefined &&
(typeof attemptInfo.remainingTime !== 'number' || attemptInfo.remainingTime < 0)) {
throw new HttpException(
{
success: false,
error: 'Tempo restante inválido',
data: null,
},
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
}