Langsung ke konten
KamusNgoding
Mahir Laravel 4 menit baca

Keamanan Laravel API: Rate Limiting, Validasi Input, dan Proteksi dari Serangan Umum

#laravel #security #api #rate-limiting #xss #sql-injection #csrf #advanced

Pendahuluan

“API saya sudah pakai Sanctum, berarti sudah aman kan?”

Autentikasi adalah lapisan pertama keamanan — bukan satu-satunya. Bayangkan pintu depan dengan kunci terbaik, tapi jendela belakang dibiarkan terbuka.

Serangan nyata tidak selalu menyerang autentikasi langsung. Mereka mencari celah lain:

  • Brute force login ribuan kali per detik
  • SQL Injection melalui parameter URL
  • Mass assignment yang secara tidak sengaja mengubah field admin
  • Input XSS yang mencuri session cookie user lain

Artikel ini adalah panduan keamanan praktis untuk aplikasi Laravel production — lengkap dengan kode yang langsung bisa diimplementasikan.


OWASP Top 10 yang Relevan untuk Laravel Developer

OWASP Top 10 adalah daftar 10 kerentanan web paling kritis. Yang paling relevan untuk Laravel:

AncamanContohStatus Laravel Default
Broken Access ControlUser A akses data User BPerlu Policy
Injection (SQL, Command)?id=1 OR 1=1Terlindungi jika pakai Eloquent
Identification FailuresBrute force passwordPerlu Rate Limiting
XSS<script> di input userTerlindungi di Blade {{ }}
Security MisconfigurationAPP_DEBUG=true di productionManual check
Mass AssignmentUser::create($request->all())Perlu $fillable

Langkah 1: Rate Limiting — Cegah Brute Force

Rate limiting membatasi berapa banyak request yang bisa dikirim dalam periode tertentu:

<?php
// app/Providers/AppServiceProvider.php

use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;

public function boot(): void
{
    // Rate limit untuk API umum: 60 request per menit per user/IP
    RateLimiter::for('api', function (Request $request) {
        return Limit::perMinute(60)
            ->by($request->user()?->id ?: $request->ip());
    });

    // Rate limit ketat untuk login: 5 percobaan per menit per IP
    RateLimiter::for('login', function (Request $request) {
        return [
            Limit::perMinute(5)->by($request->ip()),
            // User yang sudah login juga dibatasi berdasarkan email
            Limit::perMinute(10)->by($request->input('email')),
        ];
    });

    // Rate limit untuk endpoint sensitif (reset password, OTP)
    RateLimiter::for('sensitive', function (Request $request) {
        return Limit::perHour(3)->by($request->ip())
            ->response(function (Request $request, array $headers) {
                return response()->json([
                    'message' => 'Terlalu banyak percobaan. Coba lagi dalam 1 jam.',
                ], 429, $headers);
            });
    });
}

Terapkan di routes:

// routes/api.php

Route::post('/login', [AuthController::class, 'login'])
    ->middleware('throttle:login');

Route::post('/password/email', [PasswordController::class, 'sendReset'])
    ->middleware('throttle:sensitive');

// Semua route API sudah otomatis kena throttle:api
Route::middleware(['auth:sanctum', 'throttle:api'])->group(function () {
    // ...
});

Langkah 2: Mencegah SQL Injection

Eloquent ORM secara default menggunakan prepared statements yang melindungi dari SQL injection dasar. Tapi ada case yang perlu perhatian ekstra:

// ✅ AMAN — Eloquent menggunakan parameter binding
$tasks = Task::where('user_id', $userId)->get();
// SQL: SELECT * FROM tasks WHERE user_id = ? [1]

// ✅ AMAN — whereIn, whereBetween, dll.
$tasks = Task::whereIn('status', ['pending', 'completed'])->get();

// ⚠️ BERBAHAYA — jangan masukkan input user langsung ke DB::raw
$tasks = Task::whereRaw("title LIKE '%{$request->search}%'")->get();
// Jika search = "'; DROP TABLE tasks; --" → catastrophe!

// ✅ AMAN — gunakan binding parameter dengan DB::raw
$tasks = Task::whereRaw("title LIKE ?", ["%{$request->search}%"])->get();

// ✅ AMAN — cara lebih bersih dengan Eloquent
$tasks = Task::where('title', 'like', '%' . $request->search . '%')->get();

Validasi Input Sebelum Query

// Selalu validasi dan sanitize sebelum digunakan
$request->validate([
    'search'   => 'nullable|string|max:100',
    'order_by' => 'nullable|in:created_at,title,priority', // Whitelist!
    'per_page' => 'nullable|integer|min:1|max:100',
]);

// Jangan gunakan $request->order_by langsung di orderBy()!
$allowedColumns = ['created_at', 'title', 'priority'];
$orderBy = in_array($request->order_by, $allowedColumns)
    ? $request->order_by
    : 'created_at';

$tasks = Task::orderBy($orderBy)->paginate($request->per_page ?? 15);

Langkah 3: Mencegah XSS

XSS (Cross-Site Scripting) terjadi ketika input user ditampilkan di halaman tanpa di-escape, memungkinkan eksekusi JavaScript berbahaya.

{{-- ✅ AMAN — Blade {{ }} otomatis escape HTML entities --}}
<p>{{ $user->name }}</p>
{{-- Output: &lt;script&gt;alert('xss')&lt;/script&gt; --}}

{{-- ❌ BERBAHAYA — {!! !!} tidak escape apapun --}}
<p>{!! $user->bio !!}</p>
{{-- Output: <script>alert('xss')</script> — BAHAYA! --}}

{{-- Gunakan {!! !!} HANYA jika kamu yang generate HTML-nya --}}
{!! $trustedMarkdown !!}

Jika membutuhkan HTML user (editor WYSIWYG), gunakan sanitizer:

composer require ezyang/htmlpurifier
use HTMLPurifier;
use HTMLPurifier_Config;

// Bersihkan HTML sebelum simpan ke database
$config   = HTMLPurifier_Config::createDefault();
$purifier = new HTMLPurifier($config);

$cleanHtml = $purifier->purify($request->content);
Post::create(['content' => $cleanHtml]);

Langkah 4: Mass Assignment Protection

Mass assignment vulnerability terjadi ketika $request->all() digunakan langsung tanpa filter:

// ❌ SANGAT BERBAHAYA
User::create($request->all());
// Jika request berisi: {"name": "Budi", "email": "[email protected]", "is_admin": true}
// User baru akan menjadi admin!

// ✅ AMAN — hanya izinkan field yang terdaftar di $fillable
User::create($request->only(['name', 'email', 'password']));

// ✅ AMAN — gunakan validated() dari Form Request
User::create($request->validated());
// app/Models/User.php
class User extends Model
{
    // Whitelist: hanya field ini yang bisa di-mass-assign
    protected $fillable = [
        'name', 'email', 'password',
    ];

    // Field yang tidak pernah boleh dikembalikan di JSON (hidden dari response)
    protected $hidden = [
        'password', 'remember_token',
    ];
}

Langkah 5: Security Headers

Tambahkan HTTP headers yang meningkatkan keamanan browser:

php artisan make:middleware SecurityHeaders
<?php
// app/Http/Middleware/SecurityHeaders.php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class SecurityHeaders
{
    public function handle(Request $request, Closure $next): Response
    {
        $response = $next($request);

        $response->headers->set('X-Content-Type-Options', 'nosniff');
        $response->headers->set('X-Frame-Options', 'SAMEORIGIN');
        $response->headers->set('X-XSS-Protection', '1; mode=block');
        $response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
        $response->headers->set('Permissions-Policy', 'geolocation=(), microphone=(), camera=()');

        // Strict-Transport-Security (HSTS) — hanya untuk HTTPS production
        if (app()->isProduction()) {
            $response->headers->set(
                'Strict-Transport-Security',
                'max-age=31536000; includeSubDomains'
            );
        }

        return $response;
    }
}
// bootstrap/app.php (Laravel 11)
->withMiddleware(function (Middleware $middleware) {
    $middleware->append(\App\Http\Middleware\SecurityHeaders::class);
})

Langkah 6: CORS Production-Ready

Konfigurasi CORS yang aman untuk production (berbeda dari konfigurasi dasar di artikel CORS):

// config/cors.php — Production config

return [
    'paths'             => ['api/*', 'sanctum/csrf-cookie'],
    'allowed_methods'   => ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],

    // JANGAN gunakan '*' di production!
    'allowed_origins'   => explode(',', env('CORS_ALLOWED_ORIGINS', '')),
    // Contoh .env: CORS_ALLOWED_ORIGINS=https://app.domain.com,https://admin.domain.com

    'allowed_headers'   => ['Content-Type', 'Authorization', 'X-Requested-With'],
    'exposed_headers'   => ['X-RateLimit-Limit', 'X-RateLimit-Remaining'],
    'max_age'           => 86400,

    // Harus true jika menggunakan Sanctum SPA auth
    'supports_credentials' => true,
];

Langkah 7: Logging Aktivitas Mencurigakan

<?php
// app/Http/Middleware/LogSuspiciousActivity.php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;

class LogSuspiciousActivity
{
    // Pattern yang mencurigakan
    private array $sqlPatterns = [
        '/\bunion\b.*\bselect\b/i',
        '/\bselect\b.*\bfrom\b/i',
        '/drop\s+table/i',
        '/1=1|1\'=\'1/i',
    ];

    private array $xssPatterns = [
        '/<script/i',
        '/javascript:/i',
        '/onerror=/i',
        '/onload=/i',
    ];

    public function handle(Request $request, Closure $next)
    {
        $input = $request->all();
        $inputStr = json_encode($input);

        foreach ([...$this->sqlPatterns, ...$this->xssPatterns] as $pattern) {
            if (preg_match($pattern, $inputStr)) {
                Log::warning('Suspicious input detected', [
                    'ip'      => $request->ip(),
                    'url'     => $request->fullUrl(),
                    'method'  => $request->method(),
                    'user_id' => auth()->id(),
                    'input'   => $inputStr,
                    'pattern' => $pattern,
                ]);
                break;
            }
        }

        return $next($request);
    }
}

Security Checklist untuk Production

ItemPerintah/Cara CekStatus
APP_DEBUG=falsegrep APP_DEBUG .env
APP_ENV=productiongrep APP_ENV .env
HTTPS aktifCek redirect HTTP→HTTPS
Rate limiting aktifTest dengan curl
$fillable di semua modelReview model files
Tidak ada {!! !!} dengan input usergrep -r "{!!" src
Env secrets tidak di-commit.gitignore punya .env
php artisan key:generate sudah dijalankanAPP_KEY tidak kosong
Permission file storage/ benarls -la storage/
X-Frame-Options header aktifcurl -I domain.com

Troubleshooting: Error yang Sering Muncul

Rate limiter tidak bekerja / request selalu lolos

Penyebab: Middleware throttle belum diterapkan, atau RateLimiter::for() tidak dipanggil.

Solusi:

// Pastikan rate limiter terdaftar di AppServiceProvider
// Dan middleware throttle:nama-limiter ada di route

// Untuk test rate limiter:
for i in {1..10}; do
  curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8000/api/login \
    -X POST -H "Content-Type: application/json" \
    -d '{"email":"[email protected]","password":"wrong"}'
done
# Response ke-6 harus 429

Legitimate user terkena rate limit

Penyebab: Rate limit terlalu ketat, atau key berbasis IP saja (masalah shared IP di kantor/kampus).

Solusi:

// Gunakan kombinasi user ID + IP agar lebih akurat
RateLimiter::for('api', function (Request $request) {
    if ($user = $request->user()) {
        return Limit::perMinute(120)->by('user:' . $user->id); // User login: lebih longgar
    }
    return Limit::perMinute(30)->by('ip:' . $request->ip()); // Guest: lebih ketat
});

CORS dan Security Headers konflik

Penyebab: Header CORS ditambahkan dua kali — oleh Laravel dan Nginx.

Solusi:

# Di Nginx, jangan tambahkan CORS header jika Laravel sudah menangani
# HAPUS ini jika ada:
# add_header 'Access-Control-Allow-Origin' '*';

# Biarkan Laravel mengelola CORS sepenuhnya melalui config/cors.php

Pertanyaan yang Sering Diajukan

Apakah Sanctum sudah cukup untuk keamanan API?

Sanctum menangani autentikasi (memverifikasi siapa yang request). Tapi keamanan membutuhkan lapisan lebih: autorisasi (apa yang boleh dilakukan), validasi input (data berbahaya ditolak), dan rate limiting (penyalahgunaan dibatasi). Sanctum adalah fondasi, bukan solusi lengkap.

Bagaimana cara melakukan security audit pada aplikasi Laravel?

Mulai dengan tools otomatis: php artisan security:check (dari package enlightn/enlightn), scan dependency dengan composer audit, dan gunakan Laravel Telescope untuk monitoring request mencurigakan. Untuk audit mendalam, gunakan OWASP ZAP atau hire penetration tester.

Apakah perlu menggunakan WAF (Web Application Firewall)?

Untuk production dengan traffic tinggi atau menyimpan data sensitif, ya. Cloudflare WAF adalah pilihan populer di Indonesia karena mudah di-setup dan gratis untuk tier dasar. WAF menambah lapisan perlindungan sebelum request bahkan sampai ke Laravel.

Bagaimana cara handle data sensitif di log?

Jangan pernah log password, token, atau nomor kartu. Gunakan $hidden di model dan gunakan custom logging di Form Request:

// Otomatis hapus field sensitif dari log
protected function prepareForValidation(): void
{
    $this->merge([
        'password' => '***HIDDEN***',
    ]);
}

Kesimpulan

Keamanan bukan fitur yang ditambahkan di akhir — ini adalah mindset yang harus ada sejak awal development.

Ringkasan lapisan keamanan yang sudah kita bangun:

  1. Rate Limiting — Batasi akses berulang
  2. SQL Injection Prevention — Selalu gunakan parameter binding
  3. XSS Prevention — Escape output, sanitize HTML input
  4. Mass Assignment Protection — Whitelist field dengan $fillable
  5. Security Headers — Instruksikan browser untuk lebih ketat
  6. Activity Logging — Pantau aktivitas mencurigakan

Kombinasikan semua ini dengan autentikasi Sanctum yang sudah dipelajari sebelumnya, dan aplikasi Laravel-mu siap menghadapi ancaman nyata!

Ingat: keamanan adalah proses berkelanjutan, bukan checklist satu kali. Pantau terus, update dependency, dan selalu waspada. 🔐

Artikel Terkait