Brute Force #

Brute force adalah serangan yang mencoba semua kemungkinan kombinasi sampai menemukan yang benar. Dalam konteks web security, ini paling sering berarti mencoba ribuan atau jutaan kombinasi password untuk satu akun, atau mencoba daftar credential yang sudah bocor dari pelanggaran data sebelumnya. Tidak ada kecanggihan teknis yang diperlukan — hanya automasi dan kesabaran.

Yang membuat brute force relevan dan berbahaya di 2025: jumlah credential yang bocor dari data breach sudah mencapai miliaran pasang. Koleksi seperti RockYou2021 mengandung 8.4 miliar password unik. Attacker tidak perlu menebak dari nol — mereka menggunakan credential yang sudah bocor dari satu layanan untuk mencoba masuk ke layanan lain (credential stuffing), asumsinya banyak orang menggunakan password yang sama di berbagai platform.

Sistem yang tidak memiliki perlindungan brute force adalah sistem yang menunggu dieksploitasi — bukan apakah, tapi kapan.

Empat Jenis Serangan Brute Force #

graph TD
    A[Brute Force Attacks] --> B[Credential Stuffing]
    A --> C[Password Spraying]
    A --> D[Dictionary Attack]
    A --> E[Pure Brute Force]

    B --> B1["Gunakan credential bocor\ndari breach lain\n1 password per target"]
    C --> C1["1 password umum\nke banyak akun\nHindari lockout"]
    D --> D1["Wordlist password umum\nTop 10M passwords\nMangling rules"]
    E --> E1["Semua kombinasi karakter\na, b, c... aa, ab...\nHanya untuk password pendek"]

    B1 --> F[Skala besar, akurasi tinggi\nSuccess rate 0.1-2%]
    C1 --> G[Tidak trigger lockout\nLambat tapi persistent]
    D1 --> H[Efektif untuk password lemah]
    E1 --> I[Tidak praktis untuk 12+ karakter]

Credential Stuffing #

Ini adalah ancaman paling serius saat ini. Attacker mengambil pasangan email:password dari breach yang sudah ada dan mencobanya secara otomatis ke ratusan layanan berbeda.

Mengapa credential stuffing sangat efektif:

  Data yang tersedia:
  → RockYou2021: 8.4 miliar credential
  → Collection #1-5: 2.2 miliar unik credential
  → Breach dari LinkedIn, Adobe, Yahoo, dll masih aktif digunakan

  Success rate:
  → 0.1% - 2% credential masih valid di layanan lain
  → Dengan 10 juta credential: 10.000 - 200.000 akun berhasil dikompromikan

  Alat yang digunakan:
  → Sentry MBA, OpenBullet, Snipr — tool otomatis yang tersedia bebas
  → Konfigurasi per-site, mendukung proxy rotation, CAPTCHA bypass
  → Bisa berjalan dari ribuan IP berbeda secara bersamaan

  Tanda-tanda credential stuffing:
  → Login failure rate tiba-tiba naik
  → Login berhasil dari IP/lokasi yang tidak biasa
  → Banyak "successful login" tapi user tidak mengenalinya
  → Traffic login dari berbagai IP yang berbeda

Password Spraying #

Alih-alih mencoba banyak password untuk satu akun (yang akan trigger lockout), password spraying mencoba satu atau beberapa password yang sangat umum ke banyak akun. Teknik ini khususnya efektif di lingkungan enterprise.

Password spraying vs traditional brute force:

  Traditional brute force (mudah terdeteksi):
  Account: [email protected]
  Attempt 1: password123
  Attempt 2: qwerty123
  Attempt 3: ali123
  ... (ribuan attempt → trigger lockout setelah 5 attempt)

  Password spraying (sulit terdeteksi):
  Password: Spring2025!  (memenuhi policy tapi mudah ditebak)

  Attempt 1: [email protected] → Spring2025!    (tidak lock)
  Attempt 2: [email protected] → Spring2025!   (tidak lock)
  Attempt 3: [email protected] → Spring2025!  (tidak lock)
  ...ribuan akun...
  Attempt N: [email protected] → Spring2025!  ← BERHASIL

  Setiap akun hanya dicoba sekali atau dua kali → tidak trigger lockout
  Attacker menunggu beberapa jam antara batch untuk lebih aman

Rate Limiting yang Efektif #

Rate limiting adalah pertahanan pertama terhadap semua bentuk brute force. Tapi implementasi yang naif bisa dibypass dengan mudah.

import redis
import time
import hashlib
from dataclasses import dataclass
from typing import Optional

redis_client = redis.Redis(host='redis', port=6379, decode_responses=True)

@dataclass
class RateLimitResult:
    allowed: bool
    remaining_attempts: int
    reset_after_seconds: int
    lockout_seconds: Optional[int] = None

class LoginRateLimiter:
    """
    Rate limiter berlapis untuk endpoint login:
    1. Per IP — melindungi dari satu IP yang agresif
    2. Per akun — melindungi dari credential stuffing dari banyak IP
    3. Per IP + akun — kombinasi yang paling presisi
    """

    # Konfigurasi: (max_attempts, window_seconds, lockout_seconds)
    IP_LIMIT = (30, 300, 300)        # 30 per 5 menit, lockout 5 menit
    ACCOUNT_LIMIT = (5, 900, 900)    # 5 per 15 menit, lockout 15 menit
    GLOBAL_LIMIT = (10000, 60, None) # Global: 10k per menit (DDoS guard)

    def check_and_record(
        self, ip: str, email: str
    ) -> RateLimitResult:
        """
        Cek semua rate limit dan record attempt.
        Return False jika salah satu limit terlampaui.
        """

        # Cek lockout aktif
        lockout_result = self._check_lockout(ip, email)
        if lockout_result:
            return lockout_result

        # Cek rate limit per IP
        ip_result = self._check_limit(
            f"rl:ip:{ip}", *self.IP_LIMIT
        )
        if not ip_result.allowed:
            self._set_lockout(f"lockout:ip:{ip}", self.IP_LIMIT[2])
            return ip_result

        # Cek rate limit per akun (email di-hash untuk privacy)
        email_hash = hashlib.sha256(email.lower().encode()).hexdigest()[:16]
        account_result = self._check_limit(
            f"rl:account:{email_hash}", *self.ACCOUNT_LIMIT
        )
        if not account_result.allowed:
            self._set_lockout(
                f"lockout:account:{email_hash}", self.ACCOUNT_LIMIT[2]
            )
            return account_result

        return RateLimitResult(
            allowed=True,
            remaining_attempts=min(
                ip_result.remaining_attempts,
                account_result.remaining_attempts
            ),
            reset_after_seconds=min(
                ip_result.reset_after_seconds,
                account_result.reset_after_seconds
            )
        )

    def _check_limit(
        self, key: str, max_attempts: int,
        window: int, lockout: Optional[int]
    ) -> RateLimitResult:
        now = time.time()
        pipe = redis_client.pipeline()
        pipe.zremrangebyscore(key, 0, now - window)
        pipe.zcard(key)
        pipe.zadd(key, {str(now): now})
        pipe.expire(key, window)
        _, count, _, _ = pipe.execute()

        remaining = max(0, max_attempts - count - 1)
        allowed = count < max_attempts

        return RateLimitResult(
            allowed=allowed,
            remaining_attempts=remaining,
            reset_after_seconds=window
        )

    def _check_lockout(
        self, ip: str, email: str
    ) -> Optional[RateLimitResult]:
        email_hash = hashlib.sha256(email.lower().encode()).hexdigest()[:16]

        for key in [f"lockout:ip:{ip}", f"lockout:account:{email_hash}"]:
            ttl = redis_client.ttl(key)
            if ttl > 0:
                return RateLimitResult(
                    allowed=False,
                    remaining_attempts=0,
                    reset_after_seconds=ttl,
                    lockout_seconds=ttl
                )
        return None

    def _set_lockout(self, key: str, seconds: int):
        redis_client.setex(key, seconds, 1)

    def reset_on_success(self, ip: str, email: str):
        """Reset counter setelah login berhasil."""
        email_hash = hashlib.sha256(email.lower().encode()).hexdigest()[:16]
        redis_client.delete(
            f"rl:ip:{ip}",
            f"rl:account:{email_hash}",
            f"lockout:ip:{ip}",
            f"lockout:account:{email_hash}"
        )

login_limiter = LoginRateLimiter()

@app.route('/login', methods=['POST'])
def login():
    ip = get_real_ip(request)  # handle proxy headers dengan hati-hati
    email = request.json.get('email', '').lower().strip()
    password = request.json.get('password', '')

    # Cek rate limit sebelum apapun
    limit_result = login_limiter.check_and_record(ip, email)

    if not limit_result.allowed:
        response = jsonify({
            'error': 'Too many attempts',
            'retry_after': limit_result.lockout_seconds or limit_result.reset_after_seconds
        })
        response.status_code = 429
        response.headers['Retry-After'] = str(
            limit_result.lockout_seconds or limit_result.reset_after_seconds
        )
        return response

    # Proses login dengan timing yang konsisten
    user = authenticate_user(email, password)

    if user:
        login_limiter.reset_on_success(ip, email)
        session_token = create_session(user.id, request)
        response = make_response(jsonify({'status': 'ok'}))
        set_auth_cookie(response, session_token)
        return response

    # Login gagal — pesan yang sama untuk semua kasus
    return jsonify({'error': 'Email atau password tidak valid'}), 401

Timing Attack: Celah yang Sering Diabaikan #

Waktu yang dibutuhkan untuk memproses login request bisa membocorkan informasi kepada attacker.

# ANTI-PATTERN: timing leak — attacker bisa bedakan "email tidak ada" vs "password salah"
@app.route('/login', methods=['POST'])
def login_with_timing_leak():
    email = request.json['email']
    password = request.json['password']

    user = User.query.filter_by(email=email).first()

    if not user:
        # Return CEPAT — tidak ada hash computation
        return jsonify({'error': 'Invalid credentials'}), 401

    if not verify_password(password, user.password_hash):
        # Return LAMBAT — hash computation terjadi
        return jsonify({'error': 'Invalid credentials'}), 401

# Attacker mengukur response time:
# < 5ms = email tidak ada di database (langsung return)
# > 100ms = email ada tapi password salah (hash computation terjadi)
# → Attacker bisa enumerate email valid tanpa trigger rate limit

# BENAR: waktu respons yang konsisten
from argon2 import PasswordHasher
ph = PasswordHasher()

# Hash dummy yang valid untuk digunakan ketika user tidak ditemukan
DUMMY_HASH = ph.hash("dummy_password_that_never_matches")

@app.route('/login', methods=['POST'])
def login_constant_time():
    email = request.json.get('email', '')
    password = request.json.get('password', '')

    user = User.query.filter_by(email=email).first()

    if user:
        password_hash = user.password_hash
    else:
        # Gunakan dummy hash — tetap jalankan verifikasi
        # untuk memastikan waktu yang sama
        password_hash = DUMMY_HASH

    # Verifikasi selalu dijalankan, terlepas apakah user ada atau tidak
    try:
        is_valid = ph.verify(password_hash, password)
        if user and is_valid:
            # Login berhasil
            return success_response(user)
    except Exception:
        pass

    # Login gagal — pesan yang sama
    return jsonify({'error': 'Email atau password tidak valid'}), 401

Account Lockout yang Tidak Merugikan User Legitimate #

Account lockout yang terlalu agresif bisa digunakan oleh attacker sebagai serangan availability: mereka sengaja mengunci akun user legitimate dengan mencoba password yang salah.

class SmartAccountLockout:
    """
    Lockout yang cerdas:
    - Tidak langsung lockout permanen
    - Progressive delay
    - Tidak memungkinkan attacker untuk DOS akun user lain
    """

    def __init__(self):
        # Progressive lockout: semakin banyak gagal, semakin lama locked
        self.lockout_schedule = [
            (3, 30),      # Setelah 3 gagal: lock 30 detik
            (5, 300),     # Setelah 5 gagal: lock 5 menit
            (10, 1800),   # Setelah 10 gagal: lock 30 menit
            (20, 86400),  # Setelah 20 gagal: lock 24 jam
        ]

    def get_lockout_duration(self, failure_count: int) -> int:
        """Return durasi lockout berdasarkan jumlah kegagalan."""
        duration = 0
        for threshold, seconds in self.lockout_schedule:
            if failure_count >= threshold:
                duration = seconds
        return duration

    def record_failure(self, account_key: str) -> dict:
        key = f"login_failures:{account_key}"

        # Increment failure count
        failure_count = redis_client.incr(key)

        # Set expiry jika baru dibuat
        if failure_count == 1:
            redis_client.expire(key, 86400)  # reset setelah 24 jam tanpa activity

        # Tentukan lockout duration
        lockout_duration = self.get_lockout_duration(failure_count)

        if lockout_duration > 0:
            lockout_key = f"lockout:{account_key}"
            redis_client.setex(lockout_key, lockout_duration, failure_count)

        return {
            'failure_count': failure_count,
            'locked': lockout_duration > 0,
            'lockout_seconds': lockout_duration
        }

    def is_locked(self, account_key: str) -> tuple[bool, int]:
        """Return (is_locked, seconds_remaining)."""
        lockout_key = f"lockout:{account_key}"
        ttl = redis_client.ttl(lockout_key)
        return ttl > 0, max(0, ttl)

    def unlock_on_success(self, account_key: str):
        """Reset setelah login berhasil."""
        redis_client.delete(
            f"login_failures:{account_key}",
            f"lockout:{account_key}"
        )
Strategi lockout yang aman:

  ✓ Progressive lockout — semakin banyak gagal, semakin lama locked
    Attacker yang mencoba 100x akan kena lockout lama
    User yang lupa password biasanya mencoba 2-3x

  ✓ Soft lockout dengan notifikasi email
    "Akun Anda sementara terkunci. Jika bukan Anda yang mencoba login,
    klik di sini untuk mengamankan akun."

  ✓ Unlock via email verification
    Kirim link unlock ke email terdaftar
    Attacker tidak bisa unlock kecuali punya akses ke email

  ✗ Jangan lockout permanent tanpa mekanisme unlock
    User yang legitimate tidak bisa masuk ke akunnya sendiri

  ✗ Jangan lockout berdasarkan IP saja
    Satu IP bisa share banyak user (NAT, corporate network)
    Lockout IP bisa kena user innocent

CAPTCHA: Efektif tapi Bukan Solusi Lengkap #

CAPTCHA mempersulit automasi dengan menghadirkan challenge yang mudah bagi manusia tapi sulit bagi mesin.

# Integrasi Google reCAPTCHA v3 (invisible, berbasis score)
import requests

RECAPTCHA_SECRET = os.environ['RECAPTCHA_SECRET_KEY']
RECAPTCHA_VERIFY_URL = "https://www.google.com/recaptcha/api/siteverify"
RECAPTCHA_THRESHOLD = 0.5  # Score 0 (bot) sampai 1 (manusia)

def verify_recaptcha(token: str, action: str) -> tuple[bool, float]:
    """
    Verifikasi reCAPTCHA token.
    Returns: (is_human, score)
    """
    try:
        response = requests.post(RECAPTCHA_VERIFY_URL, data={
            'secret': RECAPTCHA_SECRET,
            'response': token,
        }, timeout=5)

        result = response.json()

        if not result.get('success'):
            return False, 0.0

        # Verifikasi action sesuai
        if result.get('action') != action:
            return False, 0.0

        score = result.get('score', 0)
        return score >= RECAPTCHA_THRESHOLD, score

    except requests.RequestException:
        # Jika CAPTCHA service down, jangan blokir user legitimate
        # Tapi log untuk monitoring
        logger.warning("reCAPTCHA verification failed — service unavailable")
        return True, 1.0  # Default allow jika service down

@app.route('/login', methods=['POST'])
def login():
    # Ambil CAPTCHA token dari request
    captcha_token = request.json.get('captcha_token')

    if not captcha_token:
        return jsonify({'error': 'CAPTCHA required'}), 400

    is_human, score = verify_recaptcha(captcha_token, 'login')

    if not is_human:
        logger.warning(
            "Low reCAPTCHA score on login",
            extra={'score': score, 'ip': request.remote_addr}
        )
        return jsonify({'error': 'CAPTCHA verification failed'}), 429

    # Lanjutkan proses login
    # ...
Keterbatasan CAPTCHA:

  reCAPTCHA v2 (teka-teki gambar):
  → Bisa diselesaikan oleh manusia yang dibayar murah (CAPTCHA farm)
  → Harga: $1 per 1000 solves — attacker bisa bypass dengan murah

  reCAPTCHA v3 (invisible, score-based):
  → Tidak mengganggu UX
  → Bisa di-bypass dengan menggunakan browser nyata
  → Masih efektif untuk mendeteksi bot sederhana

  hCaptcha, Turnstile (Cloudflare):
  → Alternatif yang makin populer
  → Trade-off privacy berbeda dari Google

  CAPTCHA bukan solusi standalone:
  → Gunakan bersama rate limiting dan account lockout
  → CAPTCHA melengkapi, bukan menggantikan

Deteksi Credential Stuffing #

Pola traffic credential stuffing berbeda dari login normal — bisa dideteksi dengan analisis yang tepat.

from collections import defaultdict
import geoip2.database

# GeoIP database untuk deteksi lokasi
reader = geoip2.database.Reader('/path/to/GeoLite2-City.mmdb')

class CredentialStuffingDetector:
    """
    Deteksi credential stuffing berdasarkan pola traffic yang anomalous.
    """

    def __init__(self, window_seconds: int = 300):
        self.window = window_seconds

    def analyze_login_pattern(self, ip: str, email: str) -> dict:
        """Analisis apakah request ini terlihat seperti credential stuffing."""
        signals = []

        # Signal 1: User-Agent yang tidak umum atau sama persis antar request
        user_agent = request.user_agent.string
        if self._is_suspicious_user_agent(user_agent):
            signals.append('suspicious_user_agent')

        # Signal 2: Tidak ada header yang biasanya ada di browser nyata
        expected_headers = ['accept', 'accept-language', 'accept-encoding']
        missing = [h for h in expected_headers if h not in request.headers]
        if len(missing) > 1:
            signals.append('missing_browser_headers')

        # Signal 3: Login dari banyak email berbeda dari satu IP dalam waktu singkat
        ip_key = f"logins_from_ip:{ip}"
        emails_from_ip = redis_client.pfcount(ip_key)  # HyperLogLog untuk counting
        if emails_from_ip > 10:
            signals.append('many_accounts_from_ip')

        # Signal 4: Lokasi yang tidak konsisten dengan pola login historis
        try:
            location = reader.city(ip)
            country = location.country.iso_code
            if self._is_impossible_location(email, country):
                signals.append('impossible_location')
        except Exception:
            pass

        # Signal 5: IP address dari daftar proxy/VPN/Tor yang diketahui
        if self._is_known_proxy_ip(ip):
            signals.append('proxy_ip')

        # Record email ini dari IP (dengan TTL)
        redis_client.pfadd(ip_key, email)
        redis_client.expire(ip_key, self.window)

        risk_score = len(signals)
        return {
            'risk_score': risk_score,
            'signals': signals,
            'action': self._determine_action(risk_score)
        }

    def _is_suspicious_user_agent(self, ua: str) -> bool:
        # Tool credential stuffing sering menggunakan UA yang aneh
        suspicious_patterns = [
            'python-requests', 'axios/', 'okhttp/', 'curl/',
            'go-http-client', 'java/', 'php/'
        ]
        ua_lower = ua.lower()
        return any(p in ua_lower for p in suspicious_patterns)

    def _determine_action(self, risk_score: int) -> str:
        if risk_score == 0:
            return 'allow'
        elif risk_score == 1:
            return 'monitor'  # log tapi izinkan
        elif risk_score == 2:
            return 'challenge'  # tampilkan CAPTCHA
        else:
            return 'block'  # blokir sementara

    def _is_known_proxy_ip(self, ip: str) -> bool:
        # Cek terhadap database IP proxy/VPN/Tor yang diketahui
        # Bisa menggunakan layanan seperti IPQualityScore, MaxMind, dll
        proxy_key = f"known_proxy:{ip}"
        return redis_client.exists(proxy_key) == 1

    def _is_impossible_location(self, email: str, current_country: str) -> bool:
        # Bandingkan dengan country terakhir yang diketahui
        history_key = f"login_country:{hashlib.sha256(email.encode()).hexdigest()[:16]}"
        last_country = redis_client.get(history_key)
        if last_country and last_country != current_country:
            return True
        return False

detector = CredentialStuffingDetector()

@app.route('/login', methods=['POST'])
def login():
    ip = request.remote_addr
    email = request.json.get('email', '')

    # Analisis credential stuffing signals
    analysis = detector.analyze_login_pattern(ip, email)

    if analysis['action'] == 'block':
        return jsonify({
            'error': 'Aktivitas mencurigakan terdeteksi',
            'retry_after': 300
        }), 429

    if analysis['action'] == 'challenge':
        # Minta CAPTCHA untuk request mencurigakan
        return jsonify({
            'challenge_required': True,
            'challenge_type': 'captcha'
        }), 202

    # Lanjutkan proses login normal
    # ...

Breached Password Detection #

Menolak password yang sudah diketahui bocor adalah langkah proaktif yang sangat efektif.

import hashlib
import requests

HIBP_API_URL = "https://api.pwnedpasswords.com/range/{}"

def is_password_breached(password: str) -> tuple[bool, int]:
    """
    Cek apakah password ada dalam database HaveIBeenPwned.
    Menggunakan k-Anonymity — password tidak dikirim ke API.
    Returns: (is_breached, breach_count)
    """
    sha1 = hashlib.sha1(password.encode('utf-8')).hexdigest().upper()
    prefix = sha1[:5]
    suffix = sha1[5:]

    try:
        response = requests.get(
            HIBP_API_URL.format(prefix),
            headers={'User-Agent': 'MyApp-Security-Check/1.0'},
            timeout=5
        )
        response.raise_for_status()

        for line in response.text.splitlines():
            line_suffix, count = line.split(':')
            if line_suffix == suffix:
                return True, int(count)

        return False, 0

    except requests.RequestException:
        # Jika API tidak tersedia, jangan blokir — log dan lanjutkan
        logger.warning("HIBP API unavailable")
        return False, 0

# Gunakan saat registrasi dan saat ganti password
def validate_password_strength(password: str) -> list[str]:
    errors = []

    if len(password) < 12:
        errors.append("Password minimal 12 karakter")

    is_breached, count = is_password_breached(password)
    if is_breached:
        errors.append(
            f"Password ini ditemukan dalam {count:,} data breach. "
            f"Gunakan password yang unik dan belum pernah digunakan."
        )

    return errors

Monitoring dan Alerting #

Brute force yang tidak terdeteksi bisa berjalan berhari-hari tanpa ketahuan.

# Alert metric yang harus dipantau

# 1. Login failure rate — naik signifikan = tanda serangan
# Baseline: X failure per menit di jam normal
# Alert: jika melebihi 5x baseline selama 5 menit berturut

# 2. Unique email count dari satu IP dalam 5 menit
# Baseline: 1-2 email per IP
# Alert: > 10 email berbeda dari satu IP

# 3. Login success rate — turun signifikan = tanda serangan
# Baseline: 70-80% request login berhasil
# Alert: jika success rate turun di bawah 30% selama 10 menit

# 4. Akun yang sukses login dari lokasi baru
# Alert: user yang biasanya login dari Jakarta
#        tiba-tiba login dari IP di Eropa Timur

# Implementasi sederhana dengan counter di Redis
def record_login_metric(success: bool, ip: str, email: str):
    now = int(time.time())
    minute_bucket = now - (now % 60)

    pipe = redis_client.pipeline()
    if success:
        pipe.incr(f"login:success:{minute_bucket}")
    else:
        pipe.incr(f"login:failure:{minute_bucket}")
        # Track unique emails per IP
        pipe.sadd(f"login:ips:{ip}:{minute_bucket}", email)
        pipe.expire(f"login:ips:{ip}:{minute_bucket}", 600)

    pipe.expire(f"login:success:{minute_bucket}", 600)
    pipe.expire(f"login:failure:{minute_bucket}", 600)
    pipe.execute()

def check_brute_force_alert() -> list[str]:
    now = int(time.time())
    alerts = []

    # Cek 5 menit terakhir
    total_success = 0
    total_failure = 0
    for i in range(5):
        bucket = now - (now % 60) - (i * 60)
        success = int(redis_client.get(f"login:success:{bucket}") or 0)
        failure = int(redis_client.get(f"login:failure:{bucket}") or 0)
        total_success += success
        total_failure += failure

    total = total_success + total_failure
    if total > 0:
        failure_rate = total_failure / total
        if failure_rate > 0.7 and total > 100:
            alerts.append(
                f"High login failure rate: {failure_rate:.1%} "
                f"({total_failure}/{total} failed in last 5 min)"
            )

    return alerts

Anti-Pattern yang Harus Dihindari #

# ✗ Anti-pattern 1: tidak ada rate limiting pada login endpoint
@app.route('/login', methods=['POST'])
def login():
    user = authenticate(request.json['email'], request.json['password'])
    # Tidak ada rate limit — bisa coba jutaan password per detik

# ✗ Anti-pattern 2: pesan error yang membedakan kasus
if not user_exists:
    return "Email tidak terdaftar", 401  # bocor: email tidak ada
if wrong_password:
    return "Password salah", 401         # bocor: email valid
# ✓ Solusi: "Email atau password tidak valid" untuk semua kasus

# ✗ Anti-pattern 3: lockout yang bisa di-abuse untuk DOS akun orang lain
# Attacker sengaja mencoba 10x dengan email target
# → Akun target terkunci
# ✓ Solusi: progressive delay + notifikasi email, bukan hard lockout permanen

# ✗ Anti-pattern 4: rate limit hanya per IP
# Attacker menggunakan ribuan IP berbeda (botnet, residential proxy)
# → Setiap IP hanya 1-2 attempt → tidak pernah trigger IP limit
# ✓ Solusi: rate limit per akun JUGA, bukan hanya per IP

# ✗ Anti-pattern 5: tidak ada monitoring
# Credential stuffing berjalan berhari-hari tanpa terdeteksi
# ✓ Solusi: alert untuk anomali login failure rate

# ✗ Anti-pattern 6: reset rate limit setelah successful login dari IP yang sama
# Attacker berhasil login ke satu akun → rate limit direset untuk IP itu
# → Bisa lanjut coba akun lain dari IP yang sama tanpa limit
# ✓ Solusi: reset hanya untuk akun yang berhasil, bukan untuk IP

Checklist Brute Force Protection #

RATE LIMITING:
  □ Rate limit per IP (lebih longgar — bisa shared NAT)
  □ Rate limit per akun/email (lebih ketat)
  □ Rate limit global endpoint login (guard terhadap DDoS)
  □ Progressive: semakin banyak gagal, semakin lama wait
  □ Retry-After header dikirim di response 429

ACCOUNT LOCKOUT:
  □ Progressive lockout (bukan langsung permanent)
  □ Notifikasi email saat akun terkunci
  □ Mekanisme unlock via email (bukan hanya tunggu timeout)
  □ Attacker tidak bisa lockout akun orang lain secara trivial

TIMING & INFORMATION:
  □ Waktu respons konsisten: email tidak ada ≈ password salah
  □ Pesan error tidak membedakan "email tidak ada" vs "password salah"
  □ Dummy hash computation saat email tidak ditemukan

CREDENTIAL STUFFING:
  □ HIBP API check pada registrasi dan ganti password
  □ Deteksi User-Agent yang mencurigakan (python-requests, curl, dll)
  □ Deteksi banyak email dari satu IP dalam window singkat
  □ Deteksi login dari lokasi yang tidak biasa untuk akun tersebut

CAPTCHA:
  □ CAPTCHA atau challenge setelah N kali gagal
  □ CAPTCHA tidak memblokir user jika service CAPTCHA down
  □ CAPTCHA bukan satu-satunya perlindungan (kombinasikan dengan rate limit)

MFA:
  □ MFA tersedia dan dianjurkan ke semua user
  □ MFA wajib untuk akun admin dan akun dengan privilege tinggi
  □ Brute force MFA code juga di-rate-limit

MONITORING:
  □ Alert untuk login failure rate yang anomalous
  □ Alert untuk banyak email berbeda dari satu IP
  □ Alert untuk login berhasil dari lokasi yang sangat tidak biasa
  □ Dashboard real-time untuk login metrics
  □ Log semua failed attempt dengan detail (IP, UA, timestamp, email hash)

Ringkasan #

  • Credential stuffing adalah ancaman terbesar — bukan mencoba password acak, tapi menggunakan miliaran credential yang sudah bocor. Success rate kecil tapi volumenya sangat besar.
  • Rate limiting harus di dua dimensi — per IP saja tidak cukup karena credential stuffing menggunakan ribuan IP. Rate limit per akun adalah perlindungan yang lebih penting.
  • Progressive lockout lebih baik dari hard lockout — hard lockout bisa di-abuse untuk mengunci akun orang lain. Progressive delay (30 detik, 5 menit, 30 menit) memberikan perlindungan tanpa jadi senjata DoS.
  • Timing attack membocorkan informasi — perbedaan waktu antara “email tidak ada” dan “password salah” memungkinkan enumeration email. Selalu jalankan hash computation bahkan ketika email tidak ditemukan.
  • Pesan error yang seragam wajib — “Email atau password tidak valid” untuk semua kasus. Jangan pernah bedakan “email tidak terdaftar” dari “password salah”.
  • HIBP check proaktif mencegah reuse credential yang bocor — menolak password yang ada di database breach lebih efektif dari semua policy kompleksitas lainnya.
  • MFA adalah mitigasi paling efektif — bahkan jika password berhasil ditebak, attacker masih perlu faktor kedua. Credential stuffing hampir seluruhnya tidak efektif terhadap akun dengan MFA.
  • Deteksi pola credential stuffing — banyak email berbeda dari satu IP, User-Agent yang tidak biasa, header browser yang missing, dan lokasi yang tidak mungkin adalah sinyal yang bisa dideteksi.
  • CAPTCHA bukan solusi sendirian — CAPTCHA farm bisa menyelesaikan CAPTCHA dengan harga $1/1000. Gunakan sebagai salah satu lapisan, bukan satu-satunya.
  • Monitoring real-time adalah kunci — brute force yang berjalan berhari-hari tanpa terdeteksi bisa mengkompromikan ribuan akun sebelum ada yang sadar.

← Sebelumnya: DDoS   Berikutnya: Encryption →

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