Browser memperlakukan cookie sebagai surat tugas: setiap permintaan ke sebuah domain otomatis membawa cookie domain itu, tanpa peduli permintaan lahir dari halaman mana. Sifat ini nyaman untuk login tetapi juga pintu masuk serangan CSRF (cross-site request forgery): halaman jahat di forum dapat memuat form yang mengirim POST ke situs bank Budi, dan browser tetap melampirkan cookie sesi bank — penyerang tidak perlu mencuri kredensial. Chapter ini membongkar serangannya lalu membangun pertahanan berlapis: token CSRF, double-submit cookie, dan SameSite.
Cara Serangan CSRF Bekerja #
Serangan CSRF memanfaatkan tiga fakta. Pertama, browser melampirkan cookie sesi pada permintaan lintas situs selama SameSite tidak melarang. Kedua, HTML mengizinkan form di domain A menargetkan action di domain B, dan JavaScript dapat mengirimkannya otomatis. Ketiga, penyerang tidak perlu membaca respons — cukup memicu efek sampingnya. Diagram berikut melukiskan alurnya:
Serangan berhasil bila korban punya sesi aktif, operasi target dapat dipicu lewat permintaan polos, dan server tidak meminta bukti bahwa permintaan lahir dari halamannya sendiri. API berbasis header Authorization relatif imun karena header itu diisi JavaScript secara sadar — cookie-lah yang terkirim otomatis, jadi aplikasi berbasis sesi wajib melindungi diri.
Pertahanan Berlapis #
Tidak ada satu tombol ajaib untuk CSRF; praktik terbaik menumpuk beberapa lapisan:
| Pertahanan | Cara kerja | Catatan |
|---|---|---|
| Token sesi (synchronizer) | token acak per sesi tersemat di form, diperiksa ulang saat POST | kuat, butuh penyimpanan server |
| Double-submit cookie | cookie acak dibaca JavaScript lalu dikirim ulang di header | tanpa penyimpanan server |
| SameSite Lax/Strict | browser menolak mengirim cookie pada POST lintas situs | default Lax di browser modern |
| Pemeriksaan Origin/Referer | tolak permintaan dari origin tak dikenal | pelengkap, bukan andalan |
Aturan dasarnya: GET hanya untuk operasi aman dan idempotent — menampilkan halaman, mencari data — sehingga GET tidak pernah memicu perubahan. Seluruh operasi tulis (transfer, ganti password, hapus data) wajib POST/PUT/PATCH dengan token CSRF.
Program: Middleware CSRF dengan httptest #
Program csrf-demo.go menguji dua pola sekaligus: endpoint /transfer memakai synchronizer token (token tersimpan di sesi server, dikirim ulang lewat header X-CSRF-Token), sedangkan /donasi memakai double-submit cookie. Halaman form disimulasikan dengan body berisi token sebagai pengganti hidden input HTML.
package main
import (
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"fmt"
"io"
"log"
"net/http"
"net/http/httptest"
"strings"
"sync"
)
// tokoSesi menyimpan token CSRF per sesi (pola synchronizer token).
// Mutex wajib karena map diakses goroutine handler secara bersamaan.
type tokoSesi struct {
mu sync.Mutex
token map[string]string
}
// acak menghasilkan n byte acak kriptografis dalam bentuk hex.
func acak(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
func (t *tokoSesi) buat() (sid, token, dsToken string, err error) {
sid, err = acak(32)
if err != nil {
return
}
token, err = acak(16)
if err != nil {
return
}
dsToken, err = acak(16)
if err != nil {
return
}
t.mu.Lock()
t.token[sid] = token
t.mu.Unlock()
return
}
func (t *tokoSesi) tokenUntuk(sid string) (string, bool) {
t.mu.Lock()
defer t.mu.Unlock()
tok, ok := t.token[sid]
return tok, ok
}
// sama membandingkan dua token dengan waktu konstan.
func sama(a, b string) bool {
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
}
func namaSameSite(s http.SameSite) string {
switch s {
case http.SameSiteLaxMode:
return "Lax"
case http.SameSiteStrictMode:
return "Strict"
case http.SameSiteNoneMode:
return "None"
default:
return "default"
}
}
func (t *tokoSesi) login(w http.ResponseWriter, r *http.Request) {
if r.PostFormValue("username") != "budi" || r.PostFormValue("password") != "sandiRahasia123" {
http.Error(w, "kredensial salah", http.StatusUnauthorized)
return
}
sid, _, dsToken, err := t.buat()
if err != nil {
http.Error(w, "gagal membuat sesi", http.StatusInternalServerError)
return
}
http.SetCookie(w, &http.Cookie{
Name: "session_id",
Value: sid,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
// Cookie double-submit sengaja TIDAK HttpOnly agar JavaScript
// dapat membacanya lalu menyalin nilainya ke header X-CSRF-Token.
http.SetCookie(w, &http.Cookie{Name: "csrf_ds", Value: dsToken, Path: "/"})
http.Redirect(w, r, "/transfer", http.StatusSeeOther)
}
func (t *tokoSesi) halamanTransfer(w http.ResponseWriter, r *http.Request) {
c, err := r.Cookie("session_id")
if err != nil {
http.Error(w, "belum login", http.StatusUnauthorized)
return
}
token, ok := t.tokenUntuk(c.Value)
if !ok {
http.Error(w, "sesi tidak valid", http.StatusUnauthorized)
return
}
// Sebagai pengganti halaman HTML, body berisi token tersemat.
fmt.Fprint(w, token)
}
// transfer adalah endpoint sensitif: hanya menerima POST dengan
// token CSRF yang cocok dengan token tersimpan di sesi server.
func (t *tokoSesi) transfer(w http.ResponseWriter, r *http.Request) {
c, err := r.Cookie("session_id")
if err != nil {
http.Error(w, "belum login", http.StatusUnauthorized)
return
}
token, ok := t.tokenUntuk(c.Value)
if !ok {
http.Error(w, "sesi tidak valid", http.StatusUnauthorized)
return
}
dariHeader := r.Header.Get("X-CSRF-Token")
if dariHeader == "" || !sama(dariHeader, token) {
http.Error(w, "token CSRF tidak valid", http.StatusForbidden)
return
}
fmt.Fprint(w, "transfer Rp50000 ke Siti berhasil")
}
// donasi memakai pola double-submit cookie: cookie csrf_ds
// dibandingkan dengan header X-CSRF-Token tanpa penyimpanan server.
func donasi(w http.ResponseWriter, r *http.Request) {
c, err := r.Cookie("csrf_ds")
if err != nil {
http.Error(w, "cookie csrf_ds tidak ada", http.StatusForbidden)
return
}
dariHeader := r.Header.Get("X-CSRF-Token")
if dariHeader == "" || !sama(dariHeader, c.Value) {
http.Error(w, "token CSRF tidak cocok dengan cookie", http.StatusForbidden)
return
}
fmt.Fprint(w, "donasi diterima")
}
// kirim menjalankan satu permintaan dan mengembalikan status, isi, serta cookie respons.
func kirim(client *http.Client, method, target, form, tokenHeader string, cookies ...*http.Cookie) (int, string, []*http.Cookie) {
var b io.Reader
if form != "" {
b = strings.NewReader(form)
}
req, err := http.NewRequest(method, target, b)
if err != nil {
log.Fatal(err)
}
if form != "" {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
if tokenHeader != "" {
req.Header.Set("X-CSRF-Token", tokenHeader)
}
for _, c := range cookies {
req.AddCookie(c)
}
resp, err := client.Do(req)
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
isi, _ := io.ReadAll(resp.Body)
return resp.StatusCode, strings.TrimSpace(string(isi)), resp.Cookies()
}
func main() {
toko := &tokoSesi{token: map[string]string{}}
mux := http.NewServeMux()
mux.HandleFunc("POST /login", toko.login)
mux.HandleFunc("GET /transfer", toko.halamanTransfer)
mux.HandleFunc("POST /transfer", toko.transfer)
mux.HandleFunc("POST /donasi", donasi)
server := httptest.NewServer(mux)
defer server.Close()
// Klien tanpa redirect otomatis agar status 303 terlihat.
client := &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
kode, _, cookies := kirim(client, "POST", server.URL+"/login", "username=budi&password=sandiRahasia123", "")
var cookieSesi, cookieDS *http.Cookie
for _, c := range cookies {
switch c.Name {
case "session_id":
cookieSesi = c
case "csrf_ds":
cookieDS = c
}
}
fmt.Printf("POST /login : %d (sesi dibuat)\n", kode)
// Halaman form: token CSRF tersemat sebagai hidden input (di sini: body).
_, tokenDariForm, _ := kirim(client, "GET", server.URL+"/transfer", "", "", cookieSesi)
fmt.Println("GET /transfer mengambil halaman form : 200")
kode, isi, _ := kirim(client, "POST", server.URL+"/transfer", "jumlah=50000&tujuan=Siti", "", cookieSesi)
fmt.Printf("POST /transfer tanpa token CSRF : %d (%s)\n", kode, isi)
kode, isi, _ = kirim(client, "POST", server.URL+"/transfer", "jumlah=50000&tujuan=Siti", tokenDariForm, cookieSesi)
fmt.Printf("POST /transfer dengan token CSRF benar : %d (%s)\n", kode, isi)
kode, isi, _ = kirim(client, "POST", server.URL+"/transfer", "jumlah=50000&tujuan=Siti", "palsu"+tokenDariForm, cookieSesi)
fmt.Printf("POST /transfer dengan token CSRF palsu : %d (%s)\n", kode, isi)
kode, isi, _ = kirim(client, "POST", server.URL+"/donasi", "jumlah=100000", cookieDS.Value, cookieDS)
fmt.Printf("POST /donasi double-submit cocok : %d (%s)\n", kode, isi)
kode, isi, _ = kirim(client, "POST", server.URL+"/donasi", "jumlah=100000", "nilai-berbeda", cookieDS)
fmt.Printf("POST /donasi header beda dari cookie : %d (%s)\n", kode, isi)
fmt.Printf("cookie session_id: SameSite=%s, HttpOnly=%v\n", namaSameSite(cookieSesi.SameSite), cookieSesi.HttpOnly)
fmt.Printf("cookie csrf_ds : HttpOnly=%v (sengaja terbaca JavaScript)\n", cookieDS.HttpOnly)
}Output yang Dihasilkan:
POST /login : 303 (sesi dibuat)
GET /transfer mengambil halaman form : 200
POST /transfer tanpa token CSRF : 403 (token CSRF tidak valid)
POST /transfer dengan token CSRF benar : 200 (transfer Rp50000 ke Siti berhasil)
POST /transfer dengan token CSRF palsu : 403 (token CSRF tidak valid)
POST /donasi double-submit cocok : 200 (donasi diterima)
POST /donasi header beda dari cookie : 403 (token CSRF tidak cocok dengan cookie)
cookie session_id: SameSite=Lax, HttpOnly=true
cookie csrf_ds : HttpOnly=false (sengaja terbaca JavaScript)Mari kita telaah baris demi baris program di atas:
- Baris 16-21: struct
tokoSesimenyimpan token per session ID di memori, dilindungi mutex karena handler berjalan pada goroutine terpisah. - Baris 32-49:
buatmenghasilkan tiga nilai acak: session ID 32 byte serta token synchronizer dan double-submit masing-masing 16 byte. - Baris 58-61:
samamemakaisubtle.ConstantTimeCompareagar lama perbandingan tidak membocorkan isi token. - Baris 76-97: handler
loginmenyetel cookie sesi denganHttpOnlydanSameSite=Lax, serta cookiecsrf_dsyang sengaja dibiarkan tanpaHttpOnly. - Baris 99-112:
halamanTransfermengembalikan token sebagai body — pengganti hidden input pada form sungguhan. - Baris 114-133: handler
transfermembandingkan headerX-CSRF-Tokendengan token di sesi; header kosong atau salah sama-sama berujung 403. - Baris 135-149: handler
donasimembandingkan cookiecsrf_dsdengan header tanpa menyimpan apa pun di server. - Baris 195-218: skenarionya meniru serangan nyata: form diambil lebih dulu (GET aman), POST tanpa token ditolak, POST dengan token diterima, POST dengan token palsu ditolak.
- Baris 226-227: dua baris terakhir mencetak atribut kedua cookie sebagai pengingat perannya yang berbeda.
Pola Double-Submit Cookie #
Pola double-submit memindahkan penyimpanan token ke cookie itu sendiri: server menyetel cookie csrf_ds acak tanpa HttpOnly, JavaScript membaca nilainya lalu menyalinnya ke header X-CSRF-Token pada setiap permintaan tulis, dan middleware hanya membandingkan cookie dengan header. Penyerang lintas situs tidak bisa membaca cookie domain lain karena kebijakan same-origin, sehingga tidak dapat menyetel header yang cocok. Keunggulannya: bebas penyimpanan server. Kelemahannya: cookie harus terlindung dari penimpaan subdomain — gunakan prefix __Host- bila subdomain tidak terkendali.
SameSite dan Peran Browser #
SameSite dari chapter Cookie Security
kini menjadi lapisan pertahanan CSRF di level browser. Mode Lax — default pada Chrome, Edge, dan Firefox modern — memblokir cookie pada POST lintas situs tetapi mengizinkannya pada navigasi GET tingkat atas, sehingga serangan form otomatis mati. Strict memblokir semuanya termasuk tautan masuk dari situs lain. None hanya untuk kasus yang memang butuh cookie lintas situs dan wajib disertai Secure.
Pemeriksaan token sebaiknya diletakkan di middleware agar semua endpoint terlindungi tanpa pengulangan, persis pola chapter Middleware . Perlindungan CSRF menjaga asal-usul permintaan; serangan lintas domain lainnya — pembacaan respons lintas origin oleh JavaScript — dijawab oleh CORS pada chapter berikutnya.
Referensi #
- PortSwigger Web Security: CSRF — anatomi serangan dan variasinya.
- Package net/http/httptest — server tiruan untuk skenario 403 dan 200.
- Package crypto/subtle — perbandingan waktu-konstan.
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
csrf-demo.go
Unduh berkaspackage main
import (
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"fmt"
"io"
"log"
"net/http"
"net/http/httptest"
"strings"
"sync"
)
// tokoSesi menyimpan token CSRF per sesi (pola synchronizer token).
// Mutex wajib karena map diakses goroutine handler secara bersamaan.
type tokoSesi struct {
mu sync.Mutex
token map[string]string
}
// acak menghasilkan n byte acak kriptografis dalam bentuk hex.
func acak(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
func (t *tokoSesi) buat() (sid, token, dsToken string, err error) {
sid, err = acak(32)
if err != nil {
return
}
token, err = acak(16)
if err != nil {
return
}
dsToken, err = acak(16)
if err != nil {
return
}
t.mu.Lock()
t.token[sid] = token
t.mu.Unlock()
return
}
func (t *tokoSesi) tokenUntuk(sid string) (string, bool) {
t.mu.Lock()
defer t.mu.Unlock()
tok, ok := t.token[sid]
return tok, ok
}
// sama membandingkan dua token dengan waktu konstan.
func sama(a, b string) bool {
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
}
func namaSameSite(s http.SameSite) string {
switch s {
case http.SameSiteLaxMode:
return "Lax"
case http.SameSiteStrictMode:
return "Strict"
case http.SameSiteNoneMode:
return "None"
default:
return "default"
}
}
func (t *tokoSesi) login(w http.ResponseWriter, r *http.Request) {
if r.PostFormValue("username") != "budi" || r.PostFormValue("password") != "sandiRahasia123" {
http.Error(w, "kredensial salah", http.StatusUnauthorized)
return
}
sid, _, dsToken, err := t.buat()
if err != nil {
http.Error(w, "gagal membuat sesi", http.StatusInternalServerError)
return
}
http.SetCookie(w, &http.Cookie{
Name: "session_id",
Value: sid,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
// Cookie double-submit sengaja TIDAK HttpOnly agar JavaScript
// dapat membacanya lalu menyalin nilainya ke header X-CSRF-Token.
http.SetCookie(w, &http.Cookie{Name: "csrf_ds", Value: dsToken, Path: "/"})
http.Redirect(w, r, "/transfer", http.StatusSeeOther)
}
func (t *tokoSesi) halamanTransfer(w http.ResponseWriter, r *http.Request) {
c, err := r.Cookie("session_id")
if err != nil {
http.Error(w, "belum login", http.StatusUnauthorized)
return
}
token, ok := t.tokenUntuk(c.Value)
if !ok {
http.Error(w, "sesi tidak valid", http.StatusUnauthorized)
return
}
// Sebagai pengganti halaman HTML, body berisi token tersemat.
fmt.Fprint(w, token)
}
// transfer adalah endpoint sensitif: hanya menerima POST dengan
// token CSRF yang cocok dengan token tersimpan di sesi server.
func (t *tokoSesi) transfer(w http.ResponseWriter, r *http.Request) {
c, err := r.Cookie("session_id")
if err != nil {
http.Error(w, "belum login", http.StatusUnauthorized)
return
}
token, ok := t.tokenUntuk(c.Value)
if !ok {
http.Error(w, "sesi tidak valid", http.StatusUnauthorized)
return
}
dariHeader := r.Header.Get("X-CSRF-Token")
if dariHeader == "" || !sama(dariHeader, token) {
http.Error(w, "token CSRF tidak valid", http.StatusForbidden)
return
}
fmt.Fprint(w, "transfer Rp50000 ke Siti berhasil")
}
// donasi memakai pola double-submit cookie: cookie csrf_ds
// dibandingkan dengan header X-CSRF-Token tanpa penyimpanan server.
func donasi(w http.ResponseWriter, r *http.Request) {
c, err := r.Cookie("csrf_ds")
if err != nil {
http.Error(w, "cookie csrf_ds tidak ada", http.StatusForbidden)
return
}
dariHeader := r.Header.Get("X-CSRF-Token")
if dariHeader == "" || !sama(dariHeader, c.Value) {
http.Error(w, "token CSRF tidak cocok dengan cookie", http.StatusForbidden)
return
}
fmt.Fprint(w, "donasi diterima")
}
// kirim menjalankan satu permintaan dan mengembalikan status, isi, serta cookie respons.
func kirim(client *http.Client, method, target, form, tokenHeader string, cookies ...*http.Cookie) (int, string, []*http.Cookie) {
var b io.Reader
if form != "" {
b = strings.NewReader(form)
}
req, err := http.NewRequest(method, target, b)
if err != nil {
log.Fatal(err)
}
if form != "" {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
if tokenHeader != "" {
req.Header.Set("X-CSRF-Token", tokenHeader)
}
for _, c := range cookies {
req.AddCookie(c)
}
resp, err := client.Do(req)
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
isi, _ := io.ReadAll(resp.Body)
return resp.StatusCode, strings.TrimSpace(string(isi)), resp.Cookies()
}
func main() {
toko := &tokoSesi{token: map[string]string{}}
mux := http.NewServeMux()
mux.HandleFunc("POST /login", toko.login)
mux.HandleFunc("GET /transfer", toko.halamanTransfer)
mux.HandleFunc("POST /transfer", toko.transfer)
mux.HandleFunc("POST /donasi", donasi)
server := httptest.NewServer(mux)
defer server.Close()
// Klien tanpa redirect otomatis agar status 303 terlihat.
client := &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
kode, _, cookies := kirim(client, "POST", server.URL+"/login", "username=budi&password=sandiRahasia123", "")
var cookieSesi, cookieDS *http.Cookie
for _, c := range cookies {
switch c.Name {
case "session_id":
cookieSesi = c
case "csrf_ds":
cookieDS = c
}
}
fmt.Printf("POST /login : %d (sesi dibuat)\n", kode)
// Halaman form: token CSRF tersemat sebagai hidden input (di sini: body).
_, tokenDariForm, _ := kirim(client, "GET", server.URL+"/transfer", "", "", cookieSesi)
fmt.Println("GET /transfer mengambil halaman form : 200")
kode, isi, _ := kirim(client, "POST", server.URL+"/transfer", "jumlah=50000&tujuan=Siti", "", cookieSesi)
fmt.Printf("POST /transfer tanpa token CSRF : %d (%s)\n", kode, isi)
kode, isi, _ = kirim(client, "POST", server.URL+"/transfer", "jumlah=50000&tujuan=Siti", tokenDariForm, cookieSesi)
fmt.Printf("POST /transfer dengan token CSRF benar : %d (%s)\n", kode, isi)
kode, isi, _ = kirim(client, "POST", server.URL+"/transfer", "jumlah=50000&tujuan=Siti", "palsu"+tokenDariForm, cookieSesi)
fmt.Printf("POST /transfer dengan token CSRF palsu : %d (%s)\n", kode, isi)
kode, isi, _ = kirim(client, "POST", server.URL+"/donasi", "jumlah=100000", cookieDS.Value, cookieDS)
fmt.Printf("POST /donasi double-submit cocok : %d (%s)\n", kode, isi)
kode, isi, _ = kirim(client, "POST", server.URL+"/donasi", "jumlah=100000", "nilai-berbeda", cookieDS)
fmt.Printf("POST /donasi header beda dari cookie : %d (%s)\n", kode, isi)
fmt.Printf("cookie session_id: SameSite=%s, HttpOnly=%v\n", namaSameSite(cookieSesi.SameSite), cookieSesi.HttpOnly)
fmt.Printf("cookie csrf_ds : HttpOnly=%v (sengaja terbaca JavaScript)\n", cookieDS.HttpOnly)
}