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 OWASPTitik rawan di kode kitaPertahanan inti
Injection (A03)string SQL dirangkai dari inputparameterized query
XSS (A03)data pengguna dirender ke HTMLescaping saat keluar
Broken Access Control (A01)endpoint tanpa pemeriksaan hakotorisasi di server, selalu
Cryptographic Failures (A02)kata sandi disimpan mentahhash 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:

java Versi rentan vs versi aman dengan JDBC
// 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:

java PBKDF2 untuk kata sandi (ringkas)
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:

java PraktikAman.java
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("<", "&lt;")
                .replace(">", "&gt;")
                .replace("\"", "&quot;")
                .replace("'", "&#39;");
    }

    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:

text
== 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    : &lt;script&gt;alert(&#39;xss&#39;)&lt;/script&gt;

== 4. Token CSRF ==
token sesi : ba419d35
form cocok : ba419d35 -> true
form lain  : bb0f1799 -> false

Bedah baris-per-baris keempat praktik tersebut:

  1. Baris 35-42 (bagian 1): string queryRentan hasil penggabungan memperlihatkan satu tanda kutip pengguna mengubah struktur SQL: kondisi OR '1'='1' bernilai benar untuk semua baris. Pada queryAman, masukan yang sama hanya isi parameter.
  2. Baris 18-24 (hashKataSandi): PBEKeySpec menerima kata sandi sebagai char[], garam sebagai byte[], jumlah iterasi, dan panjang kunci; clearPassword() menghapus array kata sandi dari memori segera setelah dipakai.
  3. Baris 26-31 (verifikasi): hash dihitung ulang lalu dibandingkan dengan MessageDigest.isEqual, bukan String.equals: perbandingan isEqual berjalan dalam waktu konstan, sehingga durasi verifikasi tidak membocorkan berapa byte awal cocok.
  4. Baris 47 (garam-tetap-demo): Garam yang sama untuk semua pengguna mengurangi manfaat perlindungan; produksi membangkitkan garam acak per pengguna dan menyimpannya bersama hash.
  5. Baris 55-58 (bagian 3): komentar <script>...</script> keluar sebagai teks &lt;script&gt;... — peramban menampilkannya, tidak menjalankannya.
  6. Baris 61-71 (bagian 4): Token dibangkitkan dengan Random ber-seed agar output stabil; produksi memakai SecureRandom. 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 #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

PraktikAman.java

Unduh berkas
java
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("&", "&amp;")
                .replace("<", "&lt;")
                .replace(">", "&gt;")
                .replace("\"", "&quot;")
                .replace("'", "&#39;");
    }

    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)));
    }
}