diff --git a/.env.example b/.env.example index b144fcfaf..f701947ff 100644 --- a/.env.example +++ b/.env.example @@ -76,3 +76,21 @@ RECAPTCHAV3_SECRET= # OTP Configuration OTP_EXPIRY_MINUTES=5 TELEGRAM_BOT_TOKEN= + +# Security Configuration +# Trusted Proxies: Kosongkan untuk tidak trust proxy apapun (default). +# Untuk production di belakang reverse proxy, set ke IP spesifik proxy. +# Contoh Cloudflare IP ranges: https://www.cloudflare.com/ips/ +# Contoh multiple proxies: 103.21.244.0/22,103.22.200.0/22 +# PERINGATAN: Jangan gunakan '*' di production karena risiko IP spoofing. +# 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 diff --git a/app/Helpers/IpAddress.php b/app/Helpers/IpAddress.php new file mode 100644 index 000000000..62f821baa --- /dev/null +++ b/app/Helpers/IpAddress.php @@ -0,0 +1,230 @@ +ip() ?? $request->server('REMOTE_ADDR', '127.0.0.1'); + } + + /** + * Ekstrak IP dari header proxy yang tersedia. + */ + private static function extractIpFromProxyHeaders(Request $request): ?string + { + foreach (self::PROXY_HEADERS as $header) { + $headerValue = $request->server->get('HTTP_'.$header); + + if ($headerValue === null || $headerValue === '') { + continue; + } + + // X-Forwarded-For bisa berisi multiple IP, ambil yang pertama (client IP) + if ($header === 'X_FORWARDED_FOR') { + $firstIp = self::parseFirstIp($headerValue); + + if ($firstIp !== null && $firstIp !== '') { + return $firstIp; + } + + continue; + } + + // CF-Connecting-IP dan X-Real-IP berisi single IP + $cleaned = trim($headerValue); + + if ($cleaned !== '') { + return $cleaned; + } + } + + return null; + } + + /** + * Parse IP pertama dari daftar comma-separated (X-Forwarded-For). + */ + private static function parseFirstIp(string $ipList): ?string + { + if (str_contains($ipList, ',')) { + $ips = explode(',', $ipList); + $first = trim($ips[0]); + + return $first !== '' ? $first : null; + } + + return trim($ipList); + } + + /** + * Validasi format alamat IP (IPv4 atau IPv6). + */ + private static function isValidIpAddress(string $ip): bool + { + return filter_var($ip, FILTER_VALIDATE_IP) !== false; + } + + /** + * Bersihkan alamat IP dari port atau karakter tidak valid. + * + * Menangani kasus: + * - IPv4 dengan port (contoh: 192.168.1.1:8080) + * - IPv6 dengan port (contoh: [::1]:8080) + * - Header injection attempts (newline characters) + * - IPv4-mapped IPv6 addresses (contoh: ::ffff:192.168.1.1) + */ + private static function cleanIpAddress(string $ip): string + { + // Hapus karakter newline dan control characters untuk mencegah header injection + $cleaned = preg_replace('/[\r\n\t\x00-\x1F\x7F]/', '', $ip); + + if ($cleaned === null) { + return $ip; + } + + $cleaned = trim($cleaned); + + // Handle IPv6 dengan port: [::1]:8080 + if (str_starts_with($cleaned, '[')) { + $bracketPos = strpos($cleaned, ']'); + + if ($bracketPos !== false) { + return substr($cleaned, 1, $bracketPos - 1); + } + } + + // Handle IPv4 dengan port: 192.168.1.1:8080 + // Hanya jika ada tepat satu colon dan valid IPv4 + if (substr_count($cleaned, ':') === 1) { + $colonPos = strrpos($cleaned, ':'); + $possibleIp = substr($cleaned, 0, $colonPos); + + if (filter_var($possibleIp, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false) { + return $possibleIp; + } + } + + // Handle IPv4-mapped IPv6: ::ffff:192.168.1.1 + // Jangan split colon untuk pure IPv6 addresses + if (filter_var($cleaned, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false) { + return $cleaned; + } + + return $cleaned; + } + + /** + * Generate rate limit key dengan sanitasi ketat. + * + * Key format: "rate_limit:{ip}|{identifier}" atau "rate_limit:{ip}" + */ + public static function getRateLimitKey(Request $request, ?string $identifier = null): string + { + $ip = self::getRealIp($request); + + if ($identifier !== null && $identifier !== '') { + // Sanitasi identifier - batasi panjang dan karakter + $identifier = self::sanitizeIdentifier($identifier); + + return "rate_limit:{$ip}|{$identifier}"; + } + + return "rate_limit:{$ip}"; + } + + /** + * Sanitasi identifier untuk rate limiting. + * + * - Hapus null bytes dan control characters + * - Batasi panjang maksimal 320 karakter (RFC 5321) + * - Hanya izinkan karakter alphanumeric dan @._- + */ + private static function sanitizeIdentifier(string $identifier): string + { + // Batasi panjang maksimal + if (strlen($identifier) > 320) { + $identifier = substr($identifier, 0, 320); + } + + // Hapus null bytes dan control characters + $sanitized = preg_replace('/[\x00-\x1F\x7F]/', '', $identifier); + + if ($sanitized === null) { + return ''; + } + + // Hanya izinkan karakter yang aman + $sanitized = preg_replace('/[^a-z0-9@._-]/i', '', $sanitized); + + return $sanitized !== null ? $sanitized : ''; + } +} diff --git a/app/Http/Middleware/TrustProxies.php b/app/Http/Middleware/TrustProxies.php index 6bc386d8b..cb0fae402 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,11 +34,25 @@ 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 reverse proxy + * seperti Nginx, Cloudflare, atau load balancer. + * + * Secure by default: tidak mempercayai proxy apapun kecuali dikonfigurasi eksplisit. + * + * @see https://laravel.com/docs/10.x/requests#configuring-trusted-proxies + */ class TrustProxies extends Middleware { /** * The trusted proxies for this application. * + * Secure by default: null berarti tidak trust proxy manapun. + * Untuk production di belakang reverse proxy, set TRUST_PROXIES + * ke IP spesifik proxy (comma-separated) atau gunakan Cloudflare IP ranges. + * * @var array|string|null */ protected $proxies; @@ -46,12 +60,45 @@ class TrustProxies extends Middleware /** * The headers that should be used to detect proxies. * + * Header standar yang dipakai Laravel untuk membaca original client IP + * dari trusted proxy. + * * @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 + * + * PENTING: TRUST_PROXIES=* TIDAK diizinkan karena risiko IP spoofing. + * Jika developer set '*' di environment, akan diabaikan dan return null. + * + * @return array|string|null + */ + protected function proxies() + { + $envProxies = env('TRUST_PROXIES'); + + if ($envProxies === null || $envProxies === '' || $envProxies === '*') { + // Return null: tidak trust proxy headers dari manapun + return null; + } + + // Validasi format IP/CIDR sebelum trust + $proxies = array_map('trim', explode(',', $envProxies)); + $validProxies = []; + + foreach ($proxies as $proxy) { + // Validasi IPv4, IPv6, atau CIDR range + if (filter_var($proxy, FILTER_VALIDATE_IP) || preg_match('/^[\da-fA-F.:]+\/\d+$/', $proxy)) { + $validProxies[] = $proxy; + } + } + + return empty($validProxies) ? null : array_values($validProxies); + } } diff --git a/app/Mail/OtpMail.php b/app/Mail/OtpMail.php index 84e41d659..8000171de 100644 --- a/app/Mail/OtpMail.php +++ b/app/Mail/OtpMail.php @@ -54,7 +54,7 @@ public function __construct(int $otp, string $purpose = 'login') { $this->otp = $otp; $this->purpose = $purpose; - $this->expiryMinutes = config('otp.expiry_minutes', 5); + $this->expiryMinutes = max(1, (int) config('otp.expiry_minutes', 5)); } /** diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php index cca2c9c15..904b935b7 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; @@ -80,5 +81,74 @@ protected function configureRateLimiting() RateLimiter::for('api', function (Request $request) { return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip()); }); + + RateLimiter::for('login', function (Request $request) { + $maxAttempts = max(1, (int) env('RATE_LIMIT_LOGIN_MAX', 10)); + $decayMinutes = max(1, (int) env('RATE_LIMIT_LOGIN_DECAY', 1)); + + $identifier = $this->extractAndValidateIdentifier($request); + + return Limit::perMinute($maxAttempts, $decayMinutes) + ->by(IpAddress::getRateLimitKey($request, $identifier)); + }); + + RateLimiter::for('otp', function (Request $request) { + $maxAttempts = max(1, (int) env('RATE_LIMIT_OTP_MAX', 3)); + $decayMinutes = max(1, (int) env('RATE_LIMIT_OTP_DECAY', 1)); + + $identifier = $this->extractAndValidateIdentifier($request); + + return Limit::perMinute($maxAttempts, $decayMinutes) + ->by(IpAddress::getRateLimitKey($request, $identifier)); + }); + } + + /** + * Extract dan validasi identifier (email/username) dari request. + * + * Validasi: + * - Harus string, bukan array atau tipe lain + * - Batasi panjang maksimal 320 karakter (RFC 5321) + * - Hapus null bytes dan control characters + * - Validasi format email jika terdeteksi sebagai email + */ + protected function extractAndValidateIdentifier(Request $request): ?string + { + $identifier = $request->input('email') + ?? $request->input('username') + ?? $request->input('identity') + ?? $request->input('identifier') + ?? data_get($request->session()->get('two-factor:auth'), 'email') + ?? data_get($request->session()->get('two-factor:auth'), 'id') + ?? data_get($request->session()->get('otp_login'), 'user_id'); + + // Return null jika tidak ada + if ($identifier === null) { + return null; + } + + // Validasi tipe data - harus string + if (! is_string($identifier)) { + return null; + } + + // Batasi panjang maksimal (RFC 5321: 320 characters) + if (strlen($identifier) > 320) { + $identifier = substr($identifier, 0, 320); + } + + // Hapus null bytes dan control characters + $identifier = preg_replace('/[\x00-\x1F\x7F]/', '', $identifier); + + if ($identifier === null) { + return null; + } + + $identifier = mb_strtolower(trim($identifier)); + + // Hanya izinkan karakter yang aman untuk rate limit key + $result = preg_replace('/[^a-z0-9@._-]/', '', $identifier); + + return ($result !== null && $result !== '') ? $result : null; } } diff --git a/app/Services/OtpService.php b/app/Services/OtpService.php index 00ff583d4..3c50e5710 100644 --- a/app/Services/OtpService.php +++ b/app/Services/OtpService.php @@ -75,7 +75,7 @@ public function generateAndSave(User $user, string $channel, string $identifier, ->delete(); // Create new token - $expiryMinutes = config('otp.expiry_minutes', 5); + $expiryMinutes = max(1, (int) config('otp.expiry_minutes', 5)); $token = OtpToken::create([ 'user_id' => $user->id, 'token_hash' => $tokenHash, diff --git a/routes/web.php b/routes/web.php index e0e731aa8..8c6f5dca4 100644 --- a/routes/web.php +++ b/routes/web.php @@ -95,7 +95,11 @@ 'register' => false, ]); - // OTP Routes + Route::namespace('\App\Http\Controllers\Auth')->middleware('guest')->group(function () { + Route::post('/login', 'LoginController@login') + ->middleware('throttle:login'); + }); + Route::namespace('\App\Http\Controllers\Auth')->middleware('otp.enabled')->group(function () { // OTP Activation (requires auth) Route::middleware('auth')->group(function () { @@ -107,16 +111,19 @@ Route::get('/otp/deactivate', 'OtpController@deactivate')->name('otp.deactivate'); }); - // OTP Login (guest only) Route::middleware('guest')->group(function () { Route::get('/otp/login', 'OtpController@showLoginForm')->name('otp.login'); - Route::post('/otp/request-login', 'OtpController@requestLoginOtp')->name('otp.request-login'); + Route::post('/otp/request-login', 'OtpController@requestLoginOtp') + ->middleware('throttle:otp') + ->name('otp.request-login'); Route::get('/otp/verify-login', 'OtpController@showVerifyLoginForm')->name('otp.verify-login'); - Route::post('/otp/verify-login', 'OtpController@loginWithOtp'); + Route::post('/otp/verify-login', 'OtpController@loginWithOtp') + ->middleware('throttle:login'); }); - // OTP Resend (both auth and guest) - Route::post('/otp/resend', 'OtpController@resendOtp')->name('otp.resend'); + Route::post('/otp/resend', 'OtpController@resendOtp') + ->middleware('throttle:otp') + ->name('otp.resend'); }); // 2FA Routes @@ -137,18 +144,10 @@ Route::get('/2fa/deactivate', 'TwoFactorController@deactivate')->name('2fa.deactivate'); }); - // Profile Routes (User Profile Management) - Route::namespace('\App\Http\Controllers\Auth')->prefix('profile')->middleware('auth')->group(function () { - Route::get('password', 'ProfileController@password')->name('profile.password'); - Route::post('password', 'ProfileController@updatePassword') - ->middleware('throttle:5,1') - ->name('profile.password.update'); - }); - - // 2FA Login Routes (guest access) Route::namespace('\App\Http\Controllers\Auth')->middleware('guest')->group(function () { Route::get('/2fa/verify-login', 'TwoFactorController@showVerifyLoginForm')->name('2fa.verify-login'); - Route::post('/2fa/verify-login', 'TwoFactorController@verifyLogin'); + Route::post('/2fa/verify-login', 'TwoFactorController@verifyLogin') + ->middleware('throttle:login'); }); Route::group(['prefix' => 'filemanager', 'middleware' => ['auth:web', 'role:administrator-website|super-admin|admin-kecamatan']], function () { @@ -530,19 +529,6 @@ Route::get('export-excel', ['as' => 'data.data-desa.export-excel', 'uses' => 'DataDesaController@exportExcel']); }); - // Data Sarana - Route::group(['prefix' => 'data-sarana', 'excluded_middleware' => 'xss_sanitization', 'middleware' => ['role:super-admin|data-kecamatan']], function () { - Route::get('/', ['as' => 'data.data-sarana.index', 'uses' => 'DataSaranaController@index']); - Route::get('getdata', ['as' => 'data.data-sarana.getdata', 'uses' => 'DataSaranaController@getData']); - Route::get('create', ['as' => 'data.data-sarana.create', 'uses' => 'DataSaranaController@create']); - Route::post('store', ['as' => 'data.data-sarana.store', 'uses' => 'DataSaranaController@store']); - Route::get('edit/{id}', ['as' => 'data.data-sarana.edit', 'uses' => 'DataSaranaController@edit']); - Route::put('update/{id}', ['as' => 'data.data-sarana.update', 'uses' => 'DataSaranaController@update']); - Route::delete('destroy/{id}', ['as' => 'data.data-sarana.destroy', 'uses' => 'DataSaranaController@destroy']); - Route::get('import', ['as' => 'data.data-sarana.import', 'uses' => 'DataSaranaController@import']); - Route::post('import-excel', ['as' => 'data.data-sarana.import-excel', 'uses' => 'DataSaranaController@importExcel']); - }); - // Jabatan Route::resource('jabatan', 'JabatanController', ['as' => 'data'])->middleware(['role:super-admin|admin-kecamatan'])->except(['show']); diff --git a/tests/Feature/RateLimitingTest.php b/tests/Feature/RateLimitingTest.php new file mode 100644 index 000000000..fd9b6f8cd --- /dev/null +++ b/tests/Feature/RateLimitingTest.php @@ -0,0 +1,184 @@ +logout(); + Mail::fake(); +}); + +test('login is throttled after too many failed attempts for the same email', function () { + $user = User::factory()->create([ + 'email' => 'login-throttle@example.com', + 'password' => Hash::make('password'), + 'status' => 1, + ]); + + for ($attempt = 0; $attempt < 10; $attempt++) { + $response = $this->from(route('login'))->post(route('login'), [ + 'email' => $user->email, + 'password' => 'wrong-password', + ]); + + $response->assertRedirect(route('login')); + } + + $this->post(route('login'), [ + 'email' => $user->email, + 'password' => 'wrong-password', + ])->assertStatus(429); +}); + +test('login rate limit keeps separate buckets for different emails on the same ip', function () { + $throttledUser = User::factory()->create([ + 'email' => 'login-throttle-a@example.com', + 'password' => Hash::make('password'), + 'status' => 1, + ]); + + $otherUser = User::factory()->create([ + 'email' => 'login-throttle-b@example.com', + 'password' => Hash::make('password'), + 'status' => 1, + ]); + + for ($attempt = 0; $attempt < 10; $attempt++) { + $this->from(route('login'))->post(route('login'), [ + 'email' => $throttledUser->email, + 'password' => 'wrong-password', + ])->assertRedirect(route('login')); + } + + $this->post(route('login'), [ + 'email' => $throttledUser->email, + 'password' => 'wrong-password', + ])->assertStatus(429); + + $this->from(route('login'))->post(route('login'), [ + 'email' => $otherUser->email, + 'password' => 'wrong-password', + ])->assertRedirect(route('login')); +}); + +test('otp request is throttled after too many attempts for the same identifier', function () { + $user = User::factory()->create([ + 'email' => 'otp-throttle@example.com', + 'name' => 'otp-throttle', + 'password' => Hash::make('password'), + 'status' => 1, + 'otp_enabled' => true, + 'otp_verified' => true, + 'otp_channel' => 'email', + ]); + + for ($attempt = 0; $attempt < 3; $attempt++) { + $this->post(route('otp.request-login'), [ + 'identifier' => $user->email, + ])->assertRedirect(route('otp.verify-login')); + } + + $this->post(route('otp.request-login'), [ + 'identifier' => $user->email, + ])->assertStatus(429); +}); + +test('otp request rate limit keeps separate buckets for different identifiers on the same ip', function () { + $throttledUser = User::factory()->create([ + 'email' => 'otp-throttle-a@example.com', + 'name' => 'otp-throttle-a', + 'password' => Hash::make('password'), + 'status' => 1, + 'otp_enabled' => true, + 'otp_verified' => true, + 'otp_channel' => 'email', + ]); + + $otherUser = User::factory()->create([ + 'email' => 'otp-throttle-b@example.com', + 'name' => 'otp-throttle-b', + 'password' => Hash::make('password'), + 'status' => 1, + 'otp_enabled' => true, + 'otp_verified' => true, + 'otp_channel' => 'email', + ]); + + for ($attempt = 0; $attempt < 3; $attempt++) { + $this->post(route('otp.request-login'), [ + 'identifier' => $throttledUser->email, + ])->assertRedirect(route('otp.verify-login')); + } + + $this->post(route('otp.request-login'), [ + 'identifier' => $throttledUser->email, + ])->assertStatus(429); + + $this->post(route('otp.request-login'), [ + 'identifier' => $otherUser->email, + ])->assertRedirect(route('otp.verify-login')); +}); + +test('2fa verification is throttled using the pending authentication session', function () { + $user = User::factory()->create([ + 'email' => '2fa-throttle@example.com', + 'password' => Hash::make('password'), + 'status' => 1, + 'two_fa_enabled' => true, + 'otp_verified' => true, + 'otp_channel' => 'email', + ]); + + app(OtpService::class)->generateAndSave($user, 'email', $user->email, '2fa_login'); + + session([ + 'two-factor:auth' => [ + 'id' => $user->id, + 'email' => $user->email, + ], + ]); + + for ($attempt = 0; $attempt < 10; $attempt++) { + $this->from(route('2fa.verify-login'))->post(route('2fa.verify-login'), [ + 'otp' => '000000', + ])->assertRedirect(); + } + + $this->post(route('2fa.verify-login'), [ + 'otp' => '000000', + ])->assertStatus(429); +}); diff --git a/tests/Feature/SistemKomplainControllerTest.php b/tests/Feature/SistemKomplainControllerTest.php index 79a21cba9..4da19af8f 100644 --- a/tests/Feature/SistemKomplainControllerTest.php +++ b/tests/Feature/SistemKomplainControllerTest.php @@ -60,7 +60,7 @@ // Mock ID agar direktori cocok $komplainId = 999123; - $path = public_path("storage/komplain/$komplainId"); + $path = base_path("storage/komplain/$komplainId"); File::ensureDirectoryExists($path); // Buat direktori agar move() tidak error // Buat file dummy @@ -108,7 +108,7 @@ ]); // Hapus data - File::deleteDirectory(public_path("storage/komplain/$komplainId")); + File::deleteDirectory($path); Komplain::where('judul', $data['judul'])->delete(); $this->assertDatabaseMissing($this->tableName, [