Skip to content

Commit e7cede6

Browse files
committed
add outbound abuse guards
1 parent 11ae6d8 commit e7cede6

5 files changed

Lines changed: 265 additions & 1 deletion

File tree

test/unit/rate-limit.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,77 @@ describe('Per-mailbox Send Rate Limits', () => {
9797
expect(res.status).toBe(429)
9898
})
9999

100+
test('blocks known phishing-style account verification subjects from untrusted mailbox', async () => {
101+
let resendCalled = false
102+
globalThis.fetch = mock(async () => {
103+
resendCalled = true
104+
return new Response(JSON.stringify({ id: 'should-not-send' }))
105+
}) as typeof fetch
106+
107+
const db = {
108+
prepare: (sql: string) => ({
109+
bind: (..._args: unknown[]) => ({
110+
first: async () => {
111+
if (sql.includes('suppression_list')) return null
112+
if (sql.includes('auth_tokens')) return null
113+
if (sql.includes("direction = 'inbound'")) return { count: 0 }
114+
return null
115+
},
116+
run: async () => ({ meta: { changes: 1 } }),
117+
all: async () => ({ results: [] }),
118+
}),
119+
}),
120+
} as unknown as D1Database
121+
122+
const env = { DB: db, RESEND_API_KEY: 'test' } as any
123+
const mailbox = 'fresh@mails0.com'
124+
const request = new Request('http://localhost/api/send', {
125+
method: 'POST',
126+
headers: { 'Content-Type': 'application/json' },
127+
body: JSON.stringify({
128+
from: mailbox,
129+
to: ['recipient@example.com'],
130+
subject: 'Action Required: Verify Your Account',
131+
text: 'Please verify your account at https://example.com/login',
132+
}),
133+
})
134+
135+
const res = await handleSend(request, env, mailbox)
136+
expect(res.status).toBe(400)
137+
expect(resendCalled).toBe(false)
138+
const data = await res.json() as { error: string }
139+
expect(data.error).toContain('abuse protection')
140+
})
141+
142+
test('returns 429 when global daily send limit is reached', async () => {
143+
const db = {
144+
prepare: (sql: string) => ({
145+
bind: (..._args: unknown[]) => ({
146+
first: async () => {
147+
if (sql.includes('suppression_list')) return null
148+
if (sql.includes('SELECT created_at FROM auth_tokens')) {
149+
return { created_at: '2026-01-01T00:00:00.000Z' }
150+
}
151+
if (sql.includes("direction = 'inbound'")) return { count: 1 }
152+
if (sql.includes('COALESCE(SUM(count)')) return { count: 201 }
153+
if (sql.includes('daily_send_counts')) return { count: 1 }
154+
return null
155+
},
156+
run: async () => ({ meta: { changes: 1 } }),
157+
all: async () => ({ results: [] }),
158+
}),
159+
}),
160+
} as unknown as D1Database
161+
162+
const env = { DB: db, RESEND_API_KEY: 'test', GLOBAL_DAILY_SEND_LIMIT: '200' } as any
163+
const mailbox = 'sender@mails0.com'
164+
const res = await handleSend(makeRequest(mailbox), env, mailbox)
165+
166+
expect(res.status).toBe(429)
167+
const data = await res.json() as { error: string }
168+
expect(data.error).toContain('Global daily send limit')
169+
})
170+
100171
test('skips rate limit when no mailbox (self-hosted)', async () => {
101172
globalThis.fetch = mock(async () => {
102173
return new Response(JSON.stringify({ id: 'resend-nomail' }))
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
-- Store claim source metadata so hosted deployments can rate-limit mailbox
2+
-- creation by client IP instead of relying only on coarse global limits.
3+
ALTER TABLE claim_sessions ADD COLUMN ip_address TEXT;
4+
ALTER TABLE claim_sessions ADD COLUMN user_agent TEXT;
5+
6+
CREATE INDEX IF NOT EXISTS idx_claim_sessions_ip_created ON claim_sessions(ip_address, created_at);
7+
CREATE INDEX IF NOT EXISTS idx_claim_sessions_created_at ON claim_sessions(created_at);

worker/schema.sql

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,11 +100,15 @@ CREATE TABLE IF NOT EXISTS claim_sessions (
100100
status TEXT NOT NULL DEFAULT 'pending',
101101
api_key TEXT,
102102
mailbox TEXT,
103+
ip_address TEXT,
104+
user_agent TEXT,
103105
created_at TEXT NOT NULL,
104106
expires_at TEXT NOT NULL
105107
);
106108

107109
CREATE INDEX IF NOT EXISTS idx_claim_sessions_status ON claim_sessions(status, expires_at);
110+
CREATE INDEX IF NOT EXISTS idx_claim_sessions_ip_created ON claim_sessions(ip_address, created_at);
111+
CREATE INDEX IF NOT EXISTS idx_claim_sessions_created_at ON claim_sessions(created_at);
108112

109113
-- Email labels for auto-classification
110114
CREATE TABLE IF NOT EXISTS email_labels (

worker/src/handlers/send.ts

Lines changed: 179 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,20 @@ const MAX_TOTAL_RECIPIENTS = 50
55
const MAX_ATTACHMENTS = 20
66
const MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024
77
const MAX_TOTAL_ATTACHMENT_BYTES = 40 * 1024 * 1024
8+
const DEFAULT_GLOBAL_DAILY_SEND_LIMIT = 200
9+
const DEFAULT_NEW_MAILBOX_SEND_LIMIT = 5
10+
const DEFAULT_NEW_MAILBOX_SEND_WINDOW_HOURS = 24
11+
12+
const KNOWN_ABUSE_SUBJECTS = new Set([
13+
'action required: verify your account',
14+
'payment required for your account',
15+
'your account requires attention',
16+
'your account is past due',
17+
'important: account update needed',
18+
'security alert: account activity',
19+
'notice: account status change',
20+
'urgent: account verification required',
21+
])
822

923
export async function handleSend(request: Request, env: Env, mailbox?: string, ctx?: ExecutionContext): Promise<Response> {
1024
if (!env.RESEND_API_KEY) {
@@ -172,6 +186,27 @@ export async function handleSend(request: Request, env: Env, mailbox?: string, c
172186
// Check suppression list for all recipients (fail-closed: reject if check fails)
173187
// Normalize to lowercase: suppression_list stores lowercase, but senders may use mixed case
174188
const allRecipients = [...body.to, ...(body.cc ?? []), ...(body.bcc ?? [])].map(e => e.toLowerCase())
189+
190+
if (mailbox) {
191+
try {
192+
const abuseResult = await checkOutboundAbuseGuard(env, mailbox, {
193+
subject: body.subject,
194+
text: body.text ?? '',
195+
html: body.html ?? '',
196+
recipients: allRecipients,
197+
})
198+
if (abuseResult) {
199+
return Response.json({ error: abuseResult }, { status: 400 })
200+
}
201+
} catch (err) {
202+
console.error('Outbound abuse guard failed, rejecting send:', err)
203+
return Response.json(
204+
{ error: 'Unable to verify message safety, please try again' },
205+
{ status: 503 },
206+
)
207+
}
208+
}
209+
175210
try {
176211
const suppressedCheck = await checkSuppressionList(env, allRecipients)
177212
if (suppressedCheck) {
@@ -404,6 +439,99 @@ export async function checkSuppressionList(
404439
return row ?? null
405440
}
406441

442+
type OutboundAbuseInput = {
443+
subject: string
444+
text: string
445+
html: string
446+
recipients: string[]
447+
}
448+
449+
async function checkOutboundAbuseGuard(
450+
env: Env,
451+
mailbox: string,
452+
input: OutboundAbuseInput,
453+
): Promise<string | null> {
454+
if (env.SEND_ABUSE_GUARD_ENABLED === '0' || env.SEND_ABUSE_GUARD_ENABLED === 'false') {
455+
return null
456+
}
457+
458+
const risk = scoreOutboundRisk(input)
459+
if (risk.score < 3) return null
460+
461+
let mailboxCreatedAt: string | null = null
462+
let inboundCount = 0
463+
try {
464+
const tokenRow = await env.DB.prepare(
465+
'SELECT created_at FROM auth_tokens WHERE mailbox = ? LIMIT 1'
466+
).bind(mailbox).first<{ created_at: string }>()
467+
mailboxCreatedAt = tokenRow?.created_at ?? null
468+
469+
const inboundRow = await env.DB.prepare(
470+
"SELECT COUNT(*) as count FROM emails WHERE mailbox = ? AND direction = 'inbound'"
471+
).bind(mailbox).first<{ count: number }>()
472+
inboundCount = inboundRow?.count ?? 0
473+
} catch (err) {
474+
const msg = err instanceof Error ? err.message : String(err)
475+
if (msg.includes('no such table')) return null
476+
throw err
477+
}
478+
479+
const ageHours = mailboxCreatedAt ? hoursSince(mailboxCreatedAt) : null
480+
const newOrUntrustedMailbox =
481+
inboundCount === 0 ||
482+
ageHours === null ||
483+
ageHours < DEFAULT_NEW_MAILBOX_SEND_WINDOW_HOURS
484+
485+
if (KNOWN_ABUSE_SUBJECTS.has(input.subject.trim().toLowerCase()) && newOrUntrustedMailbox) {
486+
return 'Message rejected by abuse protection: high-risk account/payment/security template'
487+
}
488+
489+
if (risk.score >= 5 && newOrUntrustedMailbox) {
490+
return `Message rejected by abuse protection: ${risk.flags.slice(0, 3).join(', ')}`
491+
}
492+
493+
return null
494+
}
495+
496+
function scoreOutboundRisk(input: OutboundAbuseInput): { score: number; flags: string[] } {
497+
const subject = input.subject.trim().toLowerCase()
498+
const body = `${input.text}\n${stripHtml(input.html)}`.toLowerCase()
499+
const content = `${subject}\n${body}`
500+
const flags: string[] = []
501+
let score = 0
502+
503+
if (KNOWN_ABUSE_SUBJECTS.has(subject)) {
504+
flags.push('known abuse subject')
505+
score += 5
506+
}
507+
if (/\b(action required|urgent|immediate action|requires attention)\b/.test(subject)) {
508+
flags.push('urgency language')
509+
score += 1
510+
}
511+
if (/\b(verify|verification|confirm|update)\b/.test(content) && /\b(account|login|identity|email)\b/.test(content)) {
512+
flags.push('account verification language')
513+
score += 2
514+
}
515+
if (/\b(payment required|past due|overdue|unpaid|billing issue)\b/.test(content)) {
516+
flags.push('payment pressure language')
517+
score += 2
518+
}
519+
if (/\b(security alert|account activity|status change|suspicious activity)\b/.test(content)) {
520+
flags.push('security alert impersonation language')
521+
score += 2
522+
}
523+
if (/\b(click here|sign in|log in|login|reset password|update your account)\b/.test(content) && /https?:\/\//.test(content)) {
524+
flags.push('credential-action link')
525+
score += 2
526+
}
527+
if (input.recipients.length > 10) {
528+
flags.push('bulk recipient fanout')
529+
score += 1
530+
}
531+
532+
return { score, flags }
533+
}
534+
407535
/**
408536
* Check daily send rate limit for a mailbox using atomic increment-then-verify.
409537
* Increments the counter first (atomic), then checks the new count against the limit.
@@ -415,6 +543,12 @@ async function checkDailySendLimit(env: Env, mailbox: string): Promise<string |
415543
try {
416544
const today = new Date().toISOString().slice(0, 10) // YYYY-MM-DD
417545
const dailyLimit = env.DAILY_SEND_LIMIT ? parseInt(env.DAILY_SEND_LIMIT as string, 10) : 100
546+
const globalDailyLimit = readNonNegativeInt(env.GLOBAL_DAILY_SEND_LIMIT, DEFAULT_GLOBAL_DAILY_SEND_LIMIT)
547+
const newMailboxLimit = readNonNegativeInt(env.NEW_MAILBOX_SEND_LIMIT, DEFAULT_NEW_MAILBOX_SEND_LIMIT)
548+
const newMailboxWindowHours = readNonNegativeInt(
549+
env.NEW_MAILBOX_SEND_WINDOW_HOURS,
550+
DEFAULT_NEW_MAILBOX_SEND_WINDOW_HOURS,
551+
)
418552

419553
// Atomic increment
420554
await env.DB.prepare(
@@ -434,7 +568,28 @@ async function checkDailySendLimit(env: Env, mailbox: string): Promise<string |
434568
).bind(mailbox, today).run()
435569
return `Daily send limit reached (${row.count - 1}/${dailyLimit})`
436570
}
437-
// Under limit: increment already applied, no further action needed
571+
572+
if (row && newMailboxLimit > 0 && row.count > newMailboxLimit) {
573+
const tokenRow = await env.DB.prepare(
574+
'SELECT created_at FROM auth_tokens WHERE mailbox = ? LIMIT 1'
575+
).bind(mailbox).first<{ created_at: string }>()
576+
const ageHours = tokenRow?.created_at ? hoursSince(tokenRow.created_at) : null
577+
if (ageHours === null || ageHours < newMailboxWindowHours) {
578+
await decrementDailySendCount(env, mailbox, today)
579+
return `New mailbox send limit reached (${newMailboxLimit}/${newMailboxWindowHours}h warmup window)`
580+
}
581+
}
582+
583+
if (globalDailyLimit > 0) {
584+
const globalRow = await env.DB.prepare(
585+
'SELECT COALESCE(SUM(count), 0) as count FROM daily_send_counts WHERE date = ?'
586+
).bind(today).first<{ count: number }>()
587+
if ((globalRow?.count ?? 0) > globalDailyLimit) {
588+
await decrementDailySendCount(env, mailbox, today)
589+
return `Global daily send limit reached (${globalDailyLimit}/day)`
590+
}
591+
}
592+
// Under limits: increment already applied, no further action needed
438593
} catch (err) {
439594
// Fail-open: rate limit check failure should not block sending
440595
const msg = err instanceof Error ? err.message : String(err)
@@ -445,6 +600,12 @@ async function checkDailySendLimit(env: Env, mailbox: string): Promise<string |
445600
return null
446601
}
447602

603+
async function decrementDailySendCount(env: Env, mailbox: string, date: string): Promise<void> {
604+
await env.DB.prepare(
605+
'UPDATE daily_send_counts SET count = count - 1 WHERE mailbox = ? AND date = ?'
606+
).bind(mailbox, date).run()
607+
}
608+
448609
/** @deprecated Rate limit counting is now handled inside checkDailySendLimit atomically. */
449610
async function incrementDailySendCount(env: Env, _mailbox: string): Promise<void> {
450611
// No-op: counting is now part of checkDailySendLimit's atomic increment-then-verify
@@ -464,3 +625,20 @@ function base64DecodedLength(value: string): number {
464625
const padding = value.endsWith('==') ? 2 : value.endsWith('=') ? 1 : 0
465626
return Math.max(0, Math.floor(value.length * 3 / 4) - padding)
466627
}
628+
629+
function stripHtml(html: string): string {
630+
return html.replace(/<[^>]*>/g, ' ')
631+
}
632+
633+
function hoursSince(value: string): number | null {
634+
const normalized = value.includes('T') ? value : `${value.replace(' ', 'T')}Z`
635+
const parsed = Date.parse(normalized)
636+
if (!Number.isFinite(parsed)) return null
637+
return Math.max(0, (Date.now() - parsed) / 3_600_000)
638+
}
639+
640+
function readNonNegativeInt(value: string | undefined, fallback: number): number {
641+
if (!value) return fallback
642+
const parsed = Number.parseInt(value, 10)
643+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback
644+
}

worker/src/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ export interface Env {
1212
WEBHOOK_SECRET?: string
1313
RESEND_WEBHOOK_SECRET?: string
1414
DAILY_SEND_LIMIT?: string
15+
GLOBAL_DAILY_SEND_LIMIT?: string
16+
NEW_MAILBOX_SEND_LIMIT?: string
17+
NEW_MAILBOX_SEND_WINDOW_HOURS?: string
18+
SEND_ABUSE_GUARD_ENABLED?: string
1519
DAILY_CLAIM_LIMIT?: string
1620
}
1721

0 commit comments

Comments
 (0)