← Semua pembelajaran / Go Nol → Enterprise
Fase 6 · Keamanan

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/template menutup sebagian besar XSS otomatis; yang tersisa masuk lewat template.HTML dan lewat JSON yang disisipkan ke halaman.
  • CSRF butuh dua lapis: SameSite=Lax pada cookie dan token sinkronisasi untuk aksi yang mengubah data.
  • API yang memakai Authorization: Bearer tidak 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

JalurStatus 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>
DirektifMencegah
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
AutentikasiRentan CSRF?
Cookie sesiYa — browser mengirimnya otomatis
Cookie SameSite=LaxSebagian besar tertutup; GET yang mengubah data tetap rentan
Authorization: BearerTidak — 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

HeaderNilai
Content-Security-PolicyKetat, dengan nonce
Strict-Transport-Securitymax-age=31536000; includeSubDomains
X-Content-Type-Optionsnosniff
Referrer-Policystrict-origin-when-cross-origin
Permissions-PolicyMatikan yang tidak dipakai
Cache-Control pada halaman personalprivate, no-store
Header versi serverHapus — 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.