html/template — HTML yang aman secara default
html/template tahu apakah nilaimu sedang ditempatkan di dalam teks HTML, atribut, URL, atau JavaScript — dan mengubah cara escaping-nya sesuai konteks. Itu perbedaan besar dari text/template.
Intisari
- Selalu
html/template, jangan pernahtext/templateuntuk keluaran HTML. - Escaping-nya sadar konteks: nilai yang sama di-escape berbeda di dalam teks, atribut, URL, dan blok script.
- Parse template sekali saat startup;
template.Mustmembuat kesalahan template jadi kegagalan startup, bukan 500 di produksi. embed.FSmemasukkan template ke dalam binari — satu berkas untuk di-deploy.- Tipe
template.HTMLmematikan escaping. Anggap setiap pemakaiannya sebagai temuan review keamanan.
Escaping yang sadar konteks
data := map[string]string{
"input": `<script>alert(1)</script>`,
}
{{.input}} → <script>alert(1)</script>
<a title="{{.input}}"> → di-escape sebagai atribut
<a href="/cari?q={{.input}}"> → di-escape sebagai URL
<script>var x = {{.input}}; → di-escape sebagai literal string JavaScript
<style>color: {{.input}} → ditolak / dinetralkan sebagai CSS
Satu nilai, lima perlakuan berbeda — otomatis, berdasarkan tempat ia muncul. Inilah alasan XSS jauh lebih
jarang di aplikasi Go yang memakai html/template dibandingkan aplikasi yang menyambung string
HTML sendiri.
text/template punya API yang identik dan tidak melakukan escaping apa pun.
Menukar satu huruf di baris import mengubah aplikasi aman jadi aplikasi rentan, tanpa satu pun error
kompilasi. Periksa import ini di setiap review.
Parse sekali, di startup
import "embed"
//go:embed web/template/*.html
var templateFS embed.FS
// template.Must: kalau template rusak, program GAGAL START —
// bukan mengembalikan 500 pertama kali halaman itu dibuka.
var tmpl = template.Must(template.New("").Funcs(fungsi).
ParseFS(templateFS, "web/template/*.html"))
func (h *Handler) Beranda(w http.ResponseWriter, r *http.Request) {
data := halamanData{Judul: "Beranda", Produk: daftar}
// Render ke buffer DULU: kalau gagal di tengah, kita masih bisa
// mengirim 500 alih-alih halaman setengah jadi berstatus 200.
var buf bytes.Buffer
if err := tmpl.ExecuteTemplate(&buf, "beranda.html", data); err != nil {
h.log.ErrorContext(r.Context(), "render gagal", "err", err)
http.Error(w, "terjadi kesalahan", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "public, s-maxage=60, stale-while-revalidate=600")
_, _ = buf.WriteTo(w)
}
Dua keputusan di potongan itu yang layak ditiru: parse saat startup (bukan per permintaan — itu membaca disk dan memakai CPU di jalur panas), dan render ke buffer sebelum menulis. Yang kedua menghilangkan kelas bug "halaman terpotong dengan status 200" yang mustahil didiagnosis dari log.
Sintaks yang dipakai sehari-hari
{{.Judul}} nilai
{{.Produk.Nama}} bersarang
{{if .Login}}...{{else}}...{{end}}
{{range .Produk}} {{.Nama}} {{else}} Kosong {{end}}
{{with .Pengguna}} {{.Nama}} {{end}} ganti konteks kalau tidak nil
{{template "kaki" .}} sisipkan template lain
{{block "isi" .}}...{{end}} definisi + pemanggilan sekaligus
{{$n := len .Produk}} variabel
{{.Harga | rupiah}} pipeline ke fungsi kustom
{{/* komentar, tidak ikut keluaran */}}
{{- .Nama -}} pangkas spasi kiri/kanan
Tata letak dengan blok
<!-- layout.html -->
<!doctype html>
<html lang="id">
<head><title>{{block "judul" .}}Toko{{end}}</title></head>
<body>
{{block "isi" .}}{{end}}
</body>
</html>
<!-- beranda.html -->
{{define "judul"}}Beranda · Toko{{end}}
{{define "isi"}}
<h1>Produk</h1>
<ul>{{range .Produk}}<li>{{.Nama}} — {{.Harga | rupiah}}</li>{{end}}</ul>
{{end}}
Fungsi kustom
var fungsi = template.FuncMap{
"rupiah": func(n int64) string {
return "Rp" + tandaRibuan(n)
},
"tanggal": func(t time.Time) string {
return t.Format("2 Jan 2006")
},
"potong": func(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "…"
},
}
Fungsi template harus murni dan cepat. Ia berjalan di tengah penulisan respons, tidak menerima context, dan tidak bisa mengembalikan error dengan rapi. Jangan pernah melakukan kueri database dari dalam template — siapkan semua data di handler.
template.HTML: satu-satunya jalan keluar, dan bahayanya
// Ini MEMATIKAN escaping. Kalau isinya pernah disentuh pengguna, ini XSS.
type artikel struct {
Isi template.HTML
}
// Yang benar: bersihkan dulu dengan sanitizer, baru tandai aman.
isiBersih := bluemonday.UGCPolicy().Sanitize(markdownKeHTML(mentah))
a := artikel{Isi: template.HTML(isiBersih)}
Perlakukan setiap template.HTML, template.JS, dan
template.URL sebagai temuan review. Semuanya berarti "aku menjamin ini aman" — dan
jaminan itu harus punya alasan yang tertulis di sebelahnya. Sebagian besar XSS di aplikasi Go masuk lewat
ketiga tipe ini.
Kapan template server-side masih pilihan terbaik
| Halaman | Pendekatan | Kenapa |
|---|---|---|
| Katalog, artikel, landing | html/template | Bisa di-cache penuh di CDN; SEO bawaan; tanpa JavaScript |
| Form, filter, interaksi ringan | Template + htmx | Interaktif tanpa membangun SPA (Fase 7) |
| Panel admin kompleks | SPA + API JSON | Dipakai sedikit orang; tidak perlu di-cache |
| Template dengan pemeriksaan tipe | templ | Kesalahan template jadi error kompilasi (Fase 7) |
Untuk situs bertrafik baca tinggi — yang jadi sasaran roadmap ini — halaman yang dirender server dan di-cache di CloudFront adalah kombinasi tersulit dikalahkan: permintaan yang tidak pernah menyentuh proses Go sama sekali.
Latihan: render halaman berisi {{.Komentar}} dengan nilai
<img src=x onerror=alert(1)> dan lihat keluarannya aman. Lalu ubah tipe field jadi
template.HTML dan muat ulang — perhatikan skripnya jalan. Terakhir, tukar import jadi
text/template dan lihat semuanya bocor meski tipenya string.
Rangkuman ini sengaja dipangkas ke bagian yang dipakai di roadmap. Buka sumber aslinya saat kamu butuh detail lengkap atau referensi parameter.