From 0d521d741bd7deda7c844b2e73d68aebc56fd50d Mon Sep 17 00:00:00 2001 From: Ahmad Afandi Date: Fri, 12 Dec 2025 10:29:42 +0700 Subject: [PATCH 1/2] Terapkan rate limiting pada OpenKab untuk membantu mencegah serangan DDOS --- .env.example | 5 + app/Http/Kernel.php | 1 + app/Http/Middleware/GlobalRateLimiter.php | 171 +++++++++++++++++++ config/rate-limiter.php | 79 +++++++++ tests/Feature/GlobalRateLimiterTest.php | 197 ++++++++++++++++++++++ 5 files changed, 453 insertions(+) create mode 100644 app/Http/Middleware/GlobalRateLimiter.php create mode 100644 config/rate-limiter.php create mode 100644 tests/Feature/GlobalRateLimiterTest.php diff --git a/.env.example b/.env.example index f79a7612..da4796f2 100644 --- a/.env.example +++ b/.env.example @@ -102,3 +102,8 @@ OTP_RESEND_DECAY_SECONDS=30 # Telegram Bot Configuration TELEGRAM_BOT_TOKEN=your_telegram_bot_token_here TELEGRAM_BOT_NAME=@your_bot_username_here + +# Global Rate Limiter Configuration +RATE_LIMITER_ENABLED=false +RATE_LIMITER_MAX_ATTEMPTS=60 +RATE_LIMITER_DECAY_MINUTES=1 diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index dd166fee..15994938 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -21,6 +21,7 @@ class Kernel extends HttpKernel \Illuminate\Foundation\Http\Middleware\ValidatePostSize::class, Middleware\TrimStrings::class, \Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class, + Middleware\GlobalRateLimiter::class, ]; /** diff --git a/app/Http/Middleware/GlobalRateLimiter.php b/app/Http/Middleware/GlobalRateLimiter.php new file mode 100644 index 00000000..8f613517 --- /dev/null +++ b/app/Http/Middleware/GlobalRateLimiter.php @@ -0,0 +1,171 @@ +limiter = $limiter; + } + + /** + * Handle an incoming request. + * + * @param \Illuminate\Http\Request $request + * @param \Closure $next + * @return \Symfony\Component\HttpFoundation\Response + */ + public function handle(Request $request, Closure $next): Response + { + // Check if global rate limiter is enabled + if (!config('rate-limiter.enabled', false)) { + return $next($request); + } + + // Check if current IP should be excluded + if ($this->shouldExcludeIp($request)) { + return $next($request); + } + + // Check if current path should be excluded + if ($this->shouldExcludePath($request)) { + return $next($request); + } + + // Get configuration from .env or use defaults + $maxAttempts = config('rate-limiter.max_attempts', 60); + $decayMinutes = config('rate-limiter.decay_minutes', 1); + + // Generate unique key for this request based on IP + $key = $this->resolveRequestSignature($request); + + // Check if the request limit has been exceeded + if ($this->limiter->tooManyAttempts($key, $maxAttempts)) { + return $this->buildResponse($key, $maxAttempts); + } + + // Add hit to the limiter + $this->limiter->hit($key, $decayMinutes * 60); + + $response = $next($request); + + // Add headers to the response + $response->headers->set('X-RateLimit-Limit', $maxAttempts); + $response->headers->set('X-RateLimit-Remaining', max(0, $maxAttempts - $this->limiter->attempts($key))); + $response->headers->set('X-RateLimit-Reset', $this->limiter->availableIn($key)); + + return $response; + } + + /** + * Resolve request signature. + * + * @param \Illuminate\Http\Request $request + * @return string + */ + protected function resolveRequestSignature(Request $request): string + { + // Use IP address as the signature for global rate limiting + return sha1( + 'global-rate-limit:' . $request->ip() + ); + } + + /** + * Create a 'too many attempts' response. + * + * @param string $key + * @param int $maxAttempts + * @return \Symfony\Component\HttpFoundation\Response + */ + protected function buildResponse(string $key, int $maxAttempts): Response + { + $seconds = $this->limiter->availableIn($key); + $request = request(); + + if (App::runningInConsole() || $request->expectsJson()) { + return response()->json([ + 'message' => 'Too many requests. Please try again later.', + 'status' => 'error', + 'code' => 429, + 'retry_after' => $seconds, + ], 429); + } + + return response('Too Many Attempts.', 429, [ + 'Retry-After' => $seconds, + 'X-RateLimit-Limit' => $maxAttempts, + 'X-RateLimit-Remaining' => 0, + 'X-RateLimit-Reset' => $seconds, + ]); + } + + /** + * Determine if the request IP should be excluded from rate limiting. + * + * @param \Illuminate\Http\Request $request + * @return bool + */ + protected function shouldExcludeIp(Request $request): bool + { + $excludeIps = config('rate-limiter.exclude_ips', []); + + return in_array($request->ip(), $excludeIps); + } + + /** + * Determine if the request path should be excluded from rate limiting. + * + * @param \Illuminate\Http\Request $request + * @return bool + */ + protected function shouldExcludePath(Request $request): bool + { + $excludePaths = config('rate-limiter.exclude_paths', []); + $requestPath = $request->path(); + + foreach ($excludePaths as $path) { + if ($this->pathMatches($path, $requestPath)) { + return true; + } + } + + return false; + } + + /** + * Check if the path matches the pattern. + * + * @param string $pattern + * @param string $path + * @return bool + */ + protected function pathMatches(string $pattern, string $path): bool + { + // Convert wildcard pattern to regex + $pattern = preg_quote($pattern, '#'); + $pattern = str_replace('\*', '.*', $pattern); + + return preg_match("#^{$pattern}$#", $path); + } +} \ No newline at end of file diff --git a/config/rate-limiter.php b/config/rate-limiter.php new file mode 100644 index 00000000..4e56df60 --- /dev/null +++ b/config/rate-limiter.php @@ -0,0 +1,79 @@ + env('RATE_LIMITER_ENABLED', false), + + /* + |-------------------------------------------------------------------------- + | Maximum Attempts + |-------------------------------------------------------------------------- + | + | This value controls the maximum number of requests that can be made + | within the given decay period. Once this limit is reached, subsequent + | requests will be blocked until the decay period has elapsed. + | + */ + 'max_attempts' => env('RATE_LIMITER_MAX_ATTEMPTS', 60), + + /* + |-------------------------------------------------------------------------- + | Decay Minutes + |-------------------------------------------------------------------------- + | + | This value controls the number of minutes to wait before the rate + | limiter resets. After this period, the request count will be reset + | to zero and new requests will be allowed. + | + */ + 'decay_minutes' => env('RATE_LIMITER_DECAY_MINUTES', 1), + + /* + |-------------------------------------------------------------------------- + | Exclude Paths + |-------------------------------------------------------------------------- + | + | This array contains the paths that should be excluded from the + | global rate limiting. These paths will not be subject to the + | rate limiting rules defined above. + | + */ + 'exclude_paths' => [ + // 'api/health', + // 'api/ping', + // 'admin/*', + ], + + /* + |-------------------------------------------------------------------------- + | Exclude IP Addresses + |-------------------------------------------------------------------------- + | + | This array contains the IP addresses that should be excluded from + | the global rate limiting. These IP addresses will not be subject + | to the rate limiting rules defined above. + | + */ + 'exclude_ips' => [ + // '127.0.0.1', + // '192.168.1.1', + ], +]; \ No newline at end of file diff --git a/tests/Feature/GlobalRateLimiterTest.php b/tests/Feature/GlobalRateLimiterTest.php new file mode 100644 index 00000000..84b5e947 --- /dev/null +++ b/tests/Feature/GlobalRateLimiterTest.php @@ -0,0 +1,197 @@ +get('/'); + + $this->assertNotEquals(429, $response->getStatusCode()); + } + } + + /** @test */ + public function it_allows_requests_within_limit_when_rate_limiter_is_enabled() + { + // Enable rate limiter with low limits for testing + Config::set('rate-limiter.enabled', true); + Config::set('rate-limiter.max_attempts', 5); + Config::set('rate-limiter.decay_minutes', 1); + + // Make requests within limit + for ($i = 0; $i < 5; $i++) { + $response = $this->get('/'); + + $this->assertNotEquals(429, $response->getStatusCode()); + $this->assertTrue($response->headers->has('X-RateLimit-Limit')); + $this->assertTrue($response->headers->has('X-RateLimit-Remaining')); + $this->assertTrue($response->headers->has('X-RateLimit-Reset')); + } + } + + /** @test */ + public function it_blocks_requests_when_limit_is_exceeded() + { + // Enable rate limiter with low limits for testing + Config::set('rate-limiter.enabled', true); + Config::set('rate-limiter.max_attempts', 2); + Config::set('rate-limiter.decay_minutes', 1); + + // Make requests within the limit + $response1 = $this->get('/'); + $this->assertNotEquals(429, $response1->getStatusCode()); + + $response2 = $this->get('/'); + $this->assertNotEquals(429, $response2->getStatusCode()); + + // The next request should be blocked + $response3 = $this->get('/'); + $this->assertEquals(429, $response3->getStatusCode()); + } + + /** @test */ + public function it_returns_correct_json_response_for_api_requests() + { + // Enable rate limiter with low limits for testing + Config::set('rate-limiter.enabled', true); + Config::set('rate-limiter.max_attempts', 1); + Config::set('rate-limiter.decay_minutes', 1); + + // Make a request to exceed the limit + $this->get('/api/user'); + + // The next request should be blocked with JSON response + $response = $this->get('/api/user', ['Accept' => 'application/json']); + + $this->assertEquals(429, $response->getStatusCode()); + $response->assertJson([ + 'message' => 'Too many requests. Please try again later.', + 'status' => 'error', + 'code' => 429, + ]); + $response->assertJsonStructure(['retry_after']); + } + + /** @test */ + public function it_excludes_configured_paths_from_rate_limiting() + { + // Enable rate limiter with low limits + Config::set('rate-limiter.enabled', true); + Config::set('rate-limiter.max_attempts', 1); + Config::set('rate-limiter.decay_minutes', 1); + + // Exclude a specific path + Config::set('rate-limiter.exclude_paths', ['search']); + + // Make multiple requests to excluded path + for ($i = 0; $i < 5; $i++) { + $response = $this->get('/search'); + + $this->assertNotEquals(429, $response->getStatusCode()); + } + } + + /** @test */ + public function it_excludes_configured_ip_addresses_from_rate_limiting() + { + // Enable rate limiter with low limits + Config::set('rate-limiter.enabled', true); + Config::set('rate-limiter.max_attempts', 1); + Config::set('rate-limiter.decay_minutes', 1); + + // Exclude localhost IP + Config::set('rate-limiter.exclude_ips', ['127.0.0.1']); + + // Make multiple requests from excluded IP + for ($i = 0; $i < 5; $i++) { + $response = $this->get('/'); + + $this->assertNotEquals(429, $response->getStatusCode()); + } + } + + /** @test */ + public function it_respects_wildcard_patterns_in_excluded_paths() + { + // Enable rate limiter with low limits + Config::set('rate-limiter.enabled', true); + Config::set('rate-limiter.max_attempts', 1); + Config::set('rate-limiter.decay_minutes', 1); + + // Exclude presisi paths with wildcard + Config::set('rate-limiter.exclude_paths', ['presisi/*']); + + // Test that excluded paths work + $response1 = $this->get('/presisi'); + $this->assertNotEquals(429, $response1->getStatusCode()); + + $response2 = $this->get('/presisi/kesehatan'); + $this->assertNotEquals(429, $response2->getStatusCode()); + + $response3 = $this->get('/presisi/bantuan'); + $this->assertNotEquals(429, $response3->getStatusCode()); + } + + /** @test */ + public function it_respects_different_rate_limits_for_different_ips() + { + // Enable rate limiter with low limits + Config::set('rate-limiter.enabled', true); + Config::set('rate-limiter.max_attempts', 2); + Config::set('rate-limiter.decay_minutes', 1); + + // Simulate requests from different IPs + $this->withServerVariables(['REMOTE_ADDR' => '192.168.1.1'])->get('/'); + $this->withServerVariables(['REMOTE_ADDR' => '192.168.1.1'])->get('/'); + + // Third request from same IP should be blocked + $response = $this->withServerVariables(['REMOTE_ADDR' => '192.168.1.1'])->get('/'); + $this->assertEquals(429, $response->getStatusCode()); + + // But requests from different IP should still work + $response = $this->withServerVariables(['REMOTE_ADDR' => '192.168.1.2'])->get('/'); + $this->assertNotEquals(429, $response->getStatusCode()); + } + + /** @test */ + public function it_works_with_simple_paths() + { + // Enable rate limiter with low limits + Config::set('rate-limiter.enabled', true); + Config::set('rate-limiter.max_attempts', 1); + Config::set('rate-limiter.decay_minutes', 1); + + // Exclude a simple path + Config::set('rate-limiter.exclude_paths', ['sitemap.xml']); + + // Make multiple requests to excluded path + for ($i = 0; $i < 3; $i++) { + $response = $this->get('/sitemap.xml'); + + $this->assertNotEquals(429, $response->getStatusCode()); + } + } +} \ No newline at end of file From 966a002538628b9cfdb5b3e5f40ee8365f7f03df Mon Sep 17 00:00:00 2001 From: Abah Roland Date: Fri, 12 Dec 2025 11:16:40 +0700 Subject: [PATCH 2/2] [ci skip] memutahirkan catatan rilis --- catatan_rilis.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/catatan_rilis.md b/catatan_rilis.md index e9829415..45888163 100644 --- a/catatan_rilis.md +++ b/catatan_rilis.md @@ -10,4 +10,5 @@ Di rilis ini, versi 2512.0.1 berisi penambahan dan perbaikan yang diminta penggu #### Perubahan Teknis 1. [#869](https://github.com/OpenSID/OpenKab/issues/869) Upgrade versi moment pada chart.js serta perbaikan halaman website presisi untuk kependudukan dan RTM. -2. [#876](https://github.com/OpenSID/OpenKab/issues/876) Ganti highchart dengan chartjs agar menggunakan satu library saja. \ No newline at end of file +2. [#876](https://github.com/OpenSID/OpenKab/issues/876) Ganti highchart dengan chartjs agar menggunakan satu library saja. +3. [#868](https://github.com/OpenSID/OpenKab/issues/868) Penerapan rate limiting pada OpenKab untuk membantu mencegah serangan DDOS. \ No newline at end of file