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.
Prevention & Best Practices
1. Always rate-limit endpoints that trigger side effects
Any route that sends email, SMS, pushes to a queue, or writes to a database should have rate limiting applied before the side-effectful operation. The check must happen server-side — client-side throttling is trivially bypassed.
2. Choose the right rate-limiting store for your architecture
| Deployment | Recommended store |
|---|---|
| Single Next.js instance | In-memory Map (as used here) |
| Multiple instances / serverless | Redis with ioredis or Upstash |
| Edge runtime | Cloudflare KV or Deno KV |
The in-memory approach in this fix is correct for a single-instance deployment but will not share state across multiple Node.js processes or serverless function invocations. If this application scales horizontally, migrate to a shared store.
3. Return HTTP 429 with a Retry-After header
The fix correctly returns status 429. Consider adding a Retry-After header to help legitimate clients back off gracefully:
return NextResponse.json(
{ success: false, error: 'Too many requests' },
{
status: 429,
headers: { 'Retry-After': '900' }, // 15 minutes in seconds
}
);
4. Validate the to field
The to address is taken directly from the request body and passed to nodemailer. A follow-up hardening step should validate that to matches an expected domain or a fixed list of allowed recipient addresses, preventing the endpoint from being used as an open email relay.
5. Layer defenses — don't rely on CAPTCHA alone
CAPTCHA (Turnstile, reCAPTCHA, hCaptcha) is a UX-level control, not a security boundary. Treat it as a first filter and always back it up with server-side rate limiting.
6. Use established middleware libraries
For production Next.js applications, consider next-rate-limit or @upstash/ratelimit rather than hand-rolled Map logic. These libraries handle edge cases like clock skew, distributed deployments, and header normalization.
Relevant standards
- OWASP API Security Top 10 — API4:2023: Unrestricted Resource Consumption
- CWE-770: Allocation of Resources Without Limits or Throttling
- OWASP Cheat Sheet: Denial of Service Cheat Sheet
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.
References
- CWE-770: Allocation of Resources Without Limits or Throttling
- OWASP API Security Top 10 — API4:2023 Unrestricted Resource Consumption
- OWASP Denial of Service Cheat Sheet
- Next.js Route Handlers — Official Documentation
- Semgrep rules for rate limiting
- fix: all three public api endpoints (send-review, co... in route.js