CSRF, XSS & header keamanan
XSS menjalankan kode penyerang di halamanmu; CSRF membuat browser korban mengirim permintaan atas namanya. Go menutup sebagian besar XSS lewat html/template — sisanya adalah header dan token yang harus kamu pasang sendiri.
Intisari
html/templatemenutup sebagian besar XSS otomatis; yang tersisa masuk lewattemplate.HTMLdan lewat JSON yang disisipkan ke halaman.- CSRF butuh dua lapis:
SameSite=Laxpada cookie dan token sinkronisasi untuk aksi yang mengubah data. - API yang memakai
Authorization: Bearertidak rentan CSRF — browser tidak mengirim header itu sendiri. - Content-Security-Policy adalah pertahanan XSS paling kuat yang bisa kamu tambahkan setelah kode selesai.
- Bandingkan token dengan
subtle.ConstantTimeCompare, bukan==.
XSS: yang sudah ditutup Go, dan yang belum
| Jalur | Status di Go |
|---|---|
{{.Nilai}} di html/template | ✅ Di-escape sesuai konteks (Fase 3) |
text/template untuk HTML | ❌ Tidak ada escaping sama sekali |
template.HTML(x) | ❌ Escaping dimatikan secara sengaja |
Menyambung HTML dengan fmt.Sprintf | ❌ Tidak ada yang menolongmu |
JSON disisipkan ke dalam <script> | ⚠️ Aman lewat {{.}}, rawan kalau dirakit manual |
URL dari pengguna di href | ⚠️ javascript: — batasi skema secara eksplisit |
// URL dari pengguna: hanya izinkan skema yang aman
func urlAman(s string) template.URL {
u, err := url.Parse(s)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
return template.URL("#")
}
return template.URL(u.String())
}
Content-Security-Policy
func HeaderKeamanan(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Referrer-Policy", "strict-origin-when-cross-origin")
h.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
h.Set("Cross-Origin-Opener-Policy", "same-origin")
// Hanya kalau seluruh situs benar-benar HTTPS. Sulit dibatalkan.
h.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
// Nonce per permintaan: skrip inline hanya jalan kalau membawa nonce
// yang sama. Ini yang membuat XSS gagal meski penyerang berhasil
// menyisipkan tag <script>.
nonce := nonceBaru()
h.Set("Content-Security-Policy", strings.Join([]string{
"default-src 'self'",
"script-src 'self' 'nonce-" + nonce + "'",
"style-src 'self' 'nonce-" + nonce + "'",
"img-src 'self' data: https://cdn.contoh.id",
"connect-src 'self'",
"frame-ancestors 'none'",
"base-uri 'none'",
"form-action 'self'",
"object-src 'none'",
}, "; "))
next.ServeHTTP(w, r.WithContext(DenganNonce(r.Context(), nonce)))
})
}
<script nonce="{{.Nonce}}">
// hanya blok dengan nonce yang cocok yang dijalankan browser
</script>
| Direktif | Mencegah |
|---|---|
script-src 'self' 'nonce-…' | Skrip yang disisipkan penyerang tidak dieksekusi |
frame-ancestors 'none' | Clickjacking — pengganti modern X-Frame-Options |
base-uri 'none' | Pembajakan URL relatif lewat tag <base> |
form-action 'self' | Formulir yang dibajak mengirim data ke situs lain |
object-src 'none' | Plugin lawas |
Mulai dengan mode laporan. Pakai Content-Security-Policy-Report-Only lebih dulu,
kumpulkan laporannya selama beberapa hari, baru aktifkan penegakannya. CSP yang langsung ketat pada
situs yang sudah berjalan hampir pasti merusak sesuatu.
Dan HSTS sulit dibatalkan: begitu browser menyimpannya, ia menolak HTTP untuk domainmu selama
max-age. Pastikan seluruh situs dan seluruh subdomain memang siap HTTPS sebelum
memasang includeSubDomains.
CSRF
1. Korban login di bank.id — browser menyimpan cookie sesi
2. Korban membuka jahat.id
3. Halaman itu berisi:
<form action="https://bank.id/transfer" method="POST">
<input name="ke" value="penyerang"><input name="jumlah" value="10000000">
</form><script>document.forms[0].submit()</script>
4. Browser mengirim permintaan itu BESERTA cookie sesi korban
| Autentikasi | Rentan CSRF? |
|---|---|
| Cookie sesi | Ya — browser mengirimnya otomatis |
Cookie SameSite=Lax | Sebagian besar tertutup; GET yang mengubah data tetap rentan |
Authorization: Bearer | Tidak — browser tidak pernah mengirim header ini sendiri |
Token sinkronisasi
func (c *CSRF) Lindungi(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Metode aman tidak boleh mengubah apa pun, jadi tidak perlu token.
switch r.Method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
next.ServeHTTP(w, r)
return
}
ck, err := r.Cookie("csrf")
kirim := r.Header.Get("X-CSRF-Token")
if kirim == "" {
kirim = r.FormValue("csrf_token")
}
// ConstantTimeCompare: perbandingan "==" berhenti di byte pertama
// yang berbeda, dan selisih waktunya bisa dipakai menebak token.
if err != nil || subtle.ConstantTimeCompare([]byte(ck.Value), []byte(kirim)) != 1 {
http.Error(w, "token CSRF tidak valid", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
subtle.ConstantTimeCompare berlaku untuk semua pembandingan rahasia: token CSRF,
tanda tangan webhook, kunci API, kode verifikasi. Aturannya mudah diingat — kalau yang dibandingkan
adalah rahasia, jangan pakai ==.
Verifikasi webhook
func (h *Handler) Webhook(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
http.Error(w, "body terlalu besar", http.StatusRequestEntityTooLarge)
return
}
mac := hmac.New(sha256.New, h.rahasiaWebhook)
mac.Write(body)
harusnya := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(harusnya), []byte(r.Header.Get("X-Signature"))) {
http.Error(w, "tanda tangan tidak valid", http.StatusUnauthorized)
return
}
// Cegah replay: tolak tanda waktu yang terlalu lama.
ts, _ := strconv.ParseInt(r.Header.Get("X-Timestamp"), 10, 64)
if time.Since(time.Unix(ts, 0)) > 5*time.Minute {
http.Error(w, "permintaan kedaluwarsa", http.StatusUnauthorized)
return
}
...
}
Daftar periksa header
| Header | Nilai |
|---|---|
Content-Security-Policy | Ketat, dengan nonce |
Strict-Transport-Security | max-age=31536000; includeSubDomains |
X-Content-Type-Options | nosniff |
Referrer-Policy | strict-origin-when-cross-origin |
Permissions-Policy | Matikan yang tidak dipakai |
Cache-Control pada halaman personal | private, no-store |
| Header versi server | Hapus — jangan umumkan tumpukan teknologimu |
Latihan: pasang middleware header di atas, buka situsmu, dan periksa hasilnya di
curl -I. Lalu buat halaman lokal berisi form yang mem-POST ke endpoint
aplikasimu dan buktikan ia ditolak. Terakhir, ubah SameSite jadi None dan
matikan token CSRF — dan lihat serangan itu berhasil.
Rangkuman ini sengaja dipangkas ke bagian yang dipakai di roadmap. Buka sumber aslinya saat kamu butuh detail lengkap atau referensi parameter.