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 Limiting | Pembatas di aplikasi | |
|---|---|---|
| Berjalan di | Edge, sebelum origin | Di dalam Astro |
| Melindungi | Bandwidth & kapasitas origin | Logika bisnis |
| Tahu tentang | IP, jalur, metode, header | Email, ID member, jenis aksi |
| Contoh aturan | Maks 20 POST ke /masuk per IP per menit | Maks 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
| Jalur | Batas | Aksi |
|---|---|---|
POST /_actions/masuk | 20 / menit / IP | Blok 15 menit |
POST /_actions/daftar | 5 / jam / IP | Tantangan terkelola |
POST /_actions/mintaResetSandi | 5 / jam / IP | Blok |
/api/* | 300 / menit / IP | Blok 1 menit |
/pencarian* | 60 / menit / IP | Tantangan |
/api/midtrans-webhook | Kecualikan | Jangan 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;
| Pola | Kemungkinan besar | Tindakan |
|---|---|---|
| 3 perangkat, IP berdekatan | Satu keluarga | Biarkan โ ini pemakaian wajar |
| 15 perangkat, IP tersebar di banyak kota | Kredensial dibagikan | Batasi sesi aktif, beri tahu pemiliknya |
| 50 perangkat, banyak negara | Akun dijual | Paksa reset sandi, hubungi pemilik |
| Pola akses seperti mesin, satu IP | Scraping | Blok 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
| Pertahanan | Lapis |
|---|---|
| Bot Fight Mode & skor bot | Cloudflare |
| Rate limit per member untuk artikel premium | Aplikasi |
| Peringatan saat satu akun membuka > 100 artikel/jam | Aplikasi |
| Tanda air tak terlihat per member di isi premium | Aplikasi โ 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.