-
Notifications
You must be signed in to change notification settings - Fork 146
Security/rate limiting proxy support #1478
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Revanza1106
wants to merge
12
commits into
OpenSID:dev
Choose a base branch
from
Revanza1106:security/rate-limiting-proxy-support
base: dev
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 7 commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
1edd66a
feat(security): add IpAddress helper for proxy IP detection
Revanza1106 6e7fe8d
feat(security): update TrustProxies for proxy support
Revanza1106 da96e69
feat(security): add rate limiters for login and OTP
Revanza1106 03732de
feat(security): apply throttle middleware to login and OTP routes
Revanza1106 6d28a70
feat(security): add rate limiting config to env example
Revanza1106 8f8de06
test(security): add Pest tests for rate limiting
Revanza1106 2d5fe03
Merge branch 'dev' into security/rate-limiting-proxy-support
Revanza1106 bd4ef79
fix(security): align auth rate limiting with current Laravel flow
Revanza1106 656e0ab
fix(otp): cast expiry configuration for PHP 8.3
Revanza1106 cafca3b
test(ci): stabilize seeded database and upload path checks
Revanza1106 4584755
fix(security): address critical security review issues from PR #1478
Revanza1106 7b78830
fix(security): address all maintainer review findings from PR #1478
Revanza1106 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,295 @@ | ||
| <?php | ||
|
|
||
| /* | ||
| * File ini bagian dari: | ||
| * | ||
| * OpenDK | ||
| * | ||
| * Aplikasi dan source code ini dirilis berdasarkan lisensi GPL V3 | ||
| * | ||
| * 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 | ||
| * tanpa batasan, termasuk hak untuk menggunakan, menyalin, mengubah dan/atau mendistribusikan, | ||
| * asal tunduk pada syarat berikut: | ||
| * | ||
| * Pemberitahuan hak cipta di atas dan pemberitahuan izin ini harus disertakan dalam | ||
| * setiap salinan atau bagian penting Aplikasi Ini. Barang siapa yang menghapus atau menghilangkan | ||
| * pemberitahuan ini melanggar ketentuan lisensi Aplikasi Ini. | ||
| * | ||
| * PERANGKAT LUNAK INI DISEDIAKAN "SEBAGAIMANA ADANYA", TANPA JAMINAN APA PUN, BAIK TERSURAT MAUPUN | ||
| * TERSIRAT. PENULIS ATAU PEMEGANG HAK CIPTA SAMA SEKALI TIDAK BERTANGGUNG JAWAB ATAS KLAIM, KERUSAKAN ATAU | ||
| * KEWAJIBAN APAPUN ATAS PENGGUNAAN ATAU LAINNYA TERKAIT APLIKASI INI. | ||
| * | ||
| * @package OpenDK | ||
| * @author Tim Pengembang OpenDesa | ||
| * @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\Helpers; | ||
|
|
||
| use Illuminate\Http\Request; | ||
| use Illuminate\Support\Facades\Log; | ||
|
|
||
| /** | ||
| * Helper untuk mendapatkan IP address asli dari request | ||
| * | ||
| * Menangani kasus ketika aplikasi berada di belakang: | ||
| * - Cloudflare Proxy (CDN) | ||
| * - Reverse Proxy (Nginx, Apache, Load Balancer) | ||
| * - AWS ELB/ALB | ||
| * | ||
| * @package App\Helpers | ||
|
Revanza1106 marked this conversation as resolved.
Outdated
Revanza1106 marked this conversation as resolved.
Outdated
|
||
| */ | ||
| class IpAddress | ||
| { | ||
| /** | ||
| * Daftar header yang dicek secara berurutan untuk mendapatkan IP asli | ||
| * | ||
| * Urutan prioritas: | ||
|
Revanza1106 marked this conversation as resolved.
Outdated
Revanza1106 marked this conversation as resolved.
Outdated
|
||
| * 1. CF-Connecting-IP - Cloudflare (paling reliable untuk CF) | ||
| * 2. True-Client-IP - Cloudflare Enterprise | ||
| * 3. X-Forwarded-For - Reverse proxy umum | ||
| * 4. X-Real-IP - Nginx | ||
| * 5. HTTP_X_FORWARDED_FOR - Beberapa proxy lama | ||
| * | ||
| * @var array<int, string> | ||
| */ | ||
| 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<int, string> | ||
| */ | ||
| 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; | ||
| } | ||
| } | ||
|
Revanza1106 marked this conversation as resolved.
|
||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.