The Problem With Unlimited Email Triggers
The app/api/send-review/route.js file in this Next.js application handles one job: accept a POST request and send an email via Gmail SMTP using nodemailer. It's a common, useful pattern — but in its original form, there was nothing stopping anyone on the internet from calling that endpoint thousands of times in rapid succession. Every single call would dutifully fire off an outbound email, draining the application's Gmail sending quota and potentially flooding recipients' inboxes.
This isn't a hypothetical edge case. Public-facing API routes that trigger email sends are among the most commonly abused endpoints on the web, and the fix is straightforward once you know what to look for.
The Vulnerability Explained
What the original code did
Here's the original POST handler, stripped to its essentials:
// app/api/send-review/route.js (before fix)
export async function POST(request) {
const { name, email, review, rating, to } = await request.json();
try {
// ... nodemailer transporter setup and sendMail call
}
}
The handler immediately destructures the request body and proceeds to send an email. There is no check on who is calling, no check on how often they are calling, and no ceiling on total calls. The entire email-sending pipeline runs unconditionally for every valid POST request that reaches the server.
Why this is dangerous for this specific endpoint
The /api/send-review endpoint (and its sibling /api/contact) use nodemailer to relay mail through a Gmail SMTP account. Gmail's free-tier sending quota sits at roughly 500 messages per day for standard accounts (2,000 for Workspace). An attacker running even a modest script — say, 50 requests per second — could exhaust that quota in under 10 seconds, causing:
- Email bombing of the recipient address — the
tofield is taken directly from the request body, meaning an attacker can target any address. - Gmail account suspension — exceeding quota triggers temporary or permanent sending bans.
- Denial of service for legitimate users — once the quota is gone, real review submission emails fail silently.
The /api/auth endpoint's weaker protection
The PR description notes that /api/auth relies on Cloudflare Turnstile CAPTCHA as its only abuse-prevention layer. While CAPTCHA adds friction, it is not a rate limit. Automated CAPTCHA-solving services cost fractions of a cent per solve, and a single valid CAPTCHA token can sometimes be replayed within its validity window. Without a server-side fallback, CAPTCHA bypass equals unlimited access.
Attack scenario
POST /api/send-review HTTP/1.1
Host: target-app.com
Content-Type: application/json
{"name":"Attacker","email":"attacker@evil.com","review":"spam","rating":5,"to":"victim@company.com"}
A script repeating this request in a loop requires no authentication, no API key, and no special knowledge of the application. The attacker needs only network access and the endpoint URL — both of which are public by definition.
The Fix
What changed in app/api/send-review/route.js
The fix adds 20 lines before the existing handler logic: a Map-based in-memory rate limiter and a guard clause at the top of the POST function.
Before:
export async function POST(request) {
const { name, email, review, rating, to } = await request.json();
// email sending proceeds immediately
After:
// Simple in-memory rate limiter: max 5 requests per IP per 15 minutes
const rateLimit = new Map();
const LIMIT = 5;
const WINDOW_MS = 15 * 60 * 1000;
function isRateLimited(ip) {
const now = Date.now();
const entry = rateLimit.get(ip) || { count: 0, start: now };
if (now - entry.start > WINDOW_MS) {
rateLimit.set(ip, { count: 1, start: now });
return false;
}
if (entry.count >= LIMIT) return true;
entry.count++;
rateLimit.set(ip, entry);
return false;
}
export async function POST(request) {
const ip =
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || 'unknown';
if (isRateLimited(ip)) {
return NextResponse.json(
{ success: false, error: 'Too many requests' },
{ status: 429 }
);
}
const { name, email, review, rating, to } = await request.json();
// email sending only reached after rate-limit check passes
How isRateLimited works
The function uses a sliding-window counter stored in a module-level Map:
| Step | Logic |
|---|---|
| 1. Look up the IP | Retrieve the existing { count, start } entry, or create a fresh one. |
| 2. Check window expiry | If more than 15 minutes have elapsed since start, reset the counter to 1 and allow the request. |
| 3. Check the limit | If count >= 5, return true (rate limited). |
| 4. Increment | Otherwise, increment count, save the entry, and return false (allowed). |
The IP is extracted from the x-forwarded-for header (taking only the first, leftmost value to avoid header spoofing by downstream proxies) with a fallback of 'unknown' for requests that arrive without the header.
What this concretely prevents
With the fix in place:
- A single IP can trigger at most 5 outbound emails every 15 minutes — well within normal human usage.
- The Gmail quota can no longer be exhausted by a single attacker in a single burst.
- The to field in the request body still needs input validation, but the blast radius of any abuse is now tightly bounded.
Key Takeaways
nodemailercalls in a Next.js route handler are a rate-limiting red flag — any unauthenticated endpoint that sends email must have per-IP throttling before the send logic runs.- The
isRateLimited()guard must come beforeawait request.json()— parsing the body is cheap, but it's good practice to reject over-limit requests as early as possible to minimize processing. x-forwarded-formust be split on commas — the header can contain a chain of IPs; always take[0](the original client) to avoid trivial bypass via header appending.- CAPTCHA on
/api/authis not a substitute for rate limiting — the PR description explicitly identifies this gap; a bypass of Turnstile would leave the auth endpoint fully unprotected. - In-memory rate limiters don't survive process restarts or scale horizontally — the fix is appropriate for the current deployment model but should be revisited if the application moves to a serverless or multi-instance architecture.
How Orbis AppSec Detected This
- Source: Unauthenticated HTTP POST body fields (
name,email,review,rating,to) received by thePOSThandler inapp/api/send-review/route.js - Sink:
nodemailersendMail()call triggered unconditionally on every request, with no preceding frequency check - Missing control: No per-IP request rate limit, no request count tracking, no HTTP 429 response path — the email-sending side effect was reachable an unlimited number of times
- CWE: CWE-770 — Allocation of Resources Without Limits or Throttling
- Fix: Added
isRateLimited(ip)guard at the top of thePOSThandler, returning HTTP 429 after 5 requests per IP within a 15-minute window
Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.
Conclusion
Missing rate limiting on email-triggering API routes is one of those vulnerabilities that looks harmless in isolation — after all, the endpoint is supposed to send emails — but becomes a serious operational and security liability the moment a bad actor discovers it. The original app/api/send-review/route.js handed attackers a direct line to the application's Gmail SMTP account with no friction whatsoever.
The fix is elegant in its simplicity: 20 lines of pure JavaScript, no new dependencies, and a hard cap of 5 requests per IP per 15 minutes. It doesn't change the behavior for any legitimate user (who will rarely submit more than one or two reviews in a quarter-hour), but it makes email bombing attacks economically and technically unviable.
The broader lesson is architectural: whenever you write a route handler that triggers an outbound side effect — email, SMS, webhook, database write — ask yourself what happens if this is called 10,000 times in a minute? If the answer is "bad things," add rate limiting before you ship.