โ† Semua pembelajaran / Astro Nol โ†’ Portal Berita
Fase 7 ยท Keamanan

CSRF & CORS

Actions melindungi dirinya sendiri; endpoint buatanmu tidak. Dan CORS yang dilonggarkan untuk mempermudah pengembangan adalah cara paling umum membocorkan data member.

Sumber asli owasp.org Artikel Rangkuman ~8 menit baca

Intisari

  • CSRF: situs lain memaksa browser member mengirim request ke portalmu, dengan cookie-nya ikut.
  • Astro Actions memeriksa header Origin otomatis. Endpoint .ts tidak.
  • sameSite: "lax" pada cookie sesi sudah menutup sebagian besar kasus โ€” tapi bukan semuanya.
  • CORS bukan pertahanan CSRF. Ia mengatur siapa yang boleh membaca respons, bukan siapa yang boleh mengirim.
  • Access-Control-Allow-Origin: * tidak boleh ada pada endpoint yang membaca sesi.

Bentuk serangan CSRF

Situs penyerang berisi:

  <form action="https://portal.contoh.id/api/hapus-akun" method="POST" id="f"></form>
  <script>f.submit()</script>

Member portalmu yang sedang login membuka situs itu.
Browser mengirim POST โ€” LENGKAP DENGAN COOKIE SESI.
Endpoint-mu melihat sesi yang sah dan menuruti perintahnya.

Penyerang tidak pernah melihat responsnya, dan tidak perlu. Tindakannya sudah terjadi.

Pertahanan berlapis

LapisMenutupOtomatis?
sameSite: "lax" pada cookiePOST lintas situsYa, kalau kamu menyetelnya (Fase 5)
Cek Origin di ActionsSemua form lintas situsYa
Cek Origin di endpointSamaTidak โ€” tulis sendiri
Mutasi hanya lewat POSTSerangan lewat <img src>Ya, kalau kamu hanya meng-export POST

sameSite: "lax" menutup banyak, tapi tidak semuanya. Ia mengizinkan cookie ikut pada navigasi tingkat atas dengan metode aman โ€” jadi endpoint GET yang mengubah sesuatu tetap rentan. Ini alasan lain kenapa mutasi tidak boleh lewat GET: bukan sekadar kerapian REST, tapi pertahanan yang nyata.

Melindungi endpoint sendiri

// src/lib/csrf.ts
export function asalSah(request: Request, site: URL): boolean {
  const origin = request.headers.get("origin");

  if (origin !== null) {
    return origin === site.origin;
  }

  // Sebagian klien lama tidak mengirim Origin pada same-origin POST.
  // Referer sebagai cadangan โ€” tapi TIDAK BOLEH lolos kalau keduanya kosong.
  const referer = request.headers.get("referer");
  if (referer !== null) {
    try {
      return new URL(referer).origin === site.origin;
    } catch {
      return false;
    }
  }

  return false;    // tidak ada keduanya โ†’ tolak
}
// src/pages/api/bookmark.ts
export const POST: APIRoute = async ({ request, site, cookies, locals }) => {
  if (!asalSah(request, site!)) {
    return new Response(JSON.stringify({ error: "asal tidak sah" }), { status: 403 });
  }

  const member = await memberDariCookie(cookies);
  if (!member) return new Response(null, { status: 401 });

  // โ€ฆ
};

return false di akhir itu keputusan yang benar meski terasa keras. Godaan untuk menuliskan "kalau tidak ada Origin dan tidak ada Referer, izinkan saja" akan membatalkan seluruh perlindungan โ€” penyerang cukup menghapus kedua header itu. Kalau ada klien sah yang terkena, perbaiki kliennya, bukan pagarnya.

Pengecualian: webhook

// src/pages/api/midtrans-webhook.ts
// TIDAK memeriksa Origin. Midtrans tidak mengirimnya, dan memang tidak seharusnya.
// Keamanannya datang dari verifikasi tanda tangan (Fase 6).

Ini pengecualian yang harus tertulis sebagai komentar di kodenya. Tanpa penjelasan, orang berikutnya akan "memperbaiki"-nya dengan menambahkan pemeriksaan Origin โ€” dan seluruh pembayaranmu berhenti.

CORS: masalah yang berbeda

CSRF  : mencegah situs lain BERTINDAK atas nama member-mu
CORS  : mengatur siapa yang boleh MEMBACA respons dari domain lain

CORS BUKAN pertahanan CSRF. Request CSRF tetap terkirim
dan tetap diproses; CORS hanya mencegah penyerang membaca jawabannya.
// src/pages/api/artikel.json.ts โ€” data publik, boleh dibaca siapa saja
export const GET: APIRoute = async () => {
  return new Response(JSON.stringify({ data }), {
    headers: {
      "Content-Type": "application/json",
      "Access-Control-Allow-Origin": "*",     // aman: tidak ada data personal
      "Cache-Control": "public, s-maxage=120",
    },
  });
};
// src/pages/api/akun/bookmark.json.ts โ€” data personal
const ASAL_SAH = new Set([
  "https://portal.contoh.id",
  "https://app.portal.contoh.id",
]);

export const GET: APIRoute = async ({ request, cookies }) => {
  const origin = request.headers.get("origin");
  const headers = new Headers({ "Content-Type": "application/json" });

  if (origin && ASAL_SAH.has(origin)) {
    headers.set("Access-Control-Allow-Origin", origin);
    headers.set("Access-Control-Allow-Credentials", "true");
    headers.set("Vary", "Origin");             // WAJIB, lihat catatan
  }
  // โ€ฆ
};

Vary: Origin wajib begitu Access-Control-Allow-Origin bergantung pada request. Tanpanya, Cloudflare bisa menyimpan respons dengan header untuk satu asal dan menyajikannya ke asal lain. Untuk endpoint yang berisi data personal, itu bukan sekadar salah header โ€” itu jalur kebocoran data antar pembaca.

Perlu diketahui juga: Allow-Origin: * dan Allow-Credentials: true tidak bisa dipakai bersamaan โ€” browser menolaknya. Itu pagar bawaan yang bagus, dan alasan kenapa daftar-izin eksplisit adalah satu-satunya jalan untuk endpoint berkredensial.

Preflight

export const OPTIONS: APIRoute = async ({ request }) => {
  const origin = request.headers.get("origin");
  if (!origin || !ASAL_SAH.has(origin)) return new Response(null, { status: 403 });

  return new Response(null, {
    status: 204,
    headers: {
      "Access-Control-Allow-Origin": origin,
      "Access-Control-Allow-Methods": "GET, POST",
      "Access-Control-Allow-Headers": "Content-Type",
      "Access-Control-Allow-Credentials": "true",
      "Access-Control-Max-Age": "86400",
      Vary: "Origin",
    },
  });
};

Uji

# CSRF: POST dengan Origin asing harus 403
curl -si -X POST http://localhost:4321/api/bookmark \
  -H 'Origin: https://jahat.id' -H 'Content-Type: application/json' \
  -d '{"artikelId":1}' | head -1

# CSRF: tanpa Origin sama sekali juga harus 403
curl -si -X POST http://localhost:4321/api/bookmark \
  -H 'Content-Type: application/json' -d '{"artikelId":1}' | head -1

# CORS: endpoint personal tidak boleh mengizinkan asal asing
curl -si http://localhost:4321/api/akun/bookmark.json \
  -H 'Origin: https://jahat.id' | grep -i access-control

Latihan: tulis asalSah() dan pasang di semua endpoint mutasi. Jalankan ketiga perintah curl di atas dan pastikan hasilnya sesuai. Lalu buat berkas HTML lokal berisi form yang mengirim POST ke endpoint-mu, buka di browser sambil login, dan buktikan ia ditolak.

Rangkuman ini sengaja dipangkas ke bagian yang dipakai di roadmap. Buka sumber aslinya saat kamu butuh detail lengkap atau referensi parameter.