CSRF & CORS
Actions melindungi dirinya sendiri; endpoint buatanmu tidak. Dan CORS yang dilonggarkan untuk mempermudah pengembangan adalah cara paling umum membocorkan data member.
Intisari
- CSRF: situs lain memaksa browser member mengirim request ke portalmu, dengan cookie-nya ikut.
- Astro Actions memeriksa header
Originotomatis. Endpoint.tstidak. 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
| Lapis | Menutup | Otomatis? |
|---|---|---|
sameSite: "lax" pada cookie | POST lintas situs | Ya, kalau kamu menyetelnya (Fase 5) |
Cek Origin di Actions | Semua form lintas situs | Ya |
Cek Origin di endpoint | Sama | Tidak โ tulis sendiri |
| Mutasi hanya lewat POST | Serangan 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.