# Email send routes have per-recipient rate limiting

- **Pattern:** `ab-000242` (`ai-slop-cost-bombs.unrate-limited-spend.email-rate-limited-per-recipient`)
- **Severity:** high
- **Lifecycle:** active
- **Last modified:** 2026-04-18
- **Canonical URL:** https://auditbuffet.com/patterns/ab-000242
- **License:** CC-BY-4.0 — attribute to AuditBuffet Pattern Catalog (https://auditbuffet.com/patterns/ab-000242)

## Why it matters

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.

## Severity rationale

High because metered email costs are directly proportional to unvalidated recipient list size, making financial abuse trivial and instantly expensive.

## Remediation

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.

```ts
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 })
}
```

## Detection

- **ID:** `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:

  ```ts
  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 })
  }
  ```

## External references

- cwe CWE-770
- cwe CWE-799
- owasp A05

Taxons: cost-efficiency, injection-and-input-trust

HTML version: https://auditbuffet.com/patterns/ab-000242
