CORS, Rate Limiting, reCAPTCHA & Timing Attack
API publik Anda akan diakses dari berbagai tempat: aplikasi web di browser, aplikasi mobile, sampai script otomatis. Semakin publik sebuah API, semakin terbuka pintunya terhadap penyalahgunaan. Artikel ini membahas empat lapis pertahanan yang saling melengkapi: CORS agar browser mau memanggil API lintas-domain dengan aman, rate limiting agar API tidak dihantam request berlebihan, reCAPTCHA agar bot tidak bisa mendaftar/masuk lewat form, dan timing attack — kelemahan halus yang membuat perbandingan data sensitif bisa ditebak hanya dari selisih waktu respons.
CORS (Cross-Origin Resource Sharing)
Masalahnya: Same-Origin Policy
Browser punya aturan keamanan bernama Same-Origin Policy (SOP): halaman dari satu origin tidak boleh membaca respons dari origin lain tanpa izin eksplisit. Sebuah origin dibentuk dari tiga bagian: scheme + host + port.
| Origin | Scheme | Host | Port |
|---|---|---|---|
https://toko.com | https | toko.com | 443 (default) |
http://toko.com:8080 | http | toko.com | 8080 |
Dua URL disebut same-origin hanya jika ketiga bagian itu sama persis. https://toko.com dan http://toko.com berbeda (scheme beda), begitu juga https://toko.com dan https://toko.com:8443 (port beda).
Bayangkan halaman https://admin.panel.com mencoba memanggil https://api.backend.com/users. Itu adalah request lintas-origin. Karena SOP, browser akan memblokirnya — padahal request tersebut mungkin sah, misalnya frontend di domain terpisah memang sengaja dibuat untuk memanggil API. Di sinilah CORS menjadi penengah.
Cara Kerja CORS
CORS (Cross-Origin Resource Sharing) adalah mekanisme HTTP yang memberi tahu browser: "origin ini boleh membaca respons saya". Server menyatakan izin itu lewat header khusus pada response.
Untuk request yang "kompleks", browser mengirim preflight request dulu: sebuah OPTIONS ke server untuk bertanya izin sebelum request aslinya dikirim.
Browser Server API
│ │
│ OPTIONS /api/users │
│ Origin: https://admin.panel.com │
│ Access-Control-Request-Method: GET │
│ Access-Control-Request-Headers: auth│
│──────────────────────────────────────►│
│ │
│ Access-Control-Allow-Origin: * │
│ Access-Control-Allow-Methods: GET │
│ Access-Control-Allow-Headers: auth │
│◄──────────────────────────────────────│
│ │
│ GET /api/users (request asli) │
│──────────────────────────────────────►│
| Header | Arah | Fungsi |
|---|---|---|
Origin | Request | Menyebutkan origin yang memanggil (diisi browser otomatis) |
Access-Control-Allow-Origin | Response | Origin mana yang diizinkan; * artinya semua |
Access-Control-Allow-Methods | Response | Method yang diizinkan (GET, POST, dst.) |
Access-Control-Allow-Headers | Response | Header kustom yang boleh dikirim klien (misal Authorization) |
Access-Control-Allow-Credentials | Response | Apakah cookie/kredensial boleh dikirim lintas-origin |
Access-Control-Max-Age | Response | Lama hasil preflight boleh di-cache browser |
Kapan Preflight Terpicu?
Tidak semua request perlu preflight. Request sederhana — method GET/POST/HEAD tanpa header kustom selain yang standar — langsung dikirim tanpa bertanya dulu. Request menjadi kompleks (dan memicu preflight) jika memakai method non-sederhana seperti PUT, PATCH, DELETE, atau memakai header kustom seperti Authorization.
| Jenis Request | Contoh | Preflight? |
|---|---|---|
| Sederhana | GET /api/products dari frontend | Tidak |
| Sederhana | POST /api/search dengan Content-Type: application/x-www-form-urlencoded | Tidak |
| Kompleks | GET /api/users dengan header Authorization | Ya |
| Kompleks | DELETE /api/users/42 | Ya |
Poin penting: preflight bukan keamanan. Ia hanyalah mekanisme perizinan browser. Server yang salah konfigurasi CORS tidak "lebih aman" — ia hanya membuat frontend browser tidak bisa dipakai.
Implementasi di Spring
Spring Framework menyediakan dua cara utama mengonfigurasi CORS: WebMvcConfigurer.addCorsMappings untuk mapping global, dan CorsConfigurationSource yang bisa dipasangkan ke CorsFilter bila Anda ingin aturan yang lebih terpusat.
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("https://admin.panel.com")
.allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE")
.allowedHeaders("Authorization", "Content-Type")
.allowCredentials(true)
.maxAge(3600);
}
}
@Configuration
public class CorsConfig {
@Bean
public CorsFilter corsFilter() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of("https://admin.panel.com"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE"));
config.setAllowedHeaders(List.of("Authorization", "Content-Type"));
config.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", config);
return new CorsFilter(source);
}
}
Jangan menulis .allowedOrigins("*") bersamaan dengan .allowCredentials(true). Keduanya bertentangan: bila server mengizinkan semua origin sambil menerima kredensial, browser menolak responsnya. Kombinasi ini juga membuka celah — situs jahat mana pun bisa mengirim request yang membawa cookie Anda. Sebutkan origin yang Anda kenal secara eksplisit.
Jangan Mengandalkan CORS sebagai Keamanan
Ini jebakan paling umum. CORS hanya membatasi browser. Alat seperti curl, Postman, atau aplikasi mobile sama sekali tidak peduli dengan header CORS.
curl -X POST https://api.backend.com/api/users \
-H "Authorization: Bearer <token>" \
-d '{"email":"bot@mail.com","role":"ADMIN"}'
Perintah di atas berjalan mulus walau server menolak semua origin — karena curl tidak menerapkan SOP. Artinya, CORS tidak pernah menjadi pengganti autentikasi dan otorisasi. CORS menjawab pertanyaan "bolehkah halaman web ini membaca respons?", bukan "apakah pemanggil ini memang dia?". Untuk yang terakhir, Anda tetap butuh Authorization, session, dan seluruh aturan di Spring Security Filter Chain.
Rate Limiting
Apa Itu dan Kenapa Perlu
Rate limiting adalah aturan yang membatasi berapa banyak request yang boleh dikirim dalam jangka waktu tertentu. Tanpa rate limiting, endpoint publik Anda bisa menjadi sasaran:
| Ancaman | Contoh | Dampak |
|---|---|---|
| Brute-force login | Mencoba ribuan password ke POST /api/login | Akun pengguna terbobol |
| Scraping / penyalahgunaan | Menguras endpoint pencarian dengan bot | Biaya bandwidth dan database naik |
| Denial of Service | Membanjiri server dengan request | Layanan melambat hingga tumbang |
Pendekatan OWASP menyebutkan bahwa mitigasi seperti rate limiting adalah bagian dari pertahanan terhadap DoS: kita tidak bisa memblokir semua trafik, tapi kita bisa membatasi dampaknya per pemanggil.
Tingkat Penerapan Rate Limit
| Strategi | Contoh | Cocok untuk |
|---|---|---|
| Per-IP | Maksimal 100 request/menit per alamat IP | Memperlambat bot anonim |
| Per-user | Maksimal 30 request/menit per akun yang login | Menjaga fairness antar pengguna |
| Per-endpoint | Endpoint login dibatasi 10/menit, endpoint GET boleh 1000/menit | Melindungi jalur sensitif |
Ketiganya bisa dipasang bersamaan. Aturan praktisnya: makin sensitif endpoint-nya, makin ketat batasnya.
Strategi Algoritma
| Algoritma | Konsep | Kelebihan | Kekurangan |
|---|---|---|---|
| Fixed window | Batas direset tiap jendela waktu tetap (misal tiap menit) | Sederhana, mudah dipahami | Dua jendela berdekatan bisa digabung bot jadi 2x kuota |
| Sliding window | Jendela digeser sesuai waktu request terakhir | Lebih adil, tidak bisa dieksploitasi di tepi jendela | Sedikit lebih kompleks |
| Token bucket | Bak berisi token; setiap request mengambil 1 token, token diisi kembali bertahap | Mampu menangani ledakan singkat (burst) | Perlu penyimpanan state |
| Leaky bucket | Request diproses dengan laju tetap, sisanya diantrekan/dibuang | Laju keluar sangat stabil | Request mendadak bisa banyak dibuang |
Jangan terjebak terlalu dalam — pahami konsepnya, lalu pilih berdasarkan kebutuhan. Untuk sebagian besar aplikasi, token bucket (misal via library bucket4j) sudah cukup fleksibel.
Implementasi Sederhana di Spring
Cara paling ringan adalah menulis HandlerInterceptor (atau servlet Filter) yang menghitung request per kunci (misal IP) di dalam penyimpanan sementara seperti ConcurrentHashMap dengan jendela waktu.
@Component
public class RateLimitInterceptor implements HandlerInterceptor {
private final ConcurrentHashMap<String, Deque<Long>> requests = new ConcurrentHashMap<>();
private static final int MAX_REQUESTS = 100;
private static final long WINDOW_MILLIS = 60_000L;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws IOException {
String key = request.getRemoteAddr();
long now = System.currentTimeMillis();
Deque<Long> stamps = requests.computeIfAbsent(key, k -> new ArrayDeque<>());
synchronized (stamps) {
while (!stamps.isEmpty() && now - stamps.peekFirst() > WINDOW_MILLIS) {
stamps.pollFirst();
}
if (stamps.size() >= MAX_REQUESTS) {
response.setStatus(HttpServletResponse.SC_TOO_MANY_REQUESTS);
response.setHeader("Retry-After", "60");
response.getWriter().write("{\"error\":\"TOO_MANY_REQUESTS\"}");
return false;
}
stamps.addLast(now);
}
return true;
}
}
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(rateLimitInterceptor)
.addPathPatterns("/api/**");
}
}
Perhatikan dua hal di atas: saat batas terlampaui kita membalas 429 Too Many Requests (status HTTP khusus untuk ini) dan menyertakan header Retry-After yang memberi tahu klien berapa detik lagi boleh mencoba. Untuk produksi yang butuh skala, gunakan penyimpanan terdistribusi seperti Redis — misalnya via @EnableRedisHttpSession atau library bucket4j dengan backend Redis — karena ConcurrentHashMap hanya bekerja untuk satu instance aplikasi.
Best Practice
Jangan menyamaratakan semua endpoint. Endpoint autentikasi seperti POST /api/login dan POST /api/register harus jauh lebih ketat — contoh umum 10 request per menit per IP — karena di sinilah serangan brute-force paling berbahaya. Sebaliknya, jangan membatasi pengguna sah secara agresif tanpa alasan; rate limit yang terlalu ketat di endpoint pembacaan umum justru merusak pengalaman aplikasi yang sah. Awasi log dan sesuaikan angka batasnya dengan pola trafik nyata.
reCAPTCHA
Kenapa Dipakai
Rate limiting memperlambat bot, tapi bot yang punya banyak IP masih bisa berjalan. Untuk form publik (registrasi, login, kontak) kita ingin memisahkan manusia dari bot secara lebih tegas. Di sinilah reCAPTCHA masuk: sebuah layanan dari Google yang menilai sebuah interaksi di browser.
| Jenis | Cara Kerja | Kapan Cocok |
|---|---|---|
| Checkbox ("I'm not a robot") | Pengguna mencentang kotak; Google menilai perilakunya | Form login, registrasi, komentar |
| Invisible | Tidak ada widget; Google menilai perilaku pengguna diam-diam | Form yang ingin minim gesekan |
| Enterprise | Skor & analitik lanjutan untuk aplikasi besar | Aplikasi dengan risiko tinggi |
Google mengembalikan score antara 0 dan 1; 0 berarti hampir pasti bot, 1 berarti hampir pasti manusia. Backend Anda yang memutuskan ambang batas skor (misal tolak jika skor < 0.5).
Alur Kerja
Kunci keamanan reCAPTCHA adalah: verifikasi selalu dilakukan di backend, bukan di frontend. Browser hanya memproduksi token; backend yang menanyakan ke Google.
Browser (klien) Backend Anda Google API
│ │ │
│ 1. Isi form + reCAPTCHA │ │
│ menghasilkan token │ │
│──────────────────────────────►│ │
│ 2. POST /api/register │ │
│ { token, data } │ │
│──────────────────────────────►│ │
│ │ 3. POST /siteverify │
│ │ secret + token │
│ │───────────────────────────►│
│ │ 4. { success, score } │
│ │◄───────────────────────────│
│ 5. 200 OK / 400 Bad Request │ │
│◄──────────────────────────────│ │
Langkah-langkahnya:
- Halaman frontend memuat widget reCAPTCHA dan menyisipkan token
g-recaptcha-responseke dalam form. - Backend menerima token itu bersama data form yang dikirim.
- Backend memanggil endpoint verifikasi Google (
POST https://www.google.com/recaptcha/api/siteverify) dengan mengirim secret key dan token tadi. - Google membalas
success(apakah token valid) danscore(seberapa yakin interaksinya manusiawi). - Backend memutuskan: tolak jika token tidak valid atau skor di bawah ambang, lanjutkan jika lolos.
// Gambaran umum pemanggilan verifikasi di backend
public boolean verifyRecaptcha(String token) {
RestClient client = RestClient.create();
RecaptchaResponse res = client.post()
.uri("https://www.google.com/recaptcha/api/siteverify")
.form(form -> form
.param("secret", recaptchaSecret) // secret dari env, bukan dari frontend
.param("response", token))
.retrieve()
.body(RecaptchaResponse.class);
return res.success() && res.score() >= 0.5;
}
Jangan pernah menaruh
secretreCAPTCHA (atau secret apa pun) di kode frontend. Secret adalah kata sandi server — begitu masuk ke bundle JavaScript, semua orang bisa melihatnya. Simpan di environment variable di sisi backend.
reCAPTCHA bukan pengganti rate limiting dan autentikasi. Ia menangkal bot di lapisan interaksi browser, sementara rate limiting tetap menangkal pemanggil langsung yang tidak lewat browser sama sekali.
Timing Attack
Apa Itu Timing Attack
Bayangkan membandingkan dua string. Kode a.equals(b) membandingkan karakter demi karakter dari kiri, dan berhenti segera saat menemukan karakter yang berbeda (perilaku short-circuit). Artinya: membandingkan token yang salah di karakter pertama jauh lebih cepat daripada token yang salah di karakter terakhir.
Selisihnya hanya beberapa nanodetik, tapi bisa diukur berulang kali. Dari perbedaan waktu respons inilah penyerang menebak isi data rahasia — token, password, atau MAC — satu karakter demi satu karakter, mirip menebak kombinasi brankas dari suara klik kunci. Inilah yang disebut timing attack.
// RENTAN: berhenti saat karakter pertama berbeda
boolean insecureCompare(String a, String b) {
return a.equals(b);
}
// RENTAN: memakai == untuk nilai yang datang dari luar
if (receivedToken == expectedToken) {
// ...
}
Solusi: Perbandingan Constant-Time
Tujuannya sederhana: waktu perbandingan harus identik terlepas dari di karakter ke berapa data berbeda. Cara paling mudah di Java adalah MessageDigest.isEqual — metode yang membandingkan dua byte[] dengan total waktu yang konstan.
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.nio.charset.StandardCharsets;
public class TokenService {
public boolean verifyToken(String received, String expected) {
byte[] a = received.getBytes(StandardCharsets.UTF_8);
byte[] b = expected.getBytes(StandardCharsets.UTF_8);
return MessageDigest.isEqual(a, b); // constant-time
}
public String generateToken() {
byte[] bytes = new byte[32];
new SecureRandom().nextBytes(bytes);
return java.util.HexFormat.of().formatHex(bytes);
}
}
Catatan praktis:
MessageDigest.isEqualmembandingkan dua array dengan panjang yang sama; bila panjangnya berbeda ia langsung mengembalikanfalse. Untuk skenario yang butuh waktu konstan mutlak bahkan pada panjang yang beda, bandingkan hash (SHA-256) dari kedua input terlebih dahulu. Yang penting: hindari==danequals()untuk perbandingan nilai rahasia.
Pertahanan Pendukung
Timing attack bukan satu-satunya jalan menebak data rahasia, dan tidak berdiri sendiri. Lengkapi dengan:
- Pesan error yang seragam — jangan bedakan respons "email tidak terdaftar" vs "password salah". Kalau pesannya beda, penyerang tinggal mengumpulkan email yang valid. Balas dengan pesan yang sama persis untuk kedua kasus.
- Hashing yang lambat — simpan password sebagai hash dari algoritma yang sengaja dibuat lambat seperti BCrypt (lewat
PasswordEncoderbawaan Spring Security). Dokumentasi Spring Security menyarankan BCrypt untuk penyimpanan password, karena setiap hash yang lambat memperlambat kecepatan brute-force secara drastis. Hash ini juga dikombinasikan dengan salt acak per user. - Jangan pernah membandingkan password mentah dengan
equals. Selalu bandingkan hasil hash yang sudah disimpan, dan untuk perbandingan sensitif lainnya gunakanMessageDigest.isEqual.
Ringkasan
Keempat teknik ini menjawab masalah yang berbeda dan saling melengkapi. Menyatukan semuanya adalah contoh nyata defense in depth — berlapis-lapis, sehingga satu lapis yang gagal masih ditahan lapis berikutnya.
| Lapisan | Menangkal | Titik Pemasangan |
|---|---|---|
| CORS | Blokir pembacaan lintas-origin yang tidak diizinkan di browser | Konfigurasi server |
| Rate limiting | Banjir request, brute-force, awal mula DoS | Filter/interceptor di edge |
| reCAPTCHA | Bot yang mengisi form publik | Halaman frontend + verifikasi backend |
| Autentikasi & hashing | Penebakan password (timing attack, brute-force) | Spring Security + PasswordEncoder |
| Perbandingan constant-time | Kebocoran data lewat selisih waktu | Kode verifikasi token/hash |
Ingat urutan pemikirannya: jangan pernah mengandalkan satu lapisan saja. CORS bukan keamanan, rate limiting bukan perlindungan penuh, reCAPTCHA hanya menjangkau browser, dan timing attack bisa menembus logika perbandingan yang "terlihat benar". Kombinasikan semuanya di belakang autentikasi yang benar.
Lanjut membaca:
- CORS - MDN — mekanisme CORS secara lengkap
- Spring Framework: CORS — konfigurasi CORS di Spring MVC
- OWASP: Denial of Service — kategori serangan yang dimitigasi rate limiting
- Spring Security: Password Storage — BCrypt dan family
PasswordEncoder - Constant-Time Encoding — pembahasan teknikal timing attack & perbandingan constant-time
- Password Hashing dengan BCrypt — praktik menyimpan password dengan aman
- OWASP Top 10 — peta besar kelemahan aplikasi web