-
Notifications
You must be signed in to change notification settings - Fork 143
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 all 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
Some comments aren't visible on the classic Files Changed page.
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,230 @@ | ||
| <?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; | ||
|
|
||
| /** | ||
| * Helper untuk mendeteksi IP asli klien di balik proxy. | ||
| * | ||
| * Mendeteksi IP dari header proxy yang dipercaya: | ||
| * - CF-Connecting-IP (Cloudflare) | ||
| * - X-Real-IP (Nginx) | ||
| * - X-Forwarded-For (standar) | ||
| * | ||
| * Keamanan: | ||
| * - Hanya membaca header jika TrustProxies middleware dikonfigurasi dengan benar | ||
| * - Sanitasi header untuk mencegah header injection | ||
| * - Validasi format IPv4 dan IPv6 | ||
| * - Filter IP private/internal untuk rate limiting | ||
| */ | ||
| class IpAddress | ||
| { | ||
| /** | ||
| * Header yang dicek secara berurutan untuk mendapatkan IP asli. | ||
| */ | ||
| private const PROXY_HEADERS = [ | ||
| 'CF_CONNECTING_IP', | ||
| 'X_REAL_IP', | ||
| 'X_FORWARDED_FOR', | ||
| ]; | ||
|
|
||
| /** | ||
| * Dapatkan IP asli klien dari request, dengan fallback ke remote address. | ||
| * | ||
| * Metode ini hanya membaca header yang sudah divalidasi oleh Laravel's | ||
| * TrustProxies middleware. Jika proxy tidak dipercaya, Laravel akan | ||
| * mengabaikan header tersebut dan menggunakan remote_addr. | ||
| */ | ||
| public static function getRealIp(Request $request): string | ||
| { | ||
| $ip = self::extractIpFromProxyHeaders($request); | ||
|
|
||
| if ($ip !== null && self::isValidIpAddress($ip)) { | ||
| return self::cleanIpAddress($ip); | ||
| } | ||
|
|
||
| return $request->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 : ''; | ||
| } | ||
| } |
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
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.