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:
606
src/auth/guards/__tests__/rate-limiting.guard.spec.ts
Normal file
606
src/auth/guards/__tests__/rate-limiting.guard.spec.ts
Normal 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');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user