Aplikasi web adalah kode kita yang paling terbuka: setiap endpoint bisa dijangkau siapa saja lewat internet. Prinsip keamanan umum — hak akses minimum, pertahanan berlapis, kriptografi yang benar — sudah kita bahas pada Section Security; bab penutup ini menerapkannya pada titik khas aplikasi web: SQL injection lewat parameter permintaan, XSS lewat data yang dirender, penyimpanan kata sandi, dan CSRF lewat session. Keempatnya menutup mayoritas temuan OWASP Top 10 untuk aplikasi web, dan seluruh polanya dibuktikan dalam satu program Java murni.
Peta Ancaman Singkat #
OWASP menerbitkan daftar sepuluh risiko keamanan aplikasi web paling umum. Empat di antaranya langsung menyentuh kode yang sudah kita tulis di Section ini:
| Risiko OWASP | Titik rawan di kode kita | Pertahanan inti |
|---|---|---|
| Injection (A03) | string SQL dirangkai dari input | parameterized query |
| XSS (A03) | data pengguna dirender ke HTML | escaping saat keluar |
| Broken Access Control (A01) | endpoint tanpa pemeriksaan hak | otorisasi di server, selalu |
| Cryptographic Failures (A02) | kata sandi disimpan mentah | hash lambat + garam |
Injection SQL: Ikat Parameter, Jangan Rangkai String #
Bentuk rentan paling klasik merangkai masukan pengguna langsung ke string SQL. Masukan Buku' OR '1'='1 mengubah arti query menjadi “semua baris” — data dari luar memang tidak boleh dipercaya (Chapter Request dan Response). Versi aman mengirim query sebagai template bertanda tanya, lalu mengikat nilai sebagai parameter:
// RENTAN: masukan menjadi bagian dari kode SQL
String sql = "SELECT * FROM produk WHERE nama = '" + masukan + "'";
Statement st = koneksi.createStatement();
ResultSet rs = st.executeQuery(sql);
// AMAN: masukan tetap menjadi data
String sql = "SELECT * FROM produk WHERE nama = ?";
PreparedStatement ps = koneksi.prepareStatement(sql);
ps.setString(1, masukan);
ResultSet rs = ps.executeQuery();Driver mengirim query dan parameter terpisah, sehingga tanda kutip dan kata kunci SQL pada masukan diperlakukan murni sebagai teks pencarian. Materi lengkap PreparedStatement ada pada Section Database Programming.
Kata Sandi: Hash Lambat dengan Garam #
Menyimpan kata sandi sebagai teks biasa berarti satu penyusupan basis data membocorkan seluruh akun. Aturan benarnya: simpan hash dari fungsi lambat yang dirancang khusus untuk kata sandi — PBKDF2, bcrypt, scrypt, atau Argon2:
PBEKeySpec spec = new PBEKeySpec(sandi.toCharArray(), garamAcak, 210_000, 256);
SecretKeyFactory pabrik = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
byte[] hash = pabrik.generateSecret(spec).getEncoded();Ratusan ribu iterasi membuat pencobaan massal jutaan tebakan menjadi mahal; garam (byte acak unik per pengguna) membuat dua pengguna bersandi sama menghasilkan hash berbeda, sehingga tabel pelangi tidak berfungsi.
XSS dan CSRF: Dua Pola dari Bab Sebelumnya #
XSS tertutup oleh aturan yang sudah kita buktikan pada Chapter Template Rendering: data pengguna di-escape saat keluar ke HTML. CSRF bekerja sebaliknya: pengguna yang sedang login membuka halaman lain, dan halaman tersebut diam-diam mengirim permintaan ke endpoint kita memakai cookie session pengguna (Chapter Sessions dan Cookies). Pertahanannya tiga lapis: atribut SameSite pada cookie session, pemeriksaan header Origin, dan token CSRF — nilai acak yang ditanam di form dan diverifikasi server untuk setiap permintaan yang mengubah keadaan.
Empat Praktik dalam Satu Program #
PraktikAman membuktikan keempat pertahanan tersebut dalam satu eksekusi. Garamnya dibuat tetap agar output deterministik; komentar pada kode menegaskan produksi wajib memakai garam acak per pengguna:
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.Random;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec;
public class PraktikAman {
static String escapeHtml(String teks) {
return teks.replace("&", "&")
.replace("<", "<")
.replace(">", ">")
.replace("\"", """)
.replace("'", "'");
}
static String hashKataSandi(String sandi, byte[] garam) throws Exception {
PBEKeySpec spec = new PBEKeySpec(sandi.toCharArray(), garam, 100_000, 256);
SecretKeyFactory pabrik = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
byte[] kunci = pabrik.generateSecret(spec).getEncoded();
spec.clearPassword();
return HexFormat.of().formatHex(kunci);
}
static boolean verifikasi(String sandi, byte[] garam, String hashTersimpan)
throws Exception {
byte[] hitungUlang = HexFormat.of().parseHex(hashKataSandi(sandi, garam));
// perbandingan waktu-konstan, bukan equals biasa
return MessageDigest.isEqual(hitungUlang, HexFormat.of().parseHex(hashTersimpan));
}
public static void main(String[] args) throws Exception {
System.out.println("== 1. Injection SQL ==");
String masukan = "Buku' OR '1'='1";
String queryRentan = "SELECT * FROM produk WHERE nama = '" + masukan + "'";
String queryAman = "SELECT * FROM produk WHERE nama = ?";
System.out.println("Masukan pengguna : " + masukan);
System.out.println("Query rentan : " + queryRentan);
System.out.println(" -> kondisi OR selalu bernilai benar");
System.out.println("Query aman : " + queryAman);
System.out.println(" -> parameter diikat sebagai data: " + masukan);
System.out.println();
System.out.println("== 2. Penyimpanan kata sandi (PBKDF2) ==");
// garam tetap HANYA untuk demo; produksi memakai garam acak per pengguna
byte[] garam = "garam-tetap-demo".getBytes(StandardCharsets.UTF_8);
String hash = hashKataSandi("rahasia123", garam);
System.out.println("hash PBKDF2WithHmacSHA256 (100000 iterasi):");
System.out.println(" " + hash);
System.out.println("verifikasi 'rahasia123' : " + verifikasi("rahasia123", garam, hash));
System.out.println("verifikasi 'tebakan123' : " + verifikasi("tebakan123", garam, hash));
System.out.println();
System.out.println("== 3. Escape keluaran (XSS) ==");
String komentar = "<script>alert('xss')</script>";
System.out.println("Masukan : " + komentar);
System.out.println("Aman : " + escapeHtml(komentar));
System.out.println();
System.out.println("== 4. Token CSRF ==");
String tokenSesi = String.format("%08x", new Random(42).nextInt());
String tokenForm = tokenSesi;
String tokenAsing = String.format("%08x", new Random(7).nextInt());
System.out.println("token sesi : " + tokenSesi);
System.out.println("form cocok : " + tokenForm + " -> "
+ MessageDigest.isEqual(tokenSesi.getBytes(StandardCharsets.UTF_8),
tokenForm.getBytes(StandardCharsets.UTF_8)));
System.out.println("form lain : " + tokenAsing + " -> "
+ MessageDigest.isEqual(tokenSesi.getBytes(StandardCharsets.UTF_8),
tokenAsing.getBytes(StandardCharsets.UTF_8)));
}
}Output yang Dihasilkan:
== 1. Injection SQL ==
Masukan pengguna : Buku' OR '1'='1
Query rentan : SELECT * FROM produk WHERE nama = 'Buku' OR '1'='1'
-> kondisi OR selalu bernilai benar
Query aman : SELECT * FROM produk WHERE nama = ?
-> parameter diikat sebagai data: Buku' OR '1'='1
== 2. Penyimpanan kata sandi (PBKDF2) ==
hash PBKDF2WithHmacSHA256 (100000 iterasi):
2e429d9aa1cb82872daa62260e421f5fd3eeaff1643e0075bb9e0d1aa422a6f5
verifikasi 'rahasia123' : true
verifikasi 'tebakan123' : false
== 3. Escape keluaran (XSS) ==
Masukan : <script>alert('xss')</script>
Aman : <script>alert('xss')</script>
== 4. Token CSRF ==
token sesi : ba419d35
form cocok : ba419d35 -> true
form lain : bb0f1799 -> falseBedah baris-per-baris keempat praktik tersebut:
- Baris 35-42 (bagian 1): string
queryRentanhasil penggabungan memperlihatkan satu tanda kutip pengguna mengubah struktur SQL: kondisiOR '1'='1'bernilai benar untuk semua baris. PadaqueryAman, masukan yang sama hanya isi parameter. - Baris 18-24 (
hashKataSandi):PBEKeySpecmenerima kata sandi sebagaichar[], garam sebagaibyte[], jumlah iterasi, dan panjang kunci;clearPassword()menghapus array kata sandi dari memori segera setelah dipakai. - Baris 26-31 (
verifikasi): hash dihitung ulang lalu dibandingkan denganMessageDigest.isEqual, bukanString.equals: perbandinganisEqualberjalan dalam waktu konstan, sehingga durasi verifikasi tidak membocorkan berapa byte awal cocok. - Baris 47 (
garam-tetap-demo): Garam yang sama untuk semua pengguna mengurangi manfaat perlindungan; produksi membangkitkan garam acak per pengguna dan menyimpannya bersama hash. - Baris 55-58 (bagian 3): komentar
<script>...</script>keluar sebagai teks<script>...— peramban menampilkannya, tidak menjalankannya. - Baris 61-71 (bagian 4): Token dibangkitkan dengan
Randomber-seed agar output stabil; produksi memakaiSecureRandom. Token form yang cocok diterima, token halaman asing ditolak.
Section ini menutup perjalanan web development dari protokol mentah sampai API yang aman. Langkah berikutnya adalah aplikasi yang terpecah menjadi layanan-layanan kecil yang saling bekerja sama lewat jaringan; pola komunikasinya dibahas pada Section Microservices dan Distributed Systems.
Referensi #
- OWASP Top Ten
- OWASP Cheat Sheet: Password Storage
- OWASP Cheat Sheet: Query Parameterization
- Oracle: Class MessageDigest (Java SE 25)
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
PraktikAman.java
Unduh berkasimport java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.Random;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec;
public class PraktikAman {
static String escapeHtml(String teks) {
return teks.replace("&", "&")
.replace("<", "<")
.replace(">", ">")
.replace("\"", """)
.replace("'", "'");
}
static String hashKataSandi(String sandi, byte[] garam) throws Exception {
PBEKeySpec spec = new PBEKeySpec(sandi.toCharArray(), garam, 100_000, 256);
SecretKeyFactory pabrik = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
byte[] kunci = pabrik.generateSecret(spec).getEncoded();
spec.clearPassword();
return HexFormat.of().formatHex(kunci);
}
static boolean verifikasi(String sandi, byte[] garam, String hashTersimpan)
throws Exception {
byte[] hitungUlang = HexFormat.of().parseHex(hashKataSandi(sandi, garam));
// perbandingan waktu-konstan, bukan equals biasa
return MessageDigest.isEqual(hitungUlang, HexFormat.of().parseHex(hashTersimpan));
}
public static void main(String[] args) throws Exception {
System.out.println("== 1. Injection SQL ==");
String masukan = "Buku' OR '1'='1";
String queryRentan = "SELECT * FROM produk WHERE nama = '" + masukan + "'";
String queryAman = "SELECT * FROM produk WHERE nama = ?";
System.out.println("Masukan pengguna : " + masukan);
System.out.println("Query rentan : " + queryRentan);
System.out.println(" -> kondisi OR selalu bernilai benar");
System.out.println("Query aman : " + queryAman);
System.out.println(" -> parameter diikat sebagai data: " + masukan);
System.out.println();
System.out.println("== 2. Penyimpanan kata sandi (PBKDF2) ==");
// garam tetap HANYA untuk demo; produksi memakai garam acak per pengguna
byte[] garam = "garam-tetap-demo".getBytes(StandardCharsets.UTF_8);
String hash = hashKataSandi("rahasia123", garam);
System.out.println("hash PBKDF2WithHmacSHA256 (100000 iterasi):");
System.out.println(" " + hash);
System.out.println("verifikasi 'rahasia123' : " + verifikasi("rahasia123", garam, hash));
System.out.println("verifikasi 'tebakan123' : " + verifikasi("tebakan123", garam, hash));
System.out.println();
System.out.println("== 3. Escape keluaran (XSS) ==");
String komentar = "<script>alert('xss')</script>";
System.out.println("Masukan : " + komentar);
System.out.println("Aman : " + escapeHtml(komentar));
System.out.println();
System.out.println("== 4. Token CSRF ==");
String tokenSesi = String.format("%08x", new Random(42).nextInt());
String tokenForm = tokenSesi;
String tokenAsing = String.format("%08x", new Random(7).nextInt());
System.out.println("token sesi : " + tokenSesi);
System.out.println("form cocok : " + tokenForm + " -> "
+ MessageDigest.isEqual(tokenSesi.getBytes(StandardCharsets.UTF_8),
tokenForm.getBytes(StandardCharsets.UTF_8)));
System.out.println("form lain : " + tokenAsing + " -> "
+ MessageDigest.isEqual(tokenSesi.getBytes(StandardCharsets.UTF_8),
tokenAsing.getBytes(StandardCharsets.UTF_8)));
}
}