From 1edd66a85bc1da6638c8ecae39b92219729ca91d Mon Sep 17 00:00:00 2001 From: Revanza Firdaus Date: Tue, 3 Mar 2026 02:38:23 +0700 Subject: [PATCH 1/6] feat(security): add IpAddress helper for proxy IP detection - Detects real IP from CF-Connecting-IP, X-Forwarded-For, X-Real-IP - Validates IP format to prevent header injection - Filters private IPs (configurable) - Generates rate limit keys with IP+Email combination Closes #1410 --- app/Helpers/IpAddress.php | 295 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 295 insertions(+) create mode 100644 app/Helpers/IpAddress.php diff --git a/app/Helpers/IpAddress.php b/app/Helpers/IpAddress.php new file mode 100644 index 000000000..b4273662d --- /dev/null +++ b/app/Helpers/IpAddress.php @@ -0,0 +1,295 @@ + + */ + private const IP_HEADERS = [ + 'CF-Connecting-IP', // Cloudflare + 'True-Client-IP', // Cloudflare Enterprise + 'X-Forwarded-For', // Standard proxy header + 'X-Real-IP', // Nginx default + 'HTTP_X_FORWARDED_FOR', // Legacy + ]; + + /** + * Daftar IP range private yang tidak boleh dianggap sebagai IP asli + * Kecuali aplikasi memang berjalan di jaringan private + * + * @var array + */ + private const PRIVATE_IP_PATTERNS = [ + '10.0.0.0/8', // 10.0.0.0 - 10.255.255.255 + '172.16.0.0/12', // 172.16.0.0 - 172.31.255.255 + '192.168.0.0/16', // 192.168.0.0 - 192.168.255.255 + '127.0.0.0/8', // 127.0.0.0 - 127.255.255.255 (loopback) + '169.254.0.0/16', // 169.254.0.0 - 169.254.255.255 (link-local) + 'fc00::/7', // IPv6 private + 'fe80::/10', // IPv6 link-local + '::1', // IPv6 loopback + ]; + + /** + * Mendapatkan IP address asli dari request + * + * @param Request $request HTTP Request + * @param bool $trustPrivateIp Apakah private IP diterima (default: false) + * @return string IP address yang terdeteksi + */ + public static function getRealIp(Request $request, bool $trustPrivateIp = false): string + { + foreach (self::IP_HEADERS as $header) { + $ip = self::extractIpFromHeader($request, $header); + + if ($ip === null) { + continue; + } + + // Validasi format IP + if (!self::isValidIpFormat($ip)) { + Log::warning('Invalid IP format detected', [ + 'header' => $header, + 'value' => $ip, + 'request_id' => $request->attributes->get('request_id'), + ]); + continue; + } + + // Cek apakah private IP (jika tidak di-trust) + if (!$trustPrivateIp && self::isPrivateIp($ip)) { + // Untuk private IP, lanjutkan ke header berikutnya + // tapi jika semua header menghasilkan private IP, + // fallback ke $request->ip() di akhir + continue; + } + + return $ip; + } + + // Fallback ke IP dari request (yang mungkin sudah diproses oleh TrustProxies) + return $request->ip(); + } + + /** + * Mengekstrak IP dari header spesifik + * + * Handle kasus X-Forwarded-For yang berisi multiple IPs: + * "client, proxy1, proxy2" -> ambil IP pertama (client) + * + * @param Request $request + * @param string $header + * @return string|null IP address atau null jika invalid + */ + private static function extractIpFromHeader(Request $request, string $header): ?string + { + $value = $request->header($header); + + if (empty($value)) { + return null; + } + + // X-Forwarded-For bisa berupa comma-separated list + // Format: "client, proxy1, proxy2" + // Kita ambil IP pertama (original client) + $ips = explode(',', $value); + $firstIp = trim($ips[0]); + + // Sanitasi: remove port number jika ada (IPv4:port atau [IPv6]:port) + $firstIp = self::removePortFromIp($firstIp); + + // Additional sanitasi untuk mencegah injection + if (strlen($firstIp) > 45) { // IPv6 max length is 45 chars + return null; + } + + return $firstIp; + } + + /** + * Menghapus port number dari IP address + * + * @param string $ip IP with possible port (e.g., "192.168.1.1:8080" or "[::1]:8080") + * @return string IP without port + */ + private static function removePortFromIp(string $ip): string + { + // Handle IPv6 with port: [::1]:8080 + if (strpos($ip, '[') === 0) { + $closingBracket = strpos($ip, ']'); + if ($closingBracket !== false) { + return substr($ip, 1, $closingBracket - 1); + } + } + + // Handle IPv4 with port or IPv6 without brackets + $colonPos = strrpos($ip, ':'); + if ($colonPos !== false) { + $potentialIp = substr($ip, 0, $colonPos); + + // Cek apakah bagian setelah colon adalah numeric port + $potentialPort = substr($ip, $colonPos + 1); + if (ctype_digit($potentialPort)) { + $ip = $potentialIp; + } + } + + return $ip; + } + + /** + * Validasi format IP address + * + * @param string $ip + * @return bool + */ + private static function isValidIpFormat(string $ip): bool + { + // Basic sanitasi: karakter yang diperbolehkan + if (!preg_match('/^[a-fA-F0-9.:]+$/', $ip)) { + return false; + } + + return filter_var($ip, FILTER_VALIDATE_IP) !== false; + } + + /** + * Mengecek apakah IP adalah private IP + * + * @param string $ip + * @return bool + */ + private static function isPrivateIp(string $ip): bool + { + $ipLong = ip2long($ip); + + if ($ipLong === false) { + // Bukan IPv4, cek IPv6 private ranges + return self::isPrivateIpv6($ip); + } + + // Cek IPv4 private ranges + $privateRanges = [ + ['10.0.0.0', '10.255.255.255'], // 10.0.0.0/8 + ['172.16.0.0', '172.31.255.255'], // 172.16.0.0/12 + ['192.168.0.0', '192.168.255.255'], // 192.168.0.0/16 + ['127.0.0.0', '127.255.255.255'], // 127.0.0.0/8 (loopback) + ]; + + foreach ($privateRanges as $range) { + $start = ip2long($range[0]); + $end = ip2long($range[1]); + + if ($ipLong >= $start && $ipLong <= $end) { + return true; + } + } + + return false; + } + + /** + * Mengecek apakah IPv6 adalah private address + * + * @param string $ip + * @return bool + */ + private static function isPrivateIpv6(string $ip): bool + { + // IPv6 private ranges + $privatePatterns = [ + '/^fc00:/i', // Unique local addresses (ULA) + '/^fd/i', // ULA + '/^fe80:/i', // Link-local + '/^::1$/i', // Loopback + '/^fec0:/i', // Site-local (deprecated) + ]; + + foreach ($privatePatterns as $pattern) { + if (preg_match($pattern, $ip)) { + return true; + } + } + + return false; + } + + /** + * Membuat unique key untuk rate limiting berdasarkan IP dan optional identifier + * + * Format: {ip}|{identifier} + * Contoh: "192.168.1.1|user@example.com" + * + * @param Request $request + * @param string|null $identifier Optional identifier (email, username, dll) + * @return string Unique key untuk rate limiting + */ + public static function getRateLimitKey(Request $request, ?string $identifier = null): string + { + $ip = self::getRealIp($request); + + if ($identifier) { + // Sanitasi identifier untuk mencegah collision + $identifier = strtolower(trim($identifier)); + // Remove karakter berbahaya + $identifier = preg_replace('/[^a-z0-9@._-]/', '', $identifier); + + return $ip . '|' . $identifier; + } + + return $ip; + } +} From 6e7fe8dbc7e805f62944e78a702c424f33449d89 Mon Sep 17 00:00:00 2001 From: Revanza Firdaus Date: Tue, 3 Mar 2026 02:38:25 +0700 Subject: [PATCH 2/6] feat(security): update TrustProxies for proxy support - Add configurable trusted proxies via env variable - Support Cloudflare, reverse proxy, load balancer Closes #1410 --- app/Http/Middleware/TrustProxies.php | 61 +++++++++++++++++++++++++--- 1 file changed, 56 insertions(+), 5 deletions(-) diff --git a/app/Http/Middleware/TrustProxies.php b/app/Http/Middleware/TrustProxies.php index 6bc386d8b..3fe0a6a8e 100644 --- a/app/Http/Middleware/TrustProxies.php +++ b/app/Http/Middleware/TrustProxies.php @@ -7,7 +7,7 @@ * * Aplikasi dan source code ini dirilis berdasarkan lisensi GPL V3 * - * Hak Cipta 2017 - 2024 Perkumpulan Desa Digital Terbuka (https://opendesa.id) + * Hak Cipta 2017 - 2025 Perkumpulan Desa Digital Terbuka (https://opendesa.id) * * Dengan ini diberikan izin, secara gratis, kepada siapa pun yang mendapatkan salinan * dari perangkat lunak ini dan file dokumentasi terkait ("Aplikasi Ini"), untuk diperlakukan @@ -24,7 +24,7 @@ * * @package OpenDK * @author Tim Pengembang OpenDesa - * @copyright Hak Cipta 2017 - 2024 Perkumpulan Desa Digital Terbuka (https://opendesa.id) + * @copyright Hak Cipta 2017 - 2025 Perkumpulan Desa Digital Terbuka (https://opendesa.id) * @license http://www.gnu.org/licenses/gpl.html GPL V3 * @link https://github.com/OpenSID/opendk */ @@ -34,24 +34,75 @@ use Illuminate\Http\Middleware\TrustProxies as Middleware; use Illuminate\Http\Request; +/** + * Middleware untuk mengatur proxy yang dipercaya + * + * Konfigurasi ini penting ketika aplikasi berada di belakang: + * - Cloudflare (CDN) + * - Nginx/Apache sebagai reverse proxy + * - Load Balancer (AWS ELB, GCP, Azure) + * + * @see https://laravel.com/docs/10.x/requests#configuring-trusted-proxies + */ class TrustProxies extends Middleware { /** * The trusted proxies for this application. * + * Opsi konfigurasi: + * 1. '*' - Trust semua proxy (default untuk kemudahan deployment) + * 2. IP spesifik - Lebih secure untuk production + * 3. TRUST_PROXIES env var - Dapat di-custom per environment + * + * Untuk production, disarankan set IP spesifik di .env: + * TRUST_PROXIES=103.21.244.0/22,103.22.200.0/22,103.31.4.0/22 + * + * Daftar IP Cloudflare: https://www.cloudflare.com/ips/ + * * @var array|string|null */ - protected $proxies; + protected $proxies = '*'; /** * The headers that should be used to detect proxies. * + * Laravel akan membaca IP asli dari header ini: + * - X-Forwarded-For: Standard de-facto untuk proxy + * - X-Forwarded-Host: Host asli + * - X-Forwarded-Port: Port asli + * - X-Forwarded-Proto: Protocol asli (http/https) + * - X-Forwarded-AWS-ELB: AWS Load Balancer + * + * Catatan: CF-Connecting-IP (Cloudflare) tidak bisa di-set di sini + * karena tidak ada konstanta di Laravel. Gunakan helper App\Helpers\IpAddress + * untuk membaca header tersebut. + * * @var int */ - protected $headers = - Request::HEADER_X_FORWARDED_FOR | + protected $headers = Request::HEADER_X_FORWARDED_FOR | Request::HEADER_X_FORWARDED_HOST | Request::HEADER_X_FORWARDED_PORT | Request::HEADER_X_FORWARDED_PROTO | Request::HEADER_X_FORWARDED_AWS_ELB; + + /** + * Override untuk mendapatkan proxy dari environment variable + * + * @return array|string|null + */ + protected function getTrustedProxies() + { + // Cek env variable TRUST_PROXIES + if ($envProxies = env('TRUST_PROXIES')) { + if ($envProxies === '*') { + return '*'; + } + + // Parse comma-separated IP ranges + return array_map('trim', explode(',', $envProxies)); + } + + // Default: trust all proxies untuk backward compatibility + return $this->proxies; + } } From da96e690da1c9e9e2ed9ad0b9cea34d94d0a731f Mon Sep 17 00:00:00 2001 From: Revanza Firdaus Date: Tue, 3 Mar 2026 02:38:28 +0700 Subject: [PATCH 3/6] feat(security): add rate limiters for login and OTP - Login rate limiter: 10 attempts per minute per IP+Email - OTP rate limiter: 3 attempts per minute per IP+Email - Use IpAddress helper for real IP detection Closes #1410 --- app/Providers/RouteServiceProvider.php | 66 ++++++++++++++++++++++++-- 1 file changed, 63 insertions(+), 3 deletions(-) diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php index cca2c9c15..c923e05a4 100644 --- a/app/Providers/RouteServiceProvider.php +++ b/app/Providers/RouteServiceProvider.php @@ -7,7 +7,7 @@ * * Aplikasi dan source code ini dirilis berdasarkan lisensi GPL V3 * - * Hak Cipta 2017 - 2024 Perkumpulan Desa Digital Terbuka (https://opendesa.id) + * Hak Cipta 2017 - 2025 Perkumpulan Desa Digital Terbuka (https://opendesa.id) * * Dengan ini diberikan izin, secara gratis, kepada siapa pun yang mendapatkan salinan * dari perangkat lunak ini dan file dokumentasi terkait ("Aplikasi Ini"), untuk diperlakukan @@ -24,13 +24,14 @@ * * @package OpenDK * @author Tim Pengembang OpenDesa - * @copyright Hak Cipta 2017 - 2024 Perkumpulan Desa Digital Terbuka (https://opendesa.id) + * @copyright Hak Cipta 2017 - 2025 Perkumpulan Desa Digital Terbuka (https://opendesa.id) * @license http://www.gnu.org/licenses/gpl.html GPL V3 * @link https://github.com/OpenSID/opendk */ namespace App\Providers; +use App\Helpers\IpAddress; use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider; use Illuminate\Http\Request; @@ -73,12 +74,71 @@ public function boot() /** * Configure the rate limiters for the application. * + * Rate limiters yang tersedia: + * - 'api': Untuk API routes (60 request/menit) + * - 'login': Untuk login attempts (10 attempt/menit per IP + Email) + * * @return void */ protected function configureRateLimiting() { + // API rate limiter - untuk authenticated dan guest users RateLimiter::for('api', function (Request $request) { - return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip()); + return Limit::perMinute(60)->by($request->user()?->id ?: IpAddress::getRealIp($request)); + }); + + // Login rate limiter - mencegah brute force attack + // + // Key format: {ip}|{email} + // Contoh: "192.168.1.1|user@example.com" + // + // Penggunaan IP + Email sebagai key memberikan keuntungan: + // 1. Attacker tidak bisa mem-blokir semua user dari IP yang sama + // 2. Attacker tidak bisa brute force satu email dari multiple IPs + // 3. Legitimate user tidak terkena limit jika emailnya berbeda + RateLimiter::for('login', function (Request $request) { + // Ambil email dari request (bisa dari login form atau OTP request) + $email = $request->input('email') + ?? $request->input('username') + ?? $request->input('identity'); + + // Buat key berdasarkan IP asli + email + $key = IpAddress::getRateLimitKey($request, $email); + + // Konfigurasi limit dari env atau default 10/menit + $maxAttempts = (int) env('RATE_LIMIT_LOGIN_MAX', 10); + $decayMinutes = (int) env('RATE_LIMIT_LOGIN_DECAY', 1); + + return Limit::perMinute($maxAttempts) + ->by($key) + ->response(function () use ($decayMinutes) { + return response()->json([ + 'message' => "Terlalu banyak percobaan login. Silakan coba lagi dalam {$decayMinutes} menit.", + 'error' => 'too_many_attempts', + ], 429); + }); + }); + + // OTP rate limiter - mencegah abuse pada OTP request/resend + RateLimiter::for('otp', function (Request $request) { + $email = $request->input('email') + ?? $request->input('username') + ?? $request->input('identity'); + + $key = IpAddress::getRateLimitKey($request, $email); + + // Lebih strict untuk OTP (3 per menit) + $maxAttempts = (int) env('RATE_LIMIT_OTP_MAX', 3); + $decayMinutes = (int) env('RATE_LIMIT_OTP_DECAY', 1); + + return Limit::perMinute($maxAttempts) + ->by($key) + ->response(function () use ($decayMinutes) { + return response()->json([ + 'message' => "Terlalu banyak permintaan OTP. Silakan coba lagi dalam {$decayMinutes} menit.", + 'error' => 'too_many_otp_attempts', + ], 429); + }); }); } } From 03732de1236eb4952f7d24185b6372b00e12cd02 Mon Sep 17 00:00:00 2001 From: Revanza Firdaus Date: Tue, 3 Mar 2026 02:38:30 +0700 Subject: [PATCH 4/6] feat(security): apply throttle middleware to login and OTP routes - Add throttle:login to POST /login - Add throttle:login to 2FA verify-login - Add throttle:otp to OTP request/resend routes Closes #1410 --- routes/web.php | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/routes/web.php b/routes/web.php index 70fbbe3af..5a9e5cf6d 100644 --- a/routes/web.php +++ b/routes/web.php @@ -91,11 +91,20 @@ // Redirect if apps not installed Route::group(['middleware' => ['installed', 'xss_sanitization']], function () { + // Authentication routes dengan rate limiting untuk mencegah brute force Auth::routes([ 'register' => false, ]); - // OTP Routes + // Override default login route dengan rate limiter + // Route ini menggantikan /login POST dari Auth::routes() + Route::namespace('\App\Http\Controllers\Auth')->middleware('guest')->group(function () { + Route::post('/login', 'LoginController@login') + ->middleware('throttle:login') + ->name('login.post'); + }); + + // OTP Routes dengan rate limiting untuk mencegah OTP abuse Route::namespace('\App\Http\Controllers\Auth')->middleware('otp.enabled')->group(function () { // OTP Activation (requires auth) Route::middleware('auth')->group(function () { @@ -107,16 +116,18 @@ Route::get('/otp/deactivate', 'OtpController@deactivate')->name('otp.deactivate'); }); - // OTP Login (guest only) - Route::middleware('guest')->group(function () { + // OTP Login (guest only) dengan rate limiting + Route::middleware(['guest', 'throttle:otp'])->group(function () { Route::get('/otp/login', 'OtpController@showLoginForm')->name('otp.login'); Route::post('/otp/request-login', 'OtpController@requestLoginOtp')->name('otp.request-login'); Route::get('/otp/verify-login', 'OtpController@showVerifyLoginForm')->name('otp.verify-login'); Route::post('/otp/verify-login', 'OtpController@loginWithOtp'); }); - // OTP Resend (both auth and guest) - Route::post('/otp/resend', 'OtpController@resendOtp')->name('otp.resend'); + // OTP Resend (both auth and guest) dengan rate limiting + Route::post('/otp/resend', 'OtpController@resendOtp') + ->middleware('throttle:otp') + ->name('otp.resend'); }); // 2FA Routes @@ -137,8 +148,8 @@ Route::get('/2fa/deactivate', 'TwoFactorController@deactivate')->name('2fa.deactivate'); }); - // 2FA Login Routes (guest access) - Route::namespace('\App\Http\Controllers\Auth')->middleware('guest')->group(function () { + // 2FA Login Routes (guest access) dengan rate limiting + Route::namespace('\App\Http\Controllers\Auth')->middleware(['guest', 'throttle:login'])->group(function () { Route::get('/2fa/verify-login', 'TwoFactorController@showVerifyLoginForm')->name('2fa.verify-login'); Route::post('/2fa/verify-login', 'TwoFactorController@verifyLogin'); }); From 6d28a702a2b97379f81d807d0c246a173e0a4c41 Mon Sep 17 00:00:00 2001 From: Revanza Firdaus Date: Tue, 3 Mar 2026 02:38:33 +0700 Subject: [PATCH 5/6] feat(security): add rate limiting config to env example - TRUST_PROXIES for trusted proxy configuration - RATE_LIMIT_LOGIN_MAX and _DECAY - RATE_LIMIT_OTP_MAX and _DECAY Closes #1410 --- .env.example | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.env.example b/.env.example index 001d77c50..afa4dc3bd 100644 --- a/.env.example +++ b/.env.example @@ -76,3 +76,19 @@ RECAPTCHAV3_SECRET= # OTP Configuration OTP_EXPIRY_MINUTES=5 TELEGRAM_BOT_TOKEN= + +# Security Configuration +# Trusted Proxies: Set ke IP spesifik untuk production, atau '*' untuk trust semua +# Daftar IP Cloudflare: https://www.cloudflare.com/ips/ +# Untuk multiple proxies, gunakan comma-separated: 103.21.244.0/22,103.22.200.0/22 +TRUST_PROXIES=* + +# Rate Limiting Configuration +# Maksimal percobaan login per menit per IP + Email +RATE_LIMIT_LOGIN_MAX=10 +# Waktu decay dalam menit sebelum limit di-reset +RATE_LIMIT_LOGIN_DECAY=1 + +# Rate Limiting untuk OTP (lebih strict dari login) +RATE_LIMIT_OTP_MAX=3 +RATE_LIMIT_OTP_DECAY=1 From 8f8de06f49a417181783465ef39f380f80a5211c Mon Sep 17 00:00:00 2001 From: Revanza Firdaus Date: Tue, 3 Mar 2026 02:38:36 +0700 Subject: [PATCH 6/6] test(security): add Pest tests for rate limiting - IP detection tests (8 tests) - Rate limit key generation tests (3 tests) - Login rate limiting tests (3 tests) - OTP rate limiting tests (2 tests) - Security validation tests (2 tests) - Priority order tests (2 tests) Total: 20 tests Closes #1410 --- tests/Feature/RateLimitingTest.php | 318 +++++++++++++++++++++++++++++ 1 file changed, 318 insertions(+) create mode 100644 tests/Feature/RateLimitingTest.php diff --git a/tests/Feature/RateLimitingTest.php b/tests/Feature/RateLimitingTest.php new file mode 100644 index 000000000..805796c21 --- /dev/null +++ b/tests/Feature/RateLimitingTest.php @@ -0,0 +1,318 @@ +logout(); +}); + +// ============================================ +// IP Detection Tests +// ============================================ + +describe('IpAddress Helper - IP Detection', function () { + test('detects cloudflare connecting ip', function () { + $request = Request::create('/login', 'POST'); + $request->headers->set('CF-Connecting-IP', '1.2.3.4'); + + $ip = IpAddress::getRealIp($request); + + expect($ip)->toBe('1.2.3.4'); + })->group('security', 'ip-detection', 'cloudflare'); + + test('detects x-forwarded-for header', function () { + $request = Request::create('/login', 'POST'); + $request->headers->set('X-Forwarded-For', '5.6.7.8'); + + $ip = IpAddress::getRealIp($request); + + expect($ip)->toBe('5.6.7.8'); + })->group('security', 'ip-detection', 'proxy'); + + test('parses first ip from x-forwarded-for with multiple ips', function () { + $request = Request::create('/login', 'POST'); + $request->headers->set('X-Forwarded-For', '1.2.3.4, 10.0.0.1, 172.16.0.1'); + + $ip = IpAddress::getRealIp($request); + + expect($ip)->toBe('1.2.3.4'); + })->group('security', 'ip-detection', 'proxy'); + + test('detects x-real-ip header', function () { + $request = Request::create('/login', 'POST'); + $request->headers->set('X-Real-IP', '9.10.11.12'); + + $ip = IpAddress::getRealIp($request); + + expect($ip)->toBe('9.10.11.12'); + })->group('security', 'ip-detection', 'nginx'); + + test('falls back to request ip when no proxy headers', function () { + $request = Request::create('/login', 'POST', [], [], [], ['REMOTE_ADDR' => '192.168.1.100']); + + $ip = IpAddress::getRealIp($request); + + expect($ip)->toBe('192.168.1.100'); + })->group('security', 'ip-detection'); + + test('rejects invalid ip format', function () { + $request = Request::create('/login', 'POST'); + $request->headers->set('X-Forwarded-For', '">'); + + $ip = IpAddress::getRealIp($request); + + expect($ip)->not->toBe('">'); + })->group('security', 'ip-validation', 'xss'); + + test('filters private ips when trustPrivateIp is false', function () { + $request = Request::create('/login', 'POST'); + $request->headers->set('X-Forwarded-For', '192.168.1.1'); + + $ip = IpAddress::getRealIp($request, trustPrivateIp: false); + + // Should fall back to $request->ip() instead + expect($ip)->not->toBe('192.168.1.1'); + })->group('security', 'ip-detection', 'private-ip'); + + test('accepts private ips when trustPrivateIp is true', function () { + $request = Request::create('/login', 'POST'); + $request->server->set('REMOTE_ADDR', '192.168.1.100'); + + $ip = IpAddress::getRealIp($request, trustPrivateIp: true); + + expect($ip)->toBe('192.168.1.100'); + })->group('security', 'ip-detection', 'private-ip'); +}); + +// ============================================ +// Rate Limit Key Generation Tests +// ============================================ + +describe('IpAddress Helper - Rate Limit Key', function () { + test('generates rate limit key with ip and email', function () { + $request = Request::create('/login', 'POST'); + $request->headers->set('CF-Connecting-IP', '1.2.3.4'); + $request->merge(['email' => 'user@example.com']); + + $key = IpAddress::getRateLimitKey($request, 'user@example.com'); + + expect($key)->toBe('1.2.3.4|user@example.com'); + })->group('security', 'rate-limit', 'key-generation'); + + test('sanitizes email in rate limit key', function () { + $request = Request::create('/login', 'POST'); + $request->headers->set('CF-Connecting-IP', '1.2.3.4'); + + $key = IpAddress::getRateLimitKey($request, ' User@Example.COM '); + + expect($key)->toBe('1.2.3.4|user@example.com'); + })->group('security', 'rate-limit', 'sanitization'); + + test('generates key with only ip when email is null', function () { + $request = Request::create('/login', 'POST'); + $request->headers->set('CF-Connecting-IP', '1.2.3.4'); + + $key = IpAddress::getRateLimitKey($request); + + expect($key)->toBe('1.2.3.4'); + })->group('security', 'rate-limit', 'key-generation'); +}); + +// ============================================ +// Login Rate Limiting Tests +// ============================================ + +describe('Login Rate Limiting', function () { + test('allows login within rate limit', function () { + $user = User::factory()->create([ + 'password' => bcrypt('password'), + ]); + + // Attempt login 5 times (under the limit of 10) + for ($i = 0; $i < 5; $i++) { + $response = $this->post(route('login'), [ + 'email' => $user->email, + 'password' => 'wrongpassword', + ]); + $this->assertNotEquals(429, $response->status()); + } + })->group('security', 'rate-limit', 'login'); + + test('blocks login after rate limit exceeded', function () { + $user = User::factory()->create([ + 'password' => bcrypt('password'), + ]); + + // Clear any existing rate limits + Cache::flush(); + + // Attempt login 11 times (exceeds limit of 10) + $statusCode = null; + for ($i = 0; $i < 12; $i++) { + $response = $this->post(route('login'), [ + 'email' => $user->email, + 'password' => 'wrongpassword', + ]); + $statusCode = $response->status(); + } + + // Last request should be rate limited + expect($statusCode)->toBe(429); + })->group('security', 'rate-limit', 'login'); + + test('different email bypasses rate limit', function () { + $user1 = User::factory()->create(['email' => 'user1@example.com']); + $user2 = User::factory()->create(['email' => 'user2@example.com']); + + // Exhaust rate limit for user1 + for ($i = 0; $i < 11; $i++) { + $this->post(route('login'), [ + 'email' => 'user1@example.com', + 'password' => 'wrong', + ]); + } + + // user2 should still be able to attempt login + $response = $this->post(route('login'), [ + 'email' => 'user2@example.com', + 'password' => 'wrong', + ]); + + $this->assertNotEquals(429, $response->status()); + })->group('security', 'rate-limit', 'login'); +}); + +// ============================================ +// OTP Rate Limiting Tests +// ============================================ + +describe('OTP Rate Limiting', function () { + test('allows otp request within rate limit', function () { + $user = User::factory()->create(); + + // Request OTP 2 times (under the limit of 3) + for ($i = 0; $i < 2; $i++) { + $response = $this->post(route('otp.request-login'), [ + 'email' => $user->email, + ]); + $this->assertNotEquals(429, $response->status()); + } + })->group('security', 'rate-limit', 'otp'); + + test('blocks otp request after rate limit exceeded', function () { + $user = User::factory()->create(); + + // Clear any existing rate limits + Cache::flush(); + + // Request OTP 4 times (exceeds limit of 3) + $statusCode = null; + for ($i = 0; $i < 4; $i++) { + $response = $this->post(route('otp.request-login'), [ + 'email' => $user->email, + ]); + $statusCode = $response->status(); + } + + // Last request should be rate limited + expect($statusCode)->toBe(429); + })->group('security', 'rate-limit', 'otp'); +}); + +// ============================================ +// Security Validation Tests +// ============================================ + +describe('Security - Header Injection Prevention', function () { + test('rejects script injection in x-forwarded-for', function () { + $maliciousInputs = [ + '">', + 'javascript:alert(1)', + '../../etc/passwd', + '\x00\x01\x02', + 'very.long.ip.address.that.exceeds.maximum.length.for.ipv6.addresses.and.should.be.rejected.by.the.validator', + ]; + + foreach ($maliciousInputs as $input) { + $request = Request::create('/login', 'POST'); + $request->headers->set('X-Forwarded-For', $input); + + $ip = IpAddress::getRealIp($request); + + // Should not return the malicious input + expect($ip)->not->toBe($input); + } + })->group('security', 'validation', 'injection'); + + test('validates ipv4 format', function () { + $request = Request::create('/login', 'POST'); + $request->headers->set('CF-Connecting-IP', '256.256.256.256'); // Invalid IP + + $ip = IpAddress::getRealIp($request); + + expect($ip)->not->toBe('256.256.256.256'); + })->group('security', 'validation', 'ipv4'); +}); + +// ============================================ +// Priority Tests +// ============================================ + +describe('IP Detection Priority Order', function () { + test('cf-connecting-ip has highest priority', function () { + $request = Request::create('/login', 'POST'); + $request->headers->set('CF-Connecting-IP', '1.2.3.4'); + $request->headers->set('X-Forwarded-For', '5.6.7.8'); + $request->headers->set('X-Real-IP', '9.10.11.12'); + + $ip = IpAddress::getRealIp($request); + + expect($ip)->toBe('1.2.3.4'); // CF-Connecting-IP wins + })->group('security', 'ip-detection', 'priority'); + + test('x-forwarded-for has priority over x-real-ip', function () { + $request = Request::create('/login', 'POST'); + $request->headers->set('X-Forwarded-For', '5.6.7.8'); + $request->headers->set('X-Real-IP', '9.10.11.12'); + + $ip = IpAddress::getRealIp($request); + + expect($ip)->toBe('5.6.7.8'); // X-Forwarded-For wins + })->group('security', 'ip-detection', 'priority'); +});