diff --git a/.gitignore b/.gitignore index 80180de..eda1536 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ /Gemfile /Gemfile.lock /favicon.ico -_site/ \ No newline at end of file +_site/ +.idea/ +dependency-reduced-pom.xml +target/ \ No newline at end of file diff --git a/src/main/java/org/owasp/astf/testcases/RateLimitingTestCase.java b/src/main/java/org/owasp/astf/testcases/RateLimitingTestCase.java new file mode 100644 index 0000000..2dd9592 --- /dev/null +++ b/src/main/java/org/owasp/astf/testcases/RateLimitingTestCase.java @@ -0,0 +1,117 @@ +package org.owasp.astf.testcases; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.owasp.astf.core.EndpointInfo; +import org.owasp.astf.core.http.HttpClient; +import org.owasp.astf.core.result.Finding; +import org.owasp.astf.core.result.Severity; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * Tests for Rate Limiting vulnerabilities. + * + * This test case sends multiple requests in rapid succession to the API endpoint + * and checks if rate limiting is properly enforced. + */ +public class RateLimitingTestCase implements TestCase { + + private static final Logger logger = LogManager.getLogger(RateLimitingTestCase.class); + + private static final int REQUEST_COUNT = 20; + private static final List SAFE_ENDPOINTS = List.of( + "/api/health", + "/api/status", + "/api/public/info" + ); + + @Override + public String getId() { + return "ASTF-RL-2025"; + } + + @Override + public String getName() { + return "Rate Limiting"; + } + + @Override + public String getDescription() { + return """ + Sends multiple requests in a short timeframe to test if the API + enforces rate limiting to protect against brute force or abuse. + """; + } + + @Override + public List execute(EndpointInfo endpoint, HttpClient httpClient) throws IOException { + logger.info("Executing {} test on {}", getId(), endpoint); + + List findings = new ArrayList<>(); + Map headers = Map.of("Content-Type", "application/json"); + String requestBody = "{\"key\":\"value\"}"; + boolean rateLimitTriggered = false; + + for (int i = 0; i < REQUEST_COUNT; i++) { + try { + String response; + + switch (endpoint.getMethod().toUpperCase()) { + case "POST" -> response = httpClient.post(endpoint.getPath(), headers, "application/json", requestBody); + case "GET" -> response = httpClient.get(endpoint.getPath(), headers); + case "PUT" -> response = httpClient.put(endpoint.getPath(), headers, "application/json", requestBody); + case "DELETE" -> response = httpClient.delete(endpoint.getPath(), headers); + default -> { + logger.warn("Unsupported HTTP method {} for endpoint {}", endpoint.getMethod(), endpoint.getPath()); + return findings; + } + } + + // Check if the response indicates rate limiting + // This might be HTTP 429 Too Many Requests or some error message in response body + if (response != null && (response.contains("429") || response.toLowerCase().contains("rate limit") || response.toLowerCase().contains("too many requests"))) { + rateLimitTriggered = true; + break; + } + + } catch (IOException e) { + logger.error("Request failed on attempt {}: {}", i + 1, e.getMessage()); + } + } + + if (rateLimitTriggered) { + findings.add(new Finding( + UUID.randomUUID().toString(), + "Rate Limiting Enforced", + "The endpoint correctly enforced rate limiting after multiple rapid requests.", + Severity.INFO, + getId(), + endpoint.getMethod() + " " + endpoint.getPath(), + "No action needed." + )); + } else if (!isSafeToSkipRateLimiting(endpoint.getPath())) { + findings.add(new Finding( + UUID.randomUUID().toString(), + "Missing or Ineffective Rate Limiting", + "The endpoint did not enforce rate limiting after multiple rapid requests.", + Severity.HIGH, + getId(), + endpoint.getMethod() + " " + endpoint.getPath(), + "Implement rate limiting to protect API endpoints from brute force and abuse." + )); + } else { + logger.info("Skipping finding for non-sensitive endpoint: {}", endpoint.getPath()); + } + + return findings; + } + + private boolean isSafeToSkipRateLimiting(String path) { + return SAFE_ENDPOINTS.stream().anyMatch(path::startsWith); + } +} diff --git a/src/test/java/org/owasp/astf/testcases/RateLimitingTest.java b/src/test/java/org/owasp/astf/testcases/RateLimitingTest.java new file mode 100644 index 0000000..41d185d --- /dev/null +++ b/src/test/java/org/owasp/astf/testcases/RateLimitingTest.java @@ -0,0 +1,101 @@ +package org.owasp.astf.testcases; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.owasp.astf.core.EndpointInfo; +import org.owasp.astf.core.http.HttpClient; +import org.owasp.astf.core.result.Finding; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.when; + +@DisplayName("Rate Limiting Test Case Tests") +class RateLimitingTest { + + @Mock + private HttpClient httpClient; + + private RateLimitingTestCase testCase; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + testCase = new RateLimitingTestCase(); + } + + @ParameterizedTest(name = "{index}: {0} {1} -> expected: {3}") + @MethodSource("provideEndpoints") + @DisplayName("Should detect or skip rate limiting based on server response") + void testRateLimitingDetectionWithProvider( + String description, + EndpointInfo endpoint, + String mockResponse, + String expectedFindingTitle + ) throws IOException { + + final int threshold = 5; + final int totalCalls = 20; + final String path = endpoint.getPath(); + final String method = endpoint.getMethod().toUpperCase(); + + final int[] counter = {0}; + var answer = (org.mockito.stubbing.Answer) invocation -> { + counter[0]++; + if (counter[0] >= threshold && mockResponse.contains("Too Many Requests")) { + return "{\"error\":\"Too Many Requests\",\"status\":429}"; + } + return "{\"message\":\"ok\"}"; + }; + + switch (method) { + case "GET" -> when(httpClient.get(eq(path), anyMap())).thenAnswer(answer); + case "POST" -> when(httpClient.post(eq(path), anyMap(), anyString(), anyString())).thenAnswer(answer); + case "PUT" -> when(httpClient.put(eq(path), anyMap(), anyString(), anyString())).thenAnswer(answer); + case "DELETE" -> when(httpClient.delete(eq(path), anyMap())).thenAnswer(answer); + default -> fail("Unsupported HTTP method in test: " + method); + } + + List findings = testCase.execute(endpoint, httpClient); + + if (expectedFindingTitle != null) { + assertFalse(findings.isEmpty(), "Expected finding for: " + description); + assertEquals(expectedFindingTitle, findings.get(0).getTitle(), + "Unexpected finding title for: " + description); + } else { + assertTrue(findings.isEmpty(), "Did not expect any findings for: " + description); + } + } + + private static Stream provideEndpoints() { + return Stream.of( + org.junit.jupiter.params.provider.Arguments.of( + "Rate limiting detected via 400 error on POST", + new EndpointInfo("/api/rate-limited", "POST"), + "{\"error\":\"invalid request\",\"status\":400}", + "Missing or Ineffective Rate Limiting" + ), + org.junit.jupiter.params.provider.Arguments.of( + "Rate limiting detected via 429 error on GET", + new EndpointInfo("/api/rate-limited", "GET"), + "{\"error\":\"Too Many Requests\",\"status\":429}", + "Rate Limiting Enforced" + ), + org.junit.jupiter.params.provider.Arguments.of( + "Health check endpoint - safe to skip", + new EndpointInfo("/api/health", "GET"), + "{\"message\":\"ok\"}", + null + ) + ); + } +}