1. Pengenalan CSRF
Cross-Site Request Forgery (CSRF) — juga dikenal sebagai Session Riding atau One-Click Attack — adalah jenis serangan keamanan web di mana penyerang memaksa browser korban yang sudah terotentikasi untuk mengirim request HTTP yang tidak diinginkan ke aplikasi web target. Request ini akan dieksekusi dengan hak akses dan session korban, sehingga penyerang dapat melakukan tindakan seolah-olah mereka adalah korban.
CSRF menempati posisi penting dalam daftar OWASP Top 10 dan telah menjadi ancaman serius sejak awal 2000-an. Meskipun banyak framework modern sudah menyertakan perlindungan bawaan, implementasi yang salah atau tidak lengkap masih sering ditemukan di aplikasi web produksi.
Mengapa CSRF Berbahaya?
| Aspek | Penjelasan |
|---|---|
| Eksploitasi Session | Penyerang memanfaatkan session korban yang sudah login, bukan mencuri credential |
| Transfer Dana | Memaksa transfer uang dari rekening korban di situs banking |
| Ubah Email/Password | Merubah email atau password akun korban sehingga penyerang bisa mengambil alih |
| Posting Konten | Menulis postingan, komentar, atau review atas nama korban |
| Install Malware | Memaksa korban menginstal malware melalui exploit di router atau IoT device |
| Privilege Escalation | Menaikkan hak akses akun penyerang jika korban adalah admin |
Prasyarat Agar CSRF Bisa Dieksploitasi
- Korban sudah terotentikasi — memiliki session aktif di situs target
- Situs menggunakan cookie untuk autentikasi — browser mengirim cookie otomatis
- Tidak ada validasi asal request — server tidak memeriksa dari mana request berasal
- Ada aksi yang bisa dieksploitasi — perubahan data, transfer uang, ubah setting
2. Cara Kerja Serangan CSRF
Serangan CSRF biasanya dimulai dengan penyerang membuat halaman web berbahaya yang berisi request tersembunyi ke situs target. Ketika korban mengunjungi halaman tersebut, browser korban secara otomatis mengirim request beserta semua cookie yang relevan ke situs target.
Skenario Serangan: Transfer Uang
Server-Side: Kode Rentan
Implementasi di Flask
flowchart TD
N0["SameSite=Strict ✅ bank.com langsung → cookie DI..."]
N1["SameSite=Lax (Default) ✅ bank.com langsung → co..."]
N2["SameSite=None; Secure ✅ SEMUA request → cookie ..."]
N0 --> N1
N1 --> N2
Implementasi SameSite Cookies
# ✅ Implementasi SameSite Cookies di Flask
from flask import Flask, session, make_response
import secrets
app = Flask(__name__)
app.secret_key = secrets.token_hex(32)
# Konfigurasi global untuk session cookies
app.config.update(
SESSION_COOKIE_SECURE=True, # Hanya kirim via HTTPS
SESSION_COOKIE_HTTPONLY=True, # Tidak bisa diakses JavaScript
SESSION_COOKIE_SAMESITE='Lax', # SameSite=Lax (default yang aman)
SESSION_COOKIE_NAME='__Host-session', # Prefix __Host untuk keamanan
PERMANENT_SESSION_LIFETIME=3600 # 1 jam timeout
)
# Jika butuh SameSite=Strict untuk endpoint sensitif
@app.route('/change-password', methods=['POST'])
def change_password():
if 'user_id' not in session:
return {"error": "Unauthorized"}, 401
# Proses ganti password...
resp = make_response({"message": "Password berhasil diubah"})
# Set cookie dengan SameSite=Strict untuk keamanan maksimal
resp.set_cookie(
'password_changed',
'true',
secure=True,
httponly=True,
samesite='Strict',
max_age=300 # 5 menit
)
return resp
# Jika butuh SameSite=None untuk cross-origin API calls
@app.route('/api/widget-data', methods=['GET'])
def widget_data():
resp = make_response({"data": [1, 2, 3]})
resp.set_cookie(
'widget_session',
'xyz789',
secure=True, # WAJIB untuk SameSite=None
httponly=True,
samesite='None', # Izinkan cross-site
max_age=86400 # 1 hari
)
return resp
// ✅ Implementasi SameSite Cookies di Express.js
const express = require('express');
const session = require('express-session');
const app = express();
// Session dengan SameSite=Lax
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
name: '__Host-session',
cookie: {
secure: true, // Hanya HTTPS
httpOnly: true, // Tidak bisa diakses JS
sameSite: 'lax', // SameSite=Lax
maxAge: 3600000 // 1 jam (dalam milidetik)
}
}));
// Set cookie manual dengan SameSite=Strict
app.post('/api/admin/delete-user', (req, res) => {
// Endpoint sensitif → SameSite=Strict
res.cookie('admin_action', 'delete_user', {
secure: true,
httpOnly: true,
sameSite: 'strict',
maxAge: 60000
});
// Proses hapus user...
res.json({ message: 'User berhasil dihapus' });
});
// Cookie untuk widget lintas domain
app.get('/api/embed/widget', (req, res) => {
res.cookie('embed_token', 'token123', {
secure: true, // WAJIB untuk SameSite=None
httpOnly: true,
sameSite: 'none', // Izinkan cross-site
maxAge: 86400000
});
res.json({ widgetData: {} });
});
- SameSite=Lax adalah default di Chrome, Edge, dan Firefox sejak 2020 — Anda tidak perlu men-setting secara eksplisit untuk perilaku default
- SameSite=None memerlukan Secure flag — cookie hanya akan dikirim via HTTPS
- SameSite=Strict bisa memutus UX — ketika user klik link dari email, mereka tidak akan langsung login karena cookie tidak dikirim
- Lax memberikan perlindungan yang cukup untuk sebagian besar kasus — mencegah POST-based CSRF
- SameSite saja TIDAK cukup — tetap gunakan CSRF token sebagai lapisan pertahanan tambahan
5. Double Submit Cookie Pattern
Double Submit Cookie adalah teknik pertahanan CSRF di mana token yang sama dikirim dalam dua tempat: sebagai cookie dan sebagai parameter request (form field atau header). Server kemudian memverifikasi bahwa kedua token tersebut cocok. Keuntungan utama pola ini adalah tidak memerlukan penyimpanan state di server, sehingga sangat cocok untuk arsitektur stateless dan microservices.
Cara Kerja Double Submit Cookie
DOUBLE SUBMIT COOKIE PATTERN
Browser
(JS reads cookie)
Server
1. Set-Cookie: csrf_token=abc123 (httponly=NO)
a) Cookie: csrf_token=abc123
b) Form: <input value="abc123">
2. POST + Cookie header + X-CSRF-Token header
3. Compare both
cookie == header?
✅ Match → process | ❌ Mismatch → 403 Forbidden
💡 Stateless: no server-side session storage needed for token
Implementasi Double Submit Cookie
# ✅ Double Submit Cookie Pattern — Stateless CSRF Protection
from flask import Flask, request, make_response, jsonify, abort
import secrets
import hmac
app = Flask(__name__)
app.secret_key = secrets.token_hex(32)
@app.route('/api/csrf-token', methods=['GET'])
def get_csrf_token():
"""Endpoint untuk mendapatkan CSRF token"""
# Generate token cryptographically random
csrf_token = secrets.token_hex(32)
resp = make_response(jsonify({"csrf_token": csrf_token}))
# Simpan di cookie (TIDAK httponly agar JS bisa baca)
resp.set_cookie(
'csrf_token',
csrf_token,
secure=True,
httponly=False, # Agar JavaScript bisa baca untuk header
samesite='Lax',
max_age=3600
)
return resp
@app.before_request
def validate_double_submit():
"""Validasi Double Submit Cookie pada semua state-changing requests"""
if request.method in ('POST', 'PUT', 'DELETE', 'PATCH'):
# Ambil token dari cookie
cookie_token = request.cookies.get('csrf_token')
# Ambil token dari header atau form
header_token = request.headers.get('X-CSRF-Token')
form_token = request.form.get('csrf_token')
request_token = header_token or form_token
# Verifikasi keduanya ada dan cocok
if not cookie_token or not request_token:
abort(403, "CSRF token tidak lengkap")
if not hmac.compare_digest(cookie_token, request_token):
abort(403, "CSRF token tidak cocok")
# Contoh endpoint yang dilindungi
@app.route('/api/transfer', methods=['POST'])
def transfer():
data = request.get_json()
to_account = data.get('to_account')
amount = data.get('amount')
# Proses transfer...
return jsonify({"message": f"Transfer Rp{amount:,} berhasil"})
# Client-side JavaScript untuk menggunakan token
@app.route('/transfer')
def transfer_page():
return '''
<html>
<body>
<form id="transferForm">
<input type="text" name="to_account" placeholder="Rekening Tujuan">
<input type="number" name="amount" placeholder="Jumlah">
<button type="submit">Transfer</button>
</form>
<script>
// Ambil CSRF token dari cookie
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
}
// Kirim dengan CSRF token di header
document.getElementById('transferForm').addEventListener('submit',
async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const csrfToken = getCookie('csrf_token');
const response = await fetch('/api/transfer', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken // Token di header
},
body: JSON.stringify(Object.fromEntries(formData))
});
const result = await response.json();
alert(result.message);
}
);
</script>
</body>
</html>
'''
6. Custom Header Validation
Teknik Custom Header Validation memanfaatkan fakta bahwa penyerang tidak dapat mengirim custom HTTP header dalam cross-origin request dari browser. Dengan memeriksa keberadaan header kustom tertentu (seperti X-Requested-With), server dapat memverifikasi bahwa request berasal dari JavaScript asli di situs sendiri, bukan dari form CSRF.
Mengapa Custom Header Aman?
- HTML
<form>TIDAK bisa mengirim custom header <img>,<script>,<iframe>TIDAK bisa mengirim custom headerXMLHttpRequestdanfetch()bisa mengirim custom header, TAPI akan memicu CORS preflight OPTIONS request- Jika server tidak mengizinkan CORS, browser akan memblokir request
- Jadi: keberadaan custom header = request dari JavaScript situs sendiri
# ✅ Custom Header Validation
from flask import Flask, request, abort, jsonify
app = Flask(__name__)
@app.before_request
def check_custom_header():
"""Validasi custom header pada semua state-changing requests"""
if request.method in ('POST', 'PUT', 'DELETE', 'PATCH'):
# Cek keberadaan custom header
requested_with = request.headers.get('X-Requested-With')
if requested_with != 'XMLHttpRequest':
abort(403, "Request harus mengandung X-Requested-With header")
# Opsional: cek Content-Type untuk POST
if request.method == 'POST':
content_type = request.content_type or ''
allowed_types = [
'application/json',
'application/x-www-form-urlencoded',
'multipart/form-data'
]
if not any(ct in content_type for ct in allowed_types):
abort(415, "Content-Type tidak didukung")
@app.route('/api/transfer', methods=['POST'])
def transfer():
data = request.get_json()
return jsonify({"message": "Transfer berhasil", "data": data})
# Client-side: Tambahkan custom header ke semua fetch request
@app.route('/app')
def app_page():
return '''
<script>
// Intercept semua fetch request dan tambahkan custom header
const originalFetch = window.fetch;
window.fetch = function(url, options = {}) {
options.headers = options.headers || {};
// Tambahkan custom header untuk semua non-GET request
if (options.method && options.method.toUpperCase() !== 'GET') {
options.headers['X-Requested-With'] = 'XMLHttpRequest';
}
// Pastikan credentials dikirim
options.credentials = 'same-origin';
return originalFetch.call(this, url, options);
};
// Atau gunakan Axios interceptor
// import axios from 'axios';
// axios.interceptors.request.use(config => {
// if (config.method !== 'get') {
// config.headers['X-Requested-With'] = 'XMLHttpRequest';
// }
// return config;
// });
</script>
'''
7. Best Practices & Defense in Depth
Pendekatan terbaik untuk melindungi aplikasi dari CSRF adalah menggunakan Defense in Depth — kombinasi beberapa teknik pertahanan sekaligus. Tidak ada satu solusi tunggal yang sempurna, jadi lapisan pertahanan berganda memastikan bahwa jika satu lapisan gagal, lapisan lainnya tetap melindungi.
Strategi Defense in Depth
| Lapisan | Teknik | Kekuatan |
|---|---|---|
| Lapisan 1 | SameSite=Lax/Strict cookies | Mencegah sebagian besar serangan cross-site otomatis |
| Lapisan 2 | CSRF Token (Synchronizer / Double Submit) | Melindungi dari serangan yang lolos SameSite |
| Lapisan 3 | Custom Header Validation | Mencegah form-based attack |
| Lapisan 4 | Re-authentication untuk aksi sensitif | Melindungi operasi kritis (ubah password, hapus akun) |
| Lapisan 5 | CORS Policy yang ketat | Membatasi domain yang bisa berinteraksi dengan API |
Implementasi Lengkap Defense in Depth
# ✅ Defense in Depth: Kombinasi Semua Teknik CSRF
from flask import Flask, request, session, abort, jsonify
from flask_cors import CORS
from functools import wraps
import secrets
import hmac
import time
app = Flask(__name__)
app.secret_key = secrets.token_hex(32)
# CORS yang ketat — hanya izinkan origin tertentu
CORS(app, origins=['https://beebanelabs.pages.dev'], supports_credentials=True)
# Konfigurasi Cookie
app.config.update(
SESSION_COOKIE_SECURE=True,
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE='Lax',
SESSION_COOKIE_NAME='__Host-session',
PERMANENT_SESSION_LIFETIME=1800
)
# === LAPISAN 1: SameSite Cookies (sudah di-set di atas) ===
# === LAPISAN 2: CSRF Token ===
def generate_csrf_token():
if '_csrf_token' not in session:
session['_csrf_token'] = secrets.token_hex(32)
return session['_csrf_token']
def validate_csrf_token(token):
session_token = session.get('_csrf_token')
if not session_token or not token:
return False
return hmac.compare_digest(session_token, token)
# === LAPISAN 3: Custom Header Validation ===
def check_custom_header():
xrw = request.headers.get('X-Requested-With')
return xrw == 'XMLHttpRequest'
# === LAPISAN 4: Re-authentication untuk aksi sensitif ===
def require_reauth(f):
@wraps(f)
def decorated(*args, **kwargs):
password = request.headers.get('X-Confirm-Password')
if not password:
abort(403, "Password confirmation required")
user = get_user(session['user_id'])
if not verify_password(password, user['password_hash']):
abort(403, "Password incorrect")
return f(*args, **kwargs)
return decorated
# Middleware gabungan
@app.before_request
def csrf_protection():
if request.method in ('POST', 'PUT', 'DELETE', 'PATCH'):
# Lapisan 2: CSRF Token
token = (request.headers.get('X-CSRF-Token') or
request.form.get('csrf_token') or
(request.get_json(silent=True) or {}).get('csrf_token'))
if not validate_csrf_token(token):
abort(403, "CSRF token invalid")
# Lapisan 3: Custom Header (opsional, tambahan)
if not check_custom_header():
abort(403, "Missing X-Requested-With header")
@app.context_processor
def inject_csrf():
return dict(csrf_token=generate_csrf_token)
# === LAPISAN 5: CORS (sudah di-set di atas) ===
# Endpoint reguler
@app.route('/api/profile', methods=['PUT'])
def update_profile():
if 'user_id' not in session:
abort(401)
data = request.get_json()
update_user_profile(session['user_id'], data)
return jsonify({"message": "Profil berhasil diperbarui"})
# Endpoint sensitif dengan re-authentication
@app.route('/api/delete-account', methods=['DELETE'])
@require_reauth
def delete_account():
if 'user_id' not in session:
abort(401)
delete_user(session['user_id'])
session.clear()
return jsonify({"message": "Akun berhasil dihapus"})
Checklist CSRF Protection
- ✅ SameSite cookies — setel Lax atau Strict untuk semua session cookies
- ✅ CSRF token — implementasikan di semua form dan state-changing API endpoints
- ✅ Custom headers — validasi X-Requested-With atau header kustom lainnya
- ✅ Re-authentication — minta password ulang untuk operasi sensitif
- ✅ CORS yang ketat — jangan izinkan origin wildcard dengan credentials
- ✅ GET harus idempotent — jangan lakukan aksi perubahan data via GET
- ✅ Constant-time comparison — gunakan
hmac.compare_digest() - ✅ Token regeneration — regenerate token setelah login dan untuk aksi penting
- ✅ HTTPS everywhere — cookie Secure flag dan HSTS header
- ✅ Security headers — X-Frame-Options, Content-Security-Policy
Teknik CSRF yang TIDAK Direkomendasikan
- Mengandalkan Referer/Origin header saja — bisa di-strip oleh proxy atau privacy extension
- GET request untuk aksi — mudah dieksploitasi via <img> tag
- CSRF token yang bisa diprediksi — gunakan cryptographically random
- Hanya mengandalkan SameSite=None — tidak memberikan perlindungan apapun
- Menyimpan CSRF token di localStorage — rentan terhadap XSS
- Menggunakan session ID sebagai CSRF token — bocor melalui log dan referer
8. Quiz: Uji Pemahamanmu!
Setelah membaca tutorial di atas, jawablah 5 pertanyaan berikut untuk menguji pemahamanmu tentang CSRF Protection: