Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
230 changes: 230 additions & 0 deletions app/Helpers/IpAddress.php
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 : '';
}
}
55 changes: 51 additions & 4 deletions app/Http/Middleware/TrustProxies.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -24,7 +24,7 @@
*
Comment thread
Revanza1106 marked this conversation as resolved.
* @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
*/
Expand All @@ -34,24 +34,71 @@
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<int, string>|string|null
*/
protected $proxies;

/**
* 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<int, string>|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);
}
}
2 changes: 1 addition & 1 deletion app/Mail/OtpMail.php
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}

/**
Expand Down
Loading
Loading