Aplikasi web modern sering memisahkan front-end dan API di domain berbeda — misalnya https://app.example.com yang memanggil https://api.example.org. Saat JavaScript di halaman pertama mencoba membaca respons dari domain kedua, browser menolak dan menampilkan pesan galat CORS policy di konsol. Penolakan tersebut berasal dari kebijakan keamanan bawaan browser bernama same-origin policy, dan CORS (Cross-Origin Resource Sharing) adalah mekanisme resmi untuk membuka pengecualian yang terkendali. Pada chapter ini kita pelajari cara kerja pengecualian tersebut, alur preflight OPTIONS, serta cara menanganinya dari PHP tanpa membuka celah bagi origin asing.
Same-Origin Policy sebagai Pondasi #
Same-origin policy (SOP) adalah aturan default browser: JavaScript hanya boleh membaca respons dari origin yang sama dengan halaman tempat skrip berjalan. Origin didefinisikan oleh gabungan tiga komponen — scheme, host, dan port — sehingga perbedaan salah satu saja berarti origin berbeda. Tabel berikut membandingkan beberapa URL terhadap halaman https://app.example.com/dashboard:
| URL pembanding | Origin | Alasan |
|---|---|---|
https://app.example.com/produk | Sama | Scheme, host, dan port identik. |
http://app.example.com | Berbeda | Scheme berbeda (HTTP vs HTTPS). |
https://app.example.com:8443 | Berbeda | Port berbeda (8443 vs 443 implisit). |
https://admin.example.com | Berbeda | Host berbeda. |
Ada satu asimetri penting yang sering disalahpahami: SOP memblokir pembacaan respons, tetapi tidak memblokir pengiriman permintaan. Formulir atau tag <img> lintas domain tetap terkirim dengan cookie penyertanya — inilah fondasi serangan CSRF
yang sudah kita bahas. CORS mengatur sisi pembacaan: server API yang memancarkan header izin yang tepat memberi tahu browser bahwa origin tertentu boleh membaca responsnya.
Permintaan Sederhana vs Preflight #
Tidak semua permintaan lintas domain melewati proses yang sama. Browser membaginya menjadi dua kategori: permintaan sederhana — metode GET, HEAD, atau POST dengan header “aman” seperti Accept dan Content-Type: application/x-www-form-urlencoded — langsung dikirim ke server, dan browser memeriksa header izin setelah respons tiba. Permintaan dengan metode lain (PUT, DELETE, PATCH) atau header kustom seperti Content-Type: application/json memicu preflight: browser mengirim permintaan OPTIONS lebih dulu untuk menanyakan izin, dan baru mengirim permintaan asli setelah jawabannya membolehkan.
Alur lengkap preflight untuk PUT dengan header JSON dapat digambarkan sebagai berikut:
Diagram di atas menunjukkan bahwa preflight adalah negosiasi dua tahap: tanggung jawab kita sebagai penulis server PHP adalah merespons OPTIONS dengan benar. Jika tanggapan preflight tidak memuat izin yang diminta, browser membatalkan permintaan asli dan JavaScript menerima galat — permintaan asli tidak pernah terkirim.
Menangani Preflight di PHP #
Sebelum membandingkan origin dengan daftar izin, kita perlu menormalisasi nilainya terlebih dahulu; nilai header mentah bisa memuat port, jalur aneh, bahkan scheme yang bukan HTTP. Fungsi berikut mengubah nilai header Origin menjadi bentuk baku atau null bila tidak valid:
<?php
function parseOrigin(array $server): ?string
{
$raw = $server['HTTP_ORIGIN'] ?? '';
if ($raw === '' || !filter_var($raw, FILTER_VALIDATE_URL)) {
return null;
}
$parts = parse_url($raw);
$scheme = $parts['scheme'] ?? '';
$host = $parts['host'] ?? '';
if (!in_array($scheme, ['http', 'https'], true) || $host === '') {
return null;
}
$port = $parts['port'] ?? null;
$isDefaultPort = ($scheme === 'https' && $port === 443)
|| ($scheme === 'http' && $port === 80);
$origin = "$scheme://$host";
if ($port !== null && !$isDefaultPort) {
$origin .= ":$port";
}
return $origin;
}
$contohRequest = [
['HTTP_ORIGIN' => 'https://app.example.com'],
['HTTP_ORIGIN' => 'https://app.example.com:8443'],
['HTTP_ORIGIN' => 'https://app.example.com:443'],
['HTTP_ORIGIN' => 'javascript:alert(1)'],
['HTTP_ORIGIN' => ''],
];
foreach ($contohRequest as $request) {
echo (parseOrigin($request) ?? '(tanpa origin valid)') . "\n";
}Output yang Dihasilkan:
https://app.example.com
https://app.example.com:8443
https://app.example.com
(tanpa origin valid)
(tanpa origin valid)Mari kita bedah normalisasi tersebut:
filter_var(..., FILTER_VALIDATE_URL)menolak string yang bukan URL sama sekali, dan pemeriksaan schemehttp/httpsmenolak URLjavascript:yang berpotensi menjadi vektor XSS bila dipantulkan begitu saja.- Port implisit dibuang —
:443pada HTTPS adalah port bawaan, dan hasilnyahttps://app.example.com:443disamakan denganhttps://app.example.comagar perbandingan origin tidak gagal hanya karena beda penulisan. - Nilai balik
?stringmemaksa pemanggil menangani kasus tidak-valid secara eksplisit, bukan diam-diam membandingkan string kosong.
Dengan normalisasi di tangan, penanganan preflight tinggal membandingkan origin dan metode terhadap daftar izin, lalu menyusun respons:
<?php
function handleCorsPreflight(string $origin, string $requestedMethod, array $allowedOrigins): array
{
$requestedMethod = strtoupper($requestedMethod);
$allowedMethods = ['GET', 'POST', 'PUT', 'DELETE'];
if (!in_array($origin, $allowedOrigins, true)) {
return ['status' => 403, 'headers' => []];
}
if (!in_array($requestedMethod, $allowedMethods, true)) {
return ['status' => 405, 'headers' => []];
}
return [
'status' => 204,
'headers' => [
'Access-Control-Allow-Origin' => $origin,
'Access-Control-Allow-Methods' => 'GET, POST, PUT, DELETE',
'Access-Control-Allow-Headers' => 'Content-Type, X-CSRF-Token',
'Access-Control-Max-Age' => '600',
],
];
}
$allowed = ['https://app.example.com', 'https://admin.example.com'];
$permintaan = [
'origin tepercaya, metode PUT' => ['https://app.example.com', 'PUT'],
'origin asing' => ['https://jahat.example', 'PUT'],
'metode tidak diizinkan' => ['https://app.example.com', 'TRACE'],
];
foreach ($permintaan as $label => [$origin, $method]) {
$respons = handleCorsPreflight($origin, $method, $allowed);
echo "$label => HTTP {$respons['status']}\n";
foreach ($respons['headers'] as $name => $value) {
echo " $name: $value\n";
}
}Output yang Dihasilkan:
origin tepercaya, metode PUT => HTTP 204
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, X-CSRF-Token
Access-Control-Max-Age: 600
origin asing => HTTP 403
metode tidak diizinkan => HTTP 405Tiga keputusan keamanan ada pada fungsi tersebut. Pertama, in_array(..., true) memaksa perbandingan string yang ketat — origin harus cocok persis dengan salah satu anggota daftar. Kedua, origin asing ditolak dengan status 403 tanpa header izin apa pun, sehingga preflight gagal dan permintaan asli tidak pernah dikirim. Ketiga, Access-Control-Max-Age: 600 memberi tahu browser bahwa hasil preflight boleh diingat selama 10 menit, mengurangi lalu lintas OPTIONS berulang.
Pemakaian di router nyata memerlukan pembacaan $_SERVER dan pemanggilan header(), seperti berikut:
<?php
// cors-router.php — penanganan preflight OPTIONS untuk API
$allowedOrigins = ['https://app.example.com', 'https://admin.example.com'];
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
if ($method === 'OPTIONS') {
$requestedMethod = strtoupper($_SERVER['HTTP_ACCESS_CONTROL_REQUEST_METHOD'] ?? '');
$allowedMethods = ['GET', 'POST', 'PUT', 'DELETE'];
$originOk = in_array($origin, $allowedOrigins, true);
$methodOk = in_array($requestedMethod, $allowedMethods, true);
if (!$originOk || !$methodOk) {
http_response_code(403);
exit;
}
header("Access-Control-Allow-Origin: $origin");
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE');
header('Access-Control-Allow-Headers: Content-Type, X-CSRF-Token');
header('Access-Control-Max-Age: 600');
http_response_code(204);
exit;
}
header('Content-Type: application/json');
echo json_encode(['status' => 'ok']);Perintah berikut menguji dua skenario preflight sekaligus — dari origin tepercaya dan dari origin asing:
php -S 127.0.0.1:8099 cors-router.php
curl -si -X OPTIONS http://127.0.0.1:8099/produk/10 \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: PUT"
curl -si -X OPTIONS http://127.0.0.1:8099/produk/10 \
-H "Origin: https://jahat.example" \
-H "Access-Control-Request-Method: PUT"Transcript pertama memperlihatkan preflight yang disetujui:
HTTP/1.1 204 No Content
Host: 127.0.0.1:8099
Date: Tue, 13 Oct 2026 16:10:03 GMT
Connection: close
X-Powered-By: PHP/8.4.23
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, X-CSRF-Token
Access-Control-Max-Age: 600
Content-type: text/html; charset=UTF-8Transcript kedua memperlihatkan penolakan — status 403 tanpa satu pun header Access-Control-Allow-*, sehingga browser membatalkan permintaan asli:
HTTP/1.1 403 Forbidden
Host: 127.0.0.1:8099
Date: Tue, 13 Oct 2026 16:10:03 GMT
Connection: close
X-Powered-By: PHP/8.4.23
Content-type: text/html; charset=UTF-8Mode Kredensial #
Secara default, fetch() lintas domain tidak mengirim cookie sama sekali — mode “anonymous”. Front-end dapat mengaktifkan pengiriman cookie dengan opsi credentials: 'include', dan pada saat itulah tanggung jawab server membesar: respons harus memuat Access-Control-Allow-Credentials: true dan nilai Access-Control-Allow-Origin harus berupa origin spesifik, bukan *. Kombinasi inilah yang mengizinkan JavaScript dari origin tepercaya membaca respons API atas nama pengguna yang sedang login.
Miskonfigurasi Berbahaya #
Kesalahan paling umum di lapangan adalah memantulkan nilai header Origin apa adanya demi membuat error CORS hilang saat pengembangan:
<?php
// RENTAN: memantulkan Origin apa pun — jangan pernah ditiru
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
header("Access-Control-Allow-Origin: $origin");
header('Access-Control-Allow-Credentials: true');Dengan kode rentan di atas, situs penyerang mana pun dapat membaca respons API atas nama pengguna yang login, karena server menyatakan “origin Anda diizinkan” untuk semua origin. Versi amannya adalah allowlist eksplisit dengan perbandingan ketat:
<?php
// AMAN: allowlist eksplisit dengan perbandingan ketat
$allowedOrigins = ['https://app.example.com', 'https://admin.example.com'];
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
if (in_array($origin, $allowedOrigins, true)) {
header('Access-Control-Allow-Origin: ' . $origin);
}Perhatikan juga jebakan pencocokan sebagian: memeriksa str_contains($origin, 'app.example.com') akan lolos untuk https://jahat-app.example.com karena substring-nya ada. Perbandingan kesamaan penuh via in_array(..., true) terhadap daftar origin terpercaya adalah satu-satunya pola yang aman.
Ringkasan #
CORS membuka pengecualian terkendali atas same-origin policy: server API memancarkan header izin, browser menegakkannya, dan preflight OPTIONS menjadi gerbang negosiasi untuk permintaan dengan metode atau header kustom. Aturan emasnya ada dua — allowlist eksplisit dengan perbandingan ketat, dan origin spesifik (bukan *) saat kredensial diaktifkan. Namun perlu diingat bahwa CORS mengatur pembacaan respons, bukan pengiriman permintaan; permintaan palsu yang menyertai cookie pengguna tetap harus ditangkal dengan token CSRF, topik dari chapter berikutnya.
Referensi #
- MDN — Cross-Origin Resource Sharing (CORS)
- MDN — Same-origin policy
- MDN — Glossary: Preflight request
- php.net — $_SERVER
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
contoh.php
Unduh berkas<?php
/* contoh.php - normalisasi origin dan penanganan preflight CORS.
* Jalankan dengan: php contoh.php
*/
function parseOrigin(array $server): ?string
{
$raw = $server['HTTP_ORIGIN'] ?? '';
if ($raw === '' || !filter_var($raw, FILTER_VALIDATE_URL)) {
return null;
}
$parts = parse_url($raw);
$scheme = $parts['scheme'] ?? '';
$host = $parts['host'] ?? '';
if (!in_array($scheme, ['http', 'https'], true) || $host === '') {
return null;
}
$port = $parts['port'] ?? null;
$isDefaultPort = ($scheme === 'https' && $port === 443)
|| ($scheme === 'http' && $port === 80);
$origin = "$scheme://$host";
if ($port !== null && !$isDefaultPort) {
$origin .= ":$port";
}
return $origin;
}
function handleCorsPreflight(string $origin, string $requestedMethod, array $allowedOrigins): array
{
$requestedMethod = strtoupper($requestedMethod);
$allowedMethods = ['GET', 'POST', 'PUT', 'DELETE'];
if (!in_array($origin, $allowedOrigins, true)) {
return ['status' => 403, 'headers' => []];
}
if (!in_array($requestedMethod, $allowedMethods, true)) {
return ['status' => 405, 'headers' => []];
}
return [
'status' => 204,
'headers' => [
'Access-Control-Allow-Origin' => $origin,
'Access-Control-Allow-Methods' => 'GET, POST, PUT, DELETE',
'Access-Control-Allow-Headers' => 'Content-Type, X-CSRF-Token',
'Access-Control-Max-Age' => '600',
],
];
}
$request = ['HTTP_ORIGIN' => 'https://app.example.com:8443'];
echo 'Origin ternormalisasi: ' . (parseOrigin($request) ?? '(tidak valid)') . "\n";
$allowed = ['https://app.example.com', 'https://admin.example.com'];
$permintaan = [
'origin tepercaya, metode PUT' => ['https://app.example.com', 'PUT'],
'origin asing' => ['https://jahat.example', 'PUT'],
];
foreach ($permintaan as $label => [$origin, $method]) {
$respons = handleCorsPreflight($origin, $method, $allowed);
echo "$label => HTTP {$respons['status']}\n";
foreach ($respons['headers'] as $name => $value) {
echo " $name: $value\n";
}
}