Petunjuk Keamanan untuk Go

Penanganan masukan dan risiko dependensi.

Daftar isi

Program yang menerima masukan dari luar tidak pernah boleh menganggap masukan itu jinak, sedangkan program yang mengimpor kode orang lain mewarisi risikonya. Topik ini membahas kedua sisi keamanan (security) tersebut, yaitu penanganan masukan yang mencakup pembersihan, batas ukuran, perpindahan jalur, dan perbandingan data rahasia, lalu pengelolaan risiko dependensi dengan go.sum dan govulncheck. Empat program contoh dieksekusi nyata, sedangkan perintah yang memerlukan jaringan ditandai tidak-dijalankan.

Membersihkan dan membatasi masukan#

Masukan yang berasal dari pengguna, berkas, atau jaringan selalu diperlakukan sebagai data tak dikenal, bukan sebagai teks yang dipercaya. Pembersihannya berurutan, yaitu memangkas spasi di tepi, menolak keadaan yang tidak mungkin, membatasi panjang, dan hanya mengizinkan kumpulan karakter tertentu. Pemeriksaan semacam ini menjadi pertahanan pertama terhadap konten yang disisipkan, karena karakter yang tidak diizinkan tidak pernah sampai ke lapisan berikutnya. Program berikut memvalidasi satu nama pendaftaran dengan langkah-langkah itu:

go validasi-nama.go
package main

import (
	"errors"
	"fmt"
	"strings"
	"unicode"
)

const batasNama = 24

func validasiNama(masukan string) (string, error) {
	nama := strings.TrimSpace(masukan)
	if nama == "" {
		return "", errors.New("nama tidak boleh kosong")
	}
	if len([]rune(nama)) > batasNama {
		return "", fmt.Errorf("nama melebihi %d karakter", batasNama)
	}
	for _, r := range nama {
		if !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != ' ' {
			return "", fmt.Errorf("karakter %q tidak diizinkan", r)
		}
	}
	return nama, nil
}

func main() {
	daftar := []string{
		"citra puspita",
		"  budi  ",
		"",
		"ali<script>",
		strings.Repeat("a", 30),
	}

	for _, m := range daftar {
		nama, err := validasiNama(m)
		if err != nil {
			fmt.Printf("ditolak: %s\n", err)
			continue
		}
		fmt.Printf("diterima: %s\n", nama)
	}
}
text Keluaran
diterima: citra puspita
diterima: budi
ditolak: nama tidak boleh kosong
ditolak: karakter '<' tidak diizinkan
ditolak: nama melebihi 24 karakter

Pemangkasan TrimSpace menghilangkan spasi di tepi, pemeriksaan kekosongan menolak masukan yang hanya berisi spasi, dan perulangan rune menolak setiap karakter di luar huruf, angka, dan spasi. Batas panjang dihitung dalam rune, yaitu satuan karakter Unicode, sehingga tidak bergantung pada banyaknya byte tiap huruf. Keluaran menunjukkan lima masukan yang menghasilkan lima keputusan berbeda, dan setiap penolakan menyebut alasannya agar pemanggil dapat menampilkannya kembali kepada pengguna.

Membatasi ukuran pembacaan#

Masukan yang sah menurut isinya tetap dapat berbahaya menurut ukurannya, misalnya satu kolom teks yang ternyata berukuran gigabyte. Pembacaan tanpa batas membiarkan sumber luar menentukan konsumsi memori program, sehingga setiap pembacaan masukan diberi batas ukuran yang tetap. Paket io menyediakan io.LimitReader untuk membatasi jumlah byte yang terbaca. Pola baku yang umum dipakai adalah membaca satu byte lebih banyak daripada batas yang diizinkan, sehingga kelebihan ukuran terdeteksi dan ditolak, bukan diam-diam dipotong:

go batas-ukuran.go
package main

import (
	"errors"
	"fmt"
	"io"
	"strings"
)

const batasBaca = 10

func baca(masukan string) (string, error) {
	terbaca, err := io.ReadAll(io.LimitReader(strings.NewReader(masukan), batasBaca+1))
	if err != nil {
		return "", err
	}
	if len(terbaca) > batasBaca {
		return "", errors.New("masukan melebihi batas ukuran")
	}
	return string(terbaca), nil
}

func main() {
	for _, m := range []string{"catatan", "teks yang jauh lebih panjang dari batas"} {
		hasil, err := baca(m)
		if err != nil {
			fmt.Printf("ditolak: %s\n", err)
			continue
		}
		fmt.Printf("diterima %d byte: %q\n", len(hasil), hasil)
	}
}
text Keluaran
diterima 7 byte: "catatan"
ditolak: masukan melebihi batas ukuran

Pembaca meminta paling banyak batasBaca+1 byte dari pembungkus LimitReader. Masukan catatan berukuran 7 byte diterima utuh, sedangkan masukan panjang terbaca 11 byte, satu byte lebih banyak daripada batas yang diizinkan. Karena kelebihan itu, fungsi menolak masukan panjang alih-alih mengembalikan potongan yang tampak sah. Pola satu byte ekstra itu penting, karena pembacaan yang berhenti tepat pada batas tidak dapat memastikan apakah sumbernya masih berlanjut.

Menahan perpindahan jalur#

Masukan yang dipakai sebagai bagian dari jalur berkas menimbulkan risiko tersendiri yang disebut path traversal, yaitu pelarian dari folder dasar dengan memanfaatkan segmen ... Jika jalur disambung mentah, masukan seperti ../../rahasia.txt keluar dari folder arsip dan menyentuh berkas mana pun yang boleh dibaca program. Penahanannya berurutan, yaitu menyusun jalur dengan filepath.Join, merapikannya dengan filepath.Clean sehingga segmen .. selesai dihitung, lalu menguji bahwa hasilnya tetap berada di dalam folder dasar:

go jalur-aman.go
package main

import (
	"errors"
	"fmt"
	"path/filepath"
	"strings"
)

const dasar = "arsip"

func aman(masukan string) (string, error) {
	target := filepath.Clean(filepath.Join(dasar, masukan))
	if target != dasar && !strings.HasPrefix(target, dasar+string(filepath.Separator)) {
		return "", errors.New("jalur keluar dari folder arsip")
	}
	return target, nil
}

func main() {
	for _, m := range []string{"catatan/hari-1.txt", "sub/../../rahasia.txt", ".."} {
		target, err := aman(m)
		if err != nil {
			fmt.Printf("ditolak: %s\n", err)
			continue
		}
		fmt.Printf("diterima: %s\n", target)
	}
}
text Keluaran
diterima: arsip/catatan/hari-1.txt
ditolak: jalur keluar dari folder arsip
ditolak: jalur keluar dari folder arsip

filepath.Clean menghitung segmen .. sampai selesai, sehingga arsip/sub/../../rahasia.txt menjadi rahasia.txt yang berada di luar folder dasar dan ditolak. Masukan berupa .. meluruh menjadi titik tunggal yang juga ditolak, sedangkan jalur normal tetap diterima dengan bentuk yang sudah rapi. Uji awalannya menyertakan pemisah jalur milik sistem operasi, sehingga folder bernama arsip-lain tidak lolos hanya karena berbagi awalan kata arsip.

Membandingkan data rahasia secara konstan#

Perbandingan data rahasia dengan operator == atau bytes.Equal berhenti begitu menemukan perbedaan pertama, sehingga lamanya perbandingan mengungkap berapa awalan yang sudah benar. Informasi yang bocor lewat perbedaan waktu semacam ini disebut timing side channel, yaitu saluran samping yang tidak terlihat pada satu percobaan tetapi dapat dirangkum dari banyak pengukuran. Paket crypto/subtle menyediakan subtle.ConstantTimeCompare yang selalu membandingkan seluruh byte dengan durasi yang sama, berapa pun posisi perbedaannya:

go perbandingan-konstan.go
package main

import (
	"crypto/subtle"
	"fmt"
)

// Kunci pada program nyata dimuat dari variabel lingkungan atau
// pengelola rahasia, bukan ditulis di dalam kode.
const kunci = "kunci-demonstrasi-42"

func cocok(masukan string) bool {
	return subtle.ConstantTimeCompare([]byte(kunci), []byte(masukan)) == 1
}

func main() {
	daftar := []string{
		"kunci-demonstrasi-42",
		"kunci-demonstrasi-43",
		"kunci",
	}

	for _, m := range daftar {
		fmt.Printf("%-21s %v\n", m, cocok(m))
	}
}
text Keluaran
kunci-demonstrasi-42  true
kunci-demonstrasi-43  false
kunci                 false

Fungsi cocok mengembalikan true hanya untuk kunci yang persis sama, dan false baik untuk kunci yang hanya berbeda pada karakter terakhir maupun yang berbeda sejak awal, dengan waktu yang tidak dapat dibedakan. Komentar pada kode menyatakan sumber kunci yang sebenarnya, yaitu variabel lingkungan atau pengelola rahasia, karena kunci yang tertulis di kode sumber ikut tersebar ke setiap salinan repositorinya.

Risiko dependensi#

Kode yang diimpor dikompilasi menjadi satu biner dengan kode sendiri, sehingga kelemahan pada dependensi menjadi kelemahan program juga. Risikonya ditekan melalui langkah berlapis. Daftar dependensi dikecilkan terlebih dahulu, karena setiap modul baru adalah permukaan baru yang harus dipantau, sedangkan pustaka standar menutup banyak kebutuhan tanpa biaya itu. Integritas unduhan dijaga go.sum, yaitu daftar checksum yang ditulis perangkat go dan menyebabkan modul yang isinya berubah ditolak saat build. Terakhir, kerentanan yang sudah diketahui diperiksa dengan govulncheck, alat resmi yang membaca basis data kerentanan Go dan menelusuri graf impor sehingga hanya kerentanan yang benar-benar terjangkau dari kode yang dilaporkan:

bash
# tidak-dijalankan: govulncheck mengunduh basis data kerentanan dari jaringan
go install golang.org/x/vuln/cmd/govulncheck@latest
# periksa kerentanan yang terjangkau dari kode modul ini
govulncheck ./...

Pemeriksaan semacam ini dijadikan rutinitas berkala, karena basis datanya berubah seiring ditemukannya kerentanan baru, sementara go.mod dan go.sum milik proyek tetap diam. Keputusan menambah atau menaikkan dependensi juga diperlakukan sebagai keputusan keamanan, sehingga perintahnya diuji pada lingkungan pengembang lebih dahulu, bukan langsung pada repositori yang melayani pengguna.

Kesalahan umum#

Tiga kekeliruan berikut paling sering muncul pada kode yang menyentuh keamanan. Ketiganya berpola sama, yaitu mempercayai bentuk mentah dari sesuatu yang berasal dari luar, baik masukan pengguna maupun isi dependensi.

  1. Menyambung jalur berkas dengan operator +. Segmen .. tidak pernah dinilai sampai jalur dirapikan, sehingga masukan beruntun seperti ../ meloloskan pembacaan ke luar folder dasar. Jalur disusun filepath.Join, dirapikan filepath.Clean, lalu diuji awalannya.
  2. Membaca masukan tanpa batas ukuran. Sumber luar menentukan konsumsi memori program. io.LimitReader dipasang dengan satu byte ekstra sebagai detektor kelebihan, dan kelebihan itu ditolak.
  3. Menaruh rahasia di dalam kode sumber. Rahasia ikut tersebar ke setiap salinan repositori beserta riwayatnya. Rahasia dimuat dari variabel lingkungan atau pengelola rahasia, sedangkan kode hanya memakai nilainya.

Ringkasan#

  • Masukan dibersihkan dengan pemangkasan, penolakan kekosongan, batas panjang dalam rune, dan daftar karakter yang diizinkan.
  • Pembacaan dibungkus io.LimitReader dengan satu byte ekstra agar kelebihan ukuran terdeteksi, bukan diam-diam dipotong.
  • Jalur berkas disusun dan dirapikan dengan paket filepath, lalu diuji awalannya agar tidak keluar folder dasar.
  • Data rahasia dibandingkan dengan subtle.ConstantTimeCompare, sedangkan risiko dependensi ditekan dengan go.sum dan govulncheck.

Referensi#