7 phút đọc

Không có rate limit, form quên mật khẩu thành máy bắn spam miễn phí

Form quên mật khẩu, đăng nhập, gửi OTP không giới hạn số lần gọi thì ai cũng dùng được để spam người khác và đốt tiền email, SMS của bạn. Cách đặt rate limit theo IP, theo tài khoản, trả 429 kèm Retry-After, có code Next.js và Express.

Xem nhanh trong 1 phút

Mỗi lần ai đó bấm “Quên mật khẩu”, app của bạn gửi đi một email. Nếu bước đó là SMS thì mỗi tin tốn vài trăm đồng. Bây giờ thử hình dung một script gọi nút đó 50.000 lần trong một đêm, mỗi lần một email nạn nhân khác nhau, hoặc cùng một số điện thoại của người bạn ghét. App không chặn gì cả, vì lúc nhờ AI viết form, không ai nhắc tới chuyện giới hạn.

Sáng ra bạn thấy tài khoản Resend hay SendGrid bị khóa vì gửi spam, tên miền vào danh sách đen, hoặc hóa đơn SMS dài bất thường. OWASP đưa đúng kịch bản này vào danh sách lỗi API phổ biến: hacker gọi API khởi tạo đặt lại mật khẩu hàng chục nghìn lần, mỗi lần nhà cung cấp SMS tính 0,05 đô, và công ty mất vài nghìn đô trong vài phút.

Linh vật robot đứng cạnh một cái phễu, dòng phong bì thư đổ ồ ạt vào bị chặn lại thành từng giọt nhỏ đều đặn

Rate limit là gì, đặt ở đâu

Rate limit là giới hạn số lần một “ai đó” được gọi một chức năng trong một khoảng thời gian. Quá giới hạn thì server từ chối, trả mã 429 Too Many Requests.

Chữ “ai đó” là phần cần nghĩ. Có ba cách đếm, và form nhạy cảm thường cần hai cách cùng lúc:

  • Theo IP: chặn một máy gọi dồn dập. Dễ làm nhưng hacker có thể đổi IP liên tục, còn người dùng sau cùng một mạng công ty thì bị chặn chung.
  • Theo tài khoản hoặc email đích: chặn việc spam một người. OWASP khuyên khóa đăng nhập theo tài khoản chứ không theo IP, chính vì hacker đổi IP được.
  • Theo API key: cho API bạn cấp key cho khách hoặc cho app khác gọi.

Những chỗ app vibe code hay quên giới hạn nhất, xếp theo độ đau:

Chức năng Vì sao cần chặn Gợi ý giới hạn
Quên mật khẩu, gửi lại email xác nhận, magic link Spam hộp thư người khác, đốt quota email 1 lần mỗi 60 giây cho mỗi email, vài lần mỗi giờ cho mỗi IP
Gửi OTP qua SMS hoặc Zalo Tốn tiền thật cho mỗi tin Như trên, cộng trần số tin mỗi ngày cho cả app
Đăng nhập, nhập mã OTP Dò mật khẩu, dò mã 6 số Khóa tạm theo tài khoản sau vài lần sai
Đăng ký Tạo hàng loạt tài khoản rác Theo IP
API gọi AI, xuất file, tìm kiếm Tốn CPU, tốn tiền model Theo người dùng và theo key
Webhook nhận từ ngoài Bị gọi dồn làm sập worker Theo nguồn, kèm kiểm chữ ký

Các con số trong bảng là điểm bắt đầu, không phải chuẩn. Supabase Auth đặt mặc định 60 giây giữa hai lần gửi email đặt lại mật khẩu cho một người, và 30 request mỗi 5 phút cho nhóm đăng ký, đăng nhập. Bạn có thể lấy đó làm mốc.

Trả 429 cho đúng

Khi chặn, đừng trả 200 kèm câu “thử lại sau”, cũng đừng trả 500. Trả 429 để trình duyệt, app di động và các thư viện gọi API hiểu chuyện gì đang xảy ra. Thêm header Retry-After cho biết phải chờ bao nhiêu giây. Theo RFC 6585, header này không bắt buộc, nhưng có nó thì client tử tế sẽ tự lùi lại thay vì gọi tiếp.

HTTP/1.1 429 Too Many Requests
Retry-After: 60
Content-Type: application/json

{"error": "Bạn gửi yêu cầu quá nhanh, thử lại sau 60 giây."}

Với form quên mật khẩu còn một điều nữa: câu trả lời khi bị chặn và khi gửi thành công nên giống nhau về nội dung lẫn thời gian phản hồi. Nếu không, hacker nhìn vào là biết email nào có tài khoản.

Next.js với Upstash Redis

App chạy trên Vercel hay serverless thì không đếm trong bộ nhớ được, vì mỗi request có thể rơi vào một instance khác. Cần một chỗ đếm chung, Redis là lựa chọn quen thuộc. Thư viện @upstash/ratelimit làm sẵn phần đếm:

// app/api/forgot-password/route.ts
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";

const redis = Redis.fromEnv();
const perEmail = new Ratelimit({ redis, limiter: Ratelimit.slidingWindow(1, "60 s"), prefix: "rl:forgot:email" });
const perIp = new Ratelimit({ redis, limiter: Ratelimit.slidingWindow(5, "1 h"), prefix: "rl:forgot:ip" });

export async function POST(req: Request) {
  const { email } = await req.json();
  const ip = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";

  const [a, b] = await Promise.all([
    perEmail.limit(String(email).toLowerCase()),
    perIp.limit(ip),
  ]);

  if (!a.success || !b.success) {
    const reset = Math.max(a.reset, b.reset);
    const retry = Math.ceil((reset - Date.now()) / 1000);
    return Response.json(
      { error: "Bạn gửi yêu cầu quá nhanh, thử lại sau." },
      { status: 429, headers: { "Retry-After": String(retry) } }
    );
  }

  await sendResetEmailIfUserExists(email); // không báo email có tồn tại hay không
  return Response.json({ message: "Nếu email có tài khoản, bạn sẽ nhận được link trong vài phút." });
}

Đếm theo email đã chuẩn hóa chữ thường, nếu không A@gmail.com và a@gmail.com thành hai người. Header x-forwarded-for trên Vercel do nền tảng đặt nên tin được phần tử đầu. Nếu tự chạy sau Nginx hay Cloudflare, hãy kiểm lại proxy của bạn ghi IP thật vào header nào, vì client tự gửi header này lên được.

Express với express-rate-limit

import express from "express";
import { rateLimit } from "express-rate-limit";

const app = express();
app.set("trust proxy", 1); // chỉ khi app nằm sau đúng một reverse proxy

const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  limit: 10,
  standardHeaders: "draft-8",
  legacyHeaders: false,
});

const forgotLimiter = rateLimit({
  windowMs: 60 * 1000,
  limit: 1,
  keyGenerator: (req) => String(req.body?.email ?? "").toLowerCase(),
});

app.post("/login", authLimiter, loginHandler);
app.post("/forgot-password", express.json(), authLimiter, forgotLimiter, forgotHandler);

Hai chỗ hay sai. Một là trust proxy: đặt sai thì mọi request mang IP của proxy, cả app chung một bộ đếm và người dùng thật bị chặn hàng loạt; đặt true bừa thì hacker giả IP bằng header. Hai là bộ đếm mặc định nằm trong RAM: chạy hai instance trở lên, hoặc khởi động lại server, là giới hạn mất tác dụng. Lúc đó dùng store Redis.

Supabase, Firebase và dịch vụ có sẵn

Nếu dùng Supabase Auth hay Firebase Auth cho đăng nhập, phần auth đã có giới hạn riêng. Vào Supabase, mục Authentication > Rate Limits để xem và chỉnh. Lưu ý email mặc định của Supabase chỉ cho 2 email mỗi giờ, nên nhiều người nối SMTP riêng để tăng lên, rồi quên đặt lại giới hạn hợp lý.

Giới hạn của dịch vụ auth chỉ bảo vệ các endpoint của nó. Edge Function bạn tự viết để gửi OTP qua Twilio, form liên hệ gửi mail qua Resend, API gọi OpenAI thì vẫn trống, và đó thường là chỗ tốn tiền nhất.

Thêm một lớp ngoài cùng: đặt trần chi tiêu ngay trên trang của nhà cung cấp SMS, email, AI. Rate limit trong code có lỗi thì trần này vẫn chặn được hóa đơn.

Kiểm tra app của bạn trong vài phút

  1. Mở danh sách route, đánh dấu những route gửi email, SMS, gọi AI, hoặc nhận mật khẩu và mã OTP.

  2. Với mỗi route đó, gọi liên tục 30 lần và xem có lần nào bị chặn không:

    for i in $(seq 1 30); do
      curl -s -o /dev/null -w "%{http_code} " -X POST https://app-cua-ban.com/api/forgot-password \
        -H 'Content-Type: application/json' -d '{"email":"email-cua-ban@example.com"}'
    done; echo

    Thấy ba mươi số 200 là chưa có giới hạn. Nhớ dùng email của chính bạn, và chỉ thử trên app của mình.

  3. Nếu đã thấy 429, kiểm tra có header Retry-After không: thêm -i vào lệnh curl để xem header.

  4. Thử đổi chữ hoa chữ thường trong email, thêm header X-Forwarded-For: 1.2.3.4. Vẫn qua được thì bộ đếm đang tin nhầm thứ client gửi lên.

  5. Mở trang quản trị của nhà cung cấp email, SMS, AI, xem đã đặt trần chi tiêu hoặc cảnh báo chưa.

Muốn nhờ AI sửa, đưa nó bảng ở trên và yêu cầu cụ thể: “thêm rate limit theo email và theo IP cho route này, trả 429 kèm Retry-After, dùng Redis”. Câu “thêm bảo mật cho form” thường chỉ ra một captcha.


App của bạn đang có route nào gửi SMS hay email mà chưa giới hạn số lần không? Vào cộng đồng Vibe Code & Bảo Mật kể cho mọi người cách bạn đặt giới hạn nhé.

Nguồn

Chia sẻ lên Facebook

Bình luận

Đang tải bình luận…