Fail-safe blocks sends to pending-unsubscribe contacts
Why it matters
GDPR Article 7(3) and CCPA §1798.120 require opt-outs to be honored — a race condition between async opt-out processing and a campaign send batch is not an acceptable excuse for sending to a contact who withdrew consent. CWE-284 (Improper Access Control) captures the broader class: the system fails to enforce the access control decision (do not send) during the window between request and processing. A contact who unsubscribes at 11:58 PM and a campaign batch that starts at midnight will be sent a campaign if the only guard is the processed consent record, which the async worker has not yet written.
Severity rationale
High because the race window between an opt-out request and async processing completion is predictable and exploitable by campaign send timing, creating measurable violations of GDPR Art. 7(3) and CAN-SPAM §7704(a)(4).
Remediation
Write the optOutStatus: 'pending' flag synchronously before enqueuing the job, so the pre-send guard can catch pending opt-outs even before the worker runs:
// src/app/api/unsubscribe/route.ts
export async function POST(req: Request) {
const { contactId, scope } = await req.json()
// Synchronous — takes effect immediately on all subsequent pre-send checks
await db.contact.update({
where: { id: contactId },
data: { optOutStatus: 'pending', optOutRequestedAt: new Date() }
})
await queue.add('process-optout', { contactId, scope })
return new Response(null, { status: 202 })
}
// Pre-send guard in src/lib/campaigns/can-send.ts
async function canSendCampaign(contactId: string, scope: string): Promise<boolean> {
const contact = await db.contact.findUnique({ where: { id: contactId } })
if (contact?.optOutStatus === 'pending' || contact?.optOutStatus === 'completed') return false
return hasConsent(contactId, scope)
}
The synchronous status write costs one extra database round-trip but eliminates the race window entirely.
Detection
-
ID:
fail-safe-block -
Severity:
high -
What to look for: Between when a contact requests an unsubscribe and when the async worker finishes processing it, there is a window where the contact could be picked up by a campaign send. Look for a
pending_unsubscribeflag, anopt_out_statuscolumn, or a suppression check in the pre-send path that catches contacts whose opt-out job is queued but not yet processed. Without this, a contact who clicks unsubscribe at 11:58pm and a campaign batch that starts at midnight will send to that contact before the worker runs. -
Pass criteria: The opt-out request immediately marks the contact as
pending_unsubscribe(or equivalent) in a synchronous write before enqueuing the job. The pre-send consent check blocks contacts inpending_unsubscribestate. Quote the actual status field name and the values used for the pending state. Count all pre-send guard checks in the send path — at least 1 must check the pending-unsubscribe state. -
Fail criteria: The unsubscribe endpoint only enqueues the job with no synchronous status update. The pre-send check only queries consent records — a contact with a queued-but-not-processed opt-out job is indistinguishable from a subscribed contact.
-
Skip (N/A) when: Opt-out processing is fully synchronous (though this would fail the async check above) or there is no overlap between opt-out processing and campaign sends.
-
Detail on fail:
"Unsubscribe only enqueues job, no immediate status update — contacts remain sendable until worker completes (average 45min delay)"or"Pre-send check queries consent_records only, not opt_out_requests table — pending opt-outs not caught" -
Remediation: Add a synchronous write before enqueuing:
export async function POST(req: Request) { const { contactId, scope } = await req.json() // Synchronous write — immediate effect on pre-send checks await db.contact.update({ where: { id: contactId }, data: { optOutStatus: 'pending', optOutRequestedAt: new Date() } }) // Async job handles cascade, ESP sync, audit logging await queue.add('process-optout', { contactId, scope }) return new Response(null, { status: 202 }) } // Pre-send check includes status check async function canSendCampaign(contactId: string, scope: string): Promise<boolean> { const contact = await db.contact.findUnique({ where: { id: contactId } }) if (contact?.optOutStatus === 'pending' || contact?.optOutStatus === 'completed') return false // ... rest of consent checks }
External references
- gdpr · Art. 7(3) — Processing must cease upon withdrawal of consent
- cwe · CWE-284 — Improper access control — pending-unsubscribe contact remains reachable
- ccpa · §1798.120 — Business must stop selling/sharing upon opt-out
- external · CAN-SPAM-§7704(a)(4) — CAN-SPAM Act — prohibits sending during opt-out processing window
Taxons
History
- 2026-04-18·v1.0.0·Initial import from compliance-consent-engine·automated