Email providers charge per send — Resend, SendGrid, and SES all bill on volume, and a single API route that accepts a recipients array with no length cap is a direct financial attack surface. An attacker who finds /api/notify can POST {recipients: Array(10000).fill('victim@example.com')} and run up thousands of dollars in charges within minutes, while simultaneously triggering your domain's abuse threshold and risking deliverability bans. CWE-770 describes exactly this pattern. OWASP A05:2021 covers the missing rate control. The reputational damage from being a spam source can far exceed the direct billing cost.
High because metered email costs are directly proportional to unvalidated recipient list size, making financial abuse trivial and instantly expensive.
Cap the recipient list with a Zod .max() and add per-IP rate limiting with @upstash/ratelimit. Both controls are necessary: schema validation caps burst size, rate limiting prevents repeated small sends.
import { z } from 'zod'
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'
import pMap from 'p-map'
const limiter = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.fixedWindow(10, '1 h'),
})
const NotifySchema = z.object({
recipients: z.array(z.string().email()).max(100),
})
export async function POST(req: Request) {
const ip = req.headers.get('x-forwarded-for') ?? 'anon'
const { success } = await limiter.limit(ip)
if (!success) return Response.json({ error: 'Rate limited' }, { status: 429 })
const { recipients } = NotifySchema.parse(await req.json())
await pMap(recipients, (email) => resend.emails.send({ to: email, ... }), { concurrency: 5 })
return Response.json({ sent: recipients.length })
}
ID: ai-slop-cost-bombs.unrate-limited-spend.email-rate-limited-per-recipient
Severity: high
What to look for: When an email library is in package.json dependencies (nodemailer, resend, @sendgrid/mail, mailgun.js, @aws-sdk/client-ses, postmark), walk source files for email-send call sites: .send(, .create(, .sendMail(, mail.send(, transporter.sendMail(. Count all email send call sites that appear inside loops over user-supplied data. For each, verify the loop has a concurrency limit (pMap(items, send, { concurrency: 5 })), OR a per-recipient rate-limit check, OR the loop is over a hardcoded list (not user-supplied).
Pass criteria: 100% of email send call sites in loops are either rate-limited or paced. Report: "X email send call sites inspected, Y rate-controlled, 0 unbounded."
Fail criteria: At least 1 email send call inside a loop over user-supplied data with no rate control.
Skip (N/A) when: No email library in dependencies.
Detail on fail: "1 unbounded email loop: app/api/notify/route.ts iterates over req.body.recipients.map(email => resend.emails.send(...)) — a malicious POST with 10,000 emails sends 10,000 messages"
Remediation: Email is metered — your provider charges per send, and abuse can spike costs into the thousands of dollars per hour. Always rate-limit AND validate the recipient list size:
import { z } from 'zod'
import pMap from 'p-map'
const NotifySchema = z.object({
recipients: z.array(z.string().email()).max(100),
})
export async function POST(req: Request) {
const { recipients } = NotifySchema.parse(await req.json())
await pMap(recipients, async (email) => {
await resend.emails.send({ to: email, ... })
}, { concurrency: 5 })
return Response.json({ sent: recipients.length })
}