CORS Deep Dive — Security

Memahami CORS Secara Mendalam CORS (Cross-Origin Resource Sharing) adalah mekanisme browser yang mengontrol akses resource dari origin yang berbeda. Salah…

CORS (Cross-Origin Resource Sharing) adalah cara server memberi tahu browser: "halaman dari origin ini boleh membaca jawabanku." Tanpa izin itu, JavaScript di halaman dari origin lain tetap bisa mengirim request, tapi browser gak menyerahkan jawabannya. Bayangin resepsionis kantor. Surat dari tamu mana pun tetap diantar ke dalam dan dibalas, tapi resepsionis cuma menyerahkan balasannya ke tamu kalau di amplopnya tertulis "boleh diberikan ke tamu dari kantor X". Resepsionisnya browser, tulisan di amplopnya header Access-Control-Allow-Origin.

🧪 Tebakan cepat: halaman di https://evil.com menjalankan fetch('https://api.myapp.com/orders'). API-mu gak memasang header CORS sama sekali. Request itu sampai ke server atau gak? Jawabannya ada di bagian "Yang ditahan browser: jawabannya, bukan request-nya".

Origin: scheme, host, port

Origin adalah gabungan tiga bagian URL: scheme (https), host (myapp.com), dan port (443, port bawaan https). Dua URL punya origin yang sama kalau ketiganya sama persis. Path seperti /orders gak ikut dihitung.

URL Sama dengan https://myapp.com? Kenapa
https://myapp.com/orders sama cuma path-nya yang beda
http://myapp.com beda scheme http, bukan https
https://api.myapp.com beda host beda, subdomain juga dihitung beda
https://myapp.com:8080 beda port 8080, bukan 443

Browser menjalankan same-origin policy (aturan origin yang sama): JavaScript di sebuah halaman cuma boleh membaca jawaban dari origin-nya sendiri. Halaman https://myapp.com yang memanggil https://api.myapp.com sudah termasuk lintas origin (cross-origin), jadi API-nya harus mengizinkan lewat CORS.

Yang ditahan browser: jawabannya, bukan request-nya

Untuk request sederhana seperti GET, browser langsung mengirimnya dan menempelkan header Origin berisi origin halaman:

GET /orders HTTP/1.1
Host: api.myapp.com
Origin: https://evil.com

Server memproses request itu dan menjawab 200 OK beserta datanya. Setelah jawabannya tiba, baru browser mengecek header Access-Control-Allow-Origin:

Access to fetch at 'https://api.myapp.com/orders' from origin 'https://evil.com' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.

Jadi request-nya sampai ke server dan dijawab. Yang ditahan cuma jawabannya, supaya JavaScript evil.com gak bisa membacanya. Itu jawaban tebakannya. Karena itu CORS bukan pengganti login: server tetap harus mengecek siapa yang meminta.

Preflight: browser bertanya dulu

Request seperti DELETE gak langsung dikirim. Browser bertanya dulu lewat request OPTIONS, namanya preflight:

OPTIONS /orders/7 HTTP/1.1
Host: api.myapp.com
Origin: https://myapp.com
Access-Control-Request-Method: DELETE

Server yang mengizinkan menjawab:

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://myapp.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 7200

Kalau origin dan method-nya diizinkan, browser baru mengirim DELETE /orders/7 yang asli. Kalau gak, request aslinya gak pernah dikirim, jadi order #7 aman.

Preflight cuma dilewati untuk request sederhana, yaitu yang memenuhi semua ini:

Jadi PUT, DELETE, header Authorization, dan Content-Type: application/json semuanya memicu preflight. Access-Control-Max-Age bilang berapa detik jawaban preflight boleh diingat. Chrome membatasinya sampai 7.200 detik (2 jam), Firefox sampai 86.400 detik (24 jam).

Memasang header CORS di server

Server memutuskan per request: origin yang meminta ada di daftar izin (allowlist) atau gak. Ini function yang sama dengan latihan:

function setCORSHeaders(res, origin, allowedOrigins) {
  if (allowedOrigins.includes(origin)) {
    res.setHeader("Access-Control-Allow-Origin", origin);
    res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE");
    res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
  }
  // origin lain: gak dapat header apa pun
}

const headers = {};
const res = { setHeader(name, value) { headers[name] = value; } };
setCORSHeaders(res, "https://myapp.com", ["https://myapp.com", "https://admin.myapp.com"]);
console.log("Access-Control-Allow-Origin:", headers["Access-Control-Allow-Origin"]);
console.log("Access-Control-Allow-Methods:", headers["Access-Control-Allow-Methods"]);
console.log("Access-Control-Allow-Headers:", headers["Access-Control-Allow-Headers"]);

const headers2 = {};
const res2 = { setHeader(name, value) { headers2[name] = value; } };
setCORSHeaders(res2, "https://evil.com", ["https://myapp.com"]);
console.log("Blocked origin headers:", JSON.stringify(headers2));

Output-nya:

Access-Control-Allow-Origin: https://myapp.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Blocked origin headers: {}

Credentials: kalau cookie ikut

Secara bawaan, fetch lintas origin gak membawa cookie. Untuk membawanya, halaman menulis fetch(url, { credentials: 'include' }), dan jawaban server harus memenuhi dua syarat:

Access-Control-Allow-Origin: https://myapp.com
Access-Control-Allow-Credentials: true

Jadi Access-Control-Allow-Origin: * bersama Access-Control-Allow-Credentials: true itu konfigurasi yang salah: aplikasimu sendiri gagal membaca jawabannya. Bahaya sebenarnya datang dari "perbaikan" yang keliru, yaitu memantulkan origin apa pun tanpa dicek.

💡 Di Express dan Laravel

Biasanya kamu gak menulis header CORS sendiri. Di Express ada package cors dengan opsi yang mirip (origin, methods, allowedHeaders, credentials, maxAge). Di Laravel, CORS sudah dipasang bawaan. Aturannya ada di config/cors.php (sejak Laravel 11, buat file-nya dengan php artisan config:publish cors):

return [
    'paths' => ['api/*'],
    'allowed_methods' => ['GET', 'POST', 'PUT', 'DELETE'],
    'allowed_origins' => ['https://myapp.com', 'https://admin.myapp.com'],
    'allowed_origins_patterns' => [],
    'allowed_headers' => ['Content-Type', 'Authorization'],
    'exposed_headers' => [],
    'max_age' => 7200,
    'supports_credentials' => true,
];

Keduanya mencocokkan origin dengan daftar, lalu menambahkan Vary: Origin ke jawaban. Header itu memberi tahu cache seperti CDN bahwa jawabannya tergantung origin, supaya jawaban untuk satu origin gak disajikan ke origin lain.

⚠️ Jebakan yang sering ketemu

TL;DR: CORS (Cross-Origin Resource Sharing) adalah izin dari server agar JavaScript di origin lain boleh membaca jawabannya. Origin = scheme + host + port, jadi https://api.myapp.com beda origin dengan https://myapp.com. Request sederhana (GET) tetap sampai ke server; browser cuma menahan jawabannya kalau Access-Control-Allow-Origin gak cocok. DELETE, PUT, header Authorization atau JSON memicu preflight OPTIONS dulu, dan kalau ditolak, request aslinya gak pernah dikirim. Di server, pantulkan origin hanya kalau allowedOrigins.includes(origin), pasang Methods dan Headers bersamanya, dan biarkan origin lain tanpa header sama sekali. Dengan cookie (credentials: 'include') wajib Access-Control-Allow-Credentials: true dan origin persis, gak boleh *.

Yang akan kamu pelajari