โ† Semua pembelajaran / Astro Nol โ†’ Portal Berita
Fase 5 ยท Membership, Auth & Paywall

Rate limit & penyalahgunaan akun

Portal berlangganan punya dua musuh: skrip yang mencoba ribuan sandi, dan satu akun yang dipakai tiga puluh orang. Keduanya butuh pertahanan yang berbeda.

Intisari

  • Batasi di dua lapis: Cloudflare menghentikan yang kasar sebelum sampai origin; aplikasi menangani yang butuh konteks.
  • Kunci pembatas login harus IP dan email sekaligus โ€” satu saja bisa dilewati.
  • Pembatas di memori proses tidak bekerja dengan banyak task. Pakai Redis atau tabel.
  • Selalu kirim Retry-After. Tanpa itu, klien yang sopan pun akan terus mencoba.
  • Berbagi akun adalah kebocoran pendapatan terbesar di portal berlangganan โ€” deteksi lewat jumlah sesi aktif dan sebaran IP.

Dua lapis, dua tugas

Cloudflare Rate LimitingPembatas di aplikasi
Berjalan diEdge, sebelum originDi dalam Astro
MelindungiBandwidth & kapasitas originLogika bisnis
Tahu tentangIP, jalur, metode, headerEmail, ID member, jenis aksi
Contoh aturanMaks 20 POST ke /masuk per IP per menitMaks 5 percobaan per email per 15 menit

Keduanya dibutuhkan. Cloudflare tidak tahu email mana yang sedang dicoba; aplikasimu tidak ingin menghabiskan kapasitas untuk membaca request yang jelas-jelas serangan.

Kunci yang benar untuk login

Kunci per IP saja      โ†’ penyerang dengan botnet melewatinya
Kunci per email saja   โ†’ penyerang mengunci akun orang lain (denial of service)
Kunci KEDUANYA         โ†’ benar
// src/lib/batas.ts
import { redis } from "./redis";

export async function bolehCoba(ip: string, email: string): Promise<boolean> {
  const kunci = [`login:ip:${hashIp(ip)}`, `login:email:${sha256(email)}`];
  const batas = [20, 5];              // per IP longgar, per email ketat
  const jendela = 15 * 60;

  for (let i = 0; i < kunci.length; i++) {
    const n = await redis.incr(kunci[i]!);
    if (n === 1) await redis.expire(kunci[i]!, jendela);
    if (n > batas[i]!) return false;
  }
  return true;
}

Batas per email harus lebih ketat, tapi jangan sampai jadi senjata. Kalau lima percobaan gagal mengunci akun selama 15 menit, siapa pun bisa mengunci akun orang lain dengan sengaja gagal login lima kali. Perbaikannya: hitung per kombinasi IP dan email, atau โ€” lebih baik โ€” jangan mengunci akun sama sekali, cukup perlambat responsnya secara progresif dan minta CAPTCHA setelah beberapa kegagalan.

Kenapa harus Redis, bukan Map

Batas: 5 percobaan
Task ECS: 12

Penyerang mencoba 60 kali. ALB menyebarkannya rata:
  tiap task melihat 5 percobaan โ†’ tidak ada yang melewati batas
  penyerang sebenarnya mencoba 60 kali.

Pembatas hanya berarti kalau hitungannya dibagi. Ini salah satu dari sedikit hal di roadmap ini yang benar-benar mewajibkan Redis โ€” bukan sekadar mempercepat.

Alternatif tanpa Redis: tabel MySQL

CREATE TABLE percobaan (
  kunci    VARCHAR(64) NOT NULL,
  jendela  INT UNSIGNED NOT NULL,     -- unix time dibagi ukuran jendela
  jumlah   INT UNSIGNED NOT NULL DEFAULT 0,
  PRIMARY KEY (kunci, jendela)
) ENGINE=InnoDB;
const jendela = Math.floor(Date.now() / 1000 / 900);   // 15 menit

await dbTulis
  .insertInto("percobaan")
  .values({ kunci, jendela, jumlah: 1 })
  .onDuplicateKeyUpdate((eb) => ({ jumlah: eb("percobaan.jumlah", "+", 1) }))
  .execute();

Lebih lambat dari Redis dan menambah tulisan ke database, tapi bekerja dan tidak menambah komponen baru untuk dioperasikan. Untuk jalur yang jarang seperti login, ini pilihan yang masuk akal. Jangan pakai untuk jalur baca bervolume tinggi.

Selalu kirim Retry-After

return new Response(
  JSON.stringify({ error: "Terlalu banyak permintaan" }),
  {
    status: 429,
    headers: {
      "Content-Type": "application/json",
      "Retry-After": "900",
      "Cache-Control": "no-store",
    },
  },
);

Cache-Control: no-store pada respons 429 itu penting: kamu tidak ingin Cloudflare meng-cache penolakan dan menyajikannya ke pembaca lain yang kebetulan berbagi jalur yang sama.

Aturan Cloudflare yang layak dipasang

JalurBatasAksi
POST /_actions/masuk20 / menit / IPBlok 15 menit
POST /_actions/daftar5 / jam / IPTantangan terkelola
POST /_actions/mintaResetSandi5 / jam / IPBlok
/api/*300 / menit / IPBlok 1 menit
/pencarian*60 / menit / IPTantangan
/api/midtrans-webhookKecualikanJangan pernah dibatasi

Baris terakhir itu wajib dan sering terlupa. Midtrans mengirim notifikasi dari sekumpulan IP miliknya. Kalau ada lonjakan pembayaran โ€” promo, akhir bulan โ€” semuanya datang dari IP yang sama dan akan terlihat seperti serangan bagi aturan rate limit-mu. Webhook yang diblokir berarti pembayaran yang tidak tercatat, dan pelanggan yang sudah membayar tetap melihat paywall. Kecualikan jalur webhook dari seluruh rate limiting dan dari managed WAF rules.

Berbagi akun: kebocoran pendapatan yang sesungguhnya

-- Akun dengan sesi aktif dari banyak jaringan berbeda
SELECT
  s.member_id,
  m.email,
  COUNT(*)                      AS sesi_aktif,
  COUNT(DISTINCT s.ip)          AS ip_berbeda,
  COUNT(DISTINCT s.peramban)    AS peramban_berbeda
FROM sesi s
JOIN member m ON m.id = s.member_id
WHERE s.kedaluwarsa > NOW()
  AND s.dipakai_pada > DATE_SUB(NOW(), INTERVAL 7 DAY)
GROUP BY s.member_id, m.email
HAVING ip_berbeda > 8 OR sesi_aktif > 12
ORDER BY ip_berbeda DESC
LIMIT 50;
PolaKemungkinan besarTindakan
3 perangkat, IP berdekatanSatu keluargaBiarkan โ€” ini pemakaian wajar
15 perangkat, IP tersebar di banyak kotaKredensial dibagikanBatasi sesi aktif, beri tahu pemiliknya
50 perangkat, banyak negaraAkun dijualPaksa reset sandi, hubungi pemilik
Pola akses seperti mesin, satu IPScrapingBlok dan tinjau

Cara paling lembut menegakkannya: batasi jumlah sesi aktif. Saat sesi ke-enam dibuat, sesi tertua dihapus. Pemilik akun yang wajar tidak pernah menyadarinya; yang membagikan sandinya ke dua puluh orang akan menemukan bahwa mereka saling mengeluarkan โ€” dan itu memaksa percakapan tanpa kamu perlu menuduh siapa pun.

const MAKS_SESI = 5;

// setelah membuat sesi baru
await dbTulis
  .deleteFrom("sesi")
  .where("member_id", "=", memberId)
  .where("id", "not in", (eb) =>
    eb.selectFrom("sesi")
      .select("id")
      .where("member_id", "=", memberId)
      .orderBy("dipakai_pada", "desc")
      .limit(MAKS_SESI),
  )
  .execute();

Scraping isi berbayar

PertahananLapis
Bot Fight Mode & skor botCloudflare
Rate limit per member untuk artikel premiumAplikasi
Peringatan saat satu akun membuka > 100 artikel/jamAplikasi
Tanda air tak terlihat per member di isi premiumAplikasi โ€” melacak sumber kebocoran

Latihan: pasang pembatas login berkunci ganda memakai tabel MySQL. Uji dengan skrip yang mencoba 30 login gagal dan verifikasi ia berhenti di percobaan ke-6 untuk email yang sama. Lalu jalankan kueri deteksi berbagi akun pada database member-mu yang sebenarnya โ€” angkanya kemungkinan besar akan mengejutkanmu, dan itu perhitungan bisnis yang layak dibawa ke rapat.

Rangkuman ini sengaja dipangkas ke bagian yang dipakai di roadmap. Buka sumber aslinya saat kamu butuh detail lengkap atau referensi parameter.