diff --git a/src/lib/__tests__/email-headers.test.ts b/src/lib/__tests__/email-headers.test.ts index c166bb10..97b4d970 100644 --- a/src/lib/__tests__/email-headers.test.ts +++ b/src/lib/__tests__/email-headers.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'; import { parseAuthenticationResults, parseSpamScore, parseSpamLLM, extractListHeaders, isAuthenticationSpoofed, headersToRecord, deriveHeaderInfo, deliveryDeltaMs, formatDelta, - findReceivingIdentity, + findReceivingIdentity, getSenderVerification, hasAlignedAuthenticationPass, } from '../email-headers'; describe('parseAuthenticationResults', () => { @@ -23,6 +23,71 @@ describe('parseAuthenticationResults', () => { const none = parseAuthenticationResults('spf=pass smtp.mailfrom=a.example; spf=none smtp.helo=b.example'); expect(none.spf?.result).toBe('pass'); }); + + // The envelope address and comments are sender-chosen text: a result + // counts only where its method opens a `;`-separated part. + it('ignores dkim= and dmarc= written into an envelope address or a comment', () => { + const r = parseAuthenticationResults( + 'mx.example; spf=pass smtp.mailfrom="dkim=pass;dmarc=pass"@evil.example; ' + + 'dkim=none (no signature; dkim=pass header.d=bank.example); dmarc=none header.from=bank.example', + ); + expect(r.dkim?.result).toBe('none'); + expect(r.dmarc?.result).toBe('none'); + const local = parseAuthenticationResults('mx; spf=pass smtp.mailfrom=dmarc=pass@evil.example'); + expect(local.dmarc).toBeUndefined(); + }); + + it("takes DKIM and DMARC from the receiving server's own (topmost) header only", () => { + const r = parseAuthenticationResults([ + 'mx.example; spf=pass smtp.mailfrom=evil.example; dkim=none; dmarc=none header.from=bank.example', + 'forged.example; dkim=pass header.d=bank.example; dmarc=pass header.from=bank.example', + ]); + expect(r.dkim?.result).toBe('none'); + expect(r.dmarc?.result).toBe('none'); + }); + + it('lets a lower header escalate SPF to a failure but never pass it', () => { + const r = parseAuthenticationResults([ + 'mx.example; spf=none smtp.mailfrom=a.example', + 'other; spf=pass smtp.mailfrom=a.example; spf=fail smtp.mailfrom=b.example', + ]); + expect(r.spf?.result).toBe('fail'); + expect(r.spf?.all?.map((e) => e.result)).toEqual(['none', 'fail']); + }); + + it('keeps every DKIM signature so a later pass still counts', () => { + const r = parseAuthenticationResults('mx; dkim=fail header.d=esp.example; dkim=pass header.d=shop.example'); + expect(r.dkim?.result).toBe('fail'); + expect(r.dkim?.all?.map((e) => e.domain)).toEqual(['esp.example', 'shop.example']); + }); +}); + +describe('getSenderVerification', () => { + it("flags mail whose SPF passes only for the sender's own envelope domain", () => { + const auth = parseAuthenticationResults('mx; spf=pass smtp.mailfrom=evil.example; dkim=none; dmarc=none'); + expect(hasAlignedAuthenticationPass(auth, 'ceo@bank.example')).toBe(false); + expect(getSenderVerification(auth, 'ceo@bank.example')) + .toEqual({ status: 'unverified', domain: 'bank.example', sentFrom: 'evil.example' }); + }); + + it('reports a DMARC fail as failed', () => { + const auth = parseAuthenticationResults('mx; spf=fail smtp.mailfrom=bank.example; dmarc=fail header.from=bank.example'); + expect(getSenderVerification(auth, 'ceo@bank.example')?.status).toBe('failed'); + }); + + it('accepts an aligned pass, a subdomain included, and judges nothing without results', () => { + const spf = parseAuthenticationResults('mx; spf=pass smtp.mailfrom=bounce.bank.example; dmarc=none'); + expect(getSenderVerification(spf, 'ceo@bank.example')).toBeNull(); + const dkim = parseAuthenticationResults('mx; dkim=fail header.d=esp.example; dkim=pass header.d=bank.example'); + expect(getSenderVerification(dkim, 'ceo@bank.example')).toBeNull(); + expect(getSenderVerification(parseAuthenticationResults('mx; iprev=pass'), 'a@bank.example')).toBeNull(); + expect(getSenderVerification(undefined, 'a@bank.example')).toBeNull(); + }); + + it('does not count a HELO pass', () => { + const auth = parseAuthenticationResults('mx; spf=pass smtp.helo=bank.example; spf=none smtp.mailfrom=bank.example'); + expect(getSenderVerification(auth, 'a@bank.example')?.status).toBe('unverified'); + }); }); describe('isAuthenticationSpoofed', () => { @@ -69,6 +134,7 @@ describe('deriveHeaderInfo', () => { { name: 'Disposition-Notification-To', value: 'Sender ' }, { name: 'X-Spam-Status', value: 'No, score=0.1' }, { name: 'Authentication-Results', value: 'mx; dmarc=fail header.from=x.example' }, + { name: 'Authentication-Results', value: 'relay; dmarc=pass header.from=x.example' }, ], messageId: ['abc@x.example'], }); diff --git a/src/lib/email-headers.ts b/src/lib/email-headers.ts index 3b61f202..15be0866 100644 --- a/src/lib/email-headers.ts +++ b/src/lib/email-headers.ts @@ -38,9 +38,16 @@ export interface SpfEntry { domain?: string; } +export interface DkimEntry { + result: DkimResult; + domain?: string; + selector?: string; +} + export interface AuthenticationResults { spf?: { result: SpfResult; domain?: string; all?: SpfEntry[] }; - dkim?: { result: DkimResult; domain?: string; selector?: string }; + /** The first signature's result; `all` lists every one when there are several. */ + dkim?: DkimEntry & { all?: DkimEntry[] }; dmarc?: { result: DmarcResult; domain?: string; policy?: DmarcPolicy }; iprev?: { result: 'pass' | 'fail'; ip?: string }; } @@ -98,35 +105,240 @@ const SPF_SEVERITY: Record = { */ export function isAuthenticationSpoofed(auth?: AuthenticationResults): boolean { if (!auth) return false; + // DMARC aligns the visible From with SPF/DKIM, so a DMARC fail is the + // strongest single spoofing signal. if (auth.dmarc?.result === 'fail') return true; - if (auth.spf?.result === 'fail' && auth.dkim?.result !== 'pass') return true; + // Otherwise a hard SPF fail with no valid DKIM signature means the sender + // isn't authorized for the envelope domain. + if (auth.spf?.result === 'fail' && !hasDkimPass(auth)) return true; return false; } -/** Parse an Authentication-Results header into SPF, DKIM, DMARC, iprev results. */ -export function parseAuthenticationResults(header: string): AuthenticationResults { +function hasDkimPass(auth: AuthenticationResults): boolean { + return auth.dkim?.result === 'pass' || !!auth.dkim?.all?.some((entry) => entry.result === 'pass'); +} + +function domainOf(address: string): string | undefined { + const domain = address.slice(address.lastIndexOf('@') + 1).trim().toLowerCase().replace(/\.$/, ''); + return /^[^\s<>@]+\.[^\s<>@]+$/.test(domain) ? domain : undefined; +} + +/** + * Whether `candidate` is the From domain or a parent/child of it: the + * relaxed alignment DMARC applies, without a public-suffix list. + */ +function alignedWith(fromDomain: string, candidate: string | undefined): boolean { + const other = candidate ? domainOf(`@${candidate}`) : undefined; + if (!other) return false; + return other === fromDomain || other.endsWith(`.${fromDomain}`) || fromDomain.endsWith(`.${other}`); +} + +/** + * An SPF pass for the MAIL FROM identity whose domain aligns with the From + * domain. Any sender can pass SPF for a domain of their own and still write + * someone else's address in From; only an aligned pass says anything about + * the visible sender. A HELO pass never does. + */ +function hasAlignedSpfPass(auth: AuthenticationResults, fromDomain: string): boolean { + if (auth.spf?.all) { + return auth.spf.all.some((entry) => entry.identity !== 'helo' && entry.result === 'pass' && alignedWith(fromDomain, entry.domain)); + } + return auth.spf?.result === 'pass' && alignedWith(fromDomain, auth.spf.domain); +} + +/** A DKIM pass for a signing domain that aligns with the From domain. */ +function hasAlignedDkimPass(auth: AuthenticationResults, fromDomain: string): boolean { + const entries = auth.dkim?.all ?? (auth.dkim ? [auth.dkim] : []); + return entries.some((entry) => entry.result === 'pass' && alignedWith(fromDomain, entry.domain)); +} + +/** + * Whether the receiving server's checks tie the message to the visible From + * domain: an aligned DMARC, SPF or DKIM pass. Shared by the sender + * verification notice and the calendar invitation trust assessment. + */ +export function hasAlignedAuthenticationPass(auth: AuthenticationResults | undefined, fromEmail: string | undefined): boolean { + if (!auth || !fromEmail) return false; + const domain = domainOf(fromEmail); + if (!domain) return false; + return auth.dmarc?.result === 'pass' || hasAlignedSpfPass(auth, domain) || hasAlignedDkimPass(auth, domain); +} + +export interface SenderVerification { + /** + * `failed`: the message fails the From domain's checks (see + * isAuthenticationSpoofed). `unverified`: no SPF or DKIM pass ties the + * message to the domain in From. + */ + status: 'failed' | 'unverified'; + /** Domain of the visible From address. */ + domain: string; + /** Envelope (MAIL FROM) host, when it differs from `domain`. */ + sentFrom?: string; +} + +/** + * Whether the receiving server's checks back the visible From domain. + * Returns null when they do, or when there are no results to judge by. + * + * DMARC alone can't answer this: mail-auth (Stalwart) only checks alignment + * once SPF or DKIM passes, so a message that passes neither reports + * `dmarc=none` even when the From domain publishes a policy. That is the + * plainest kind of forgery, so it gets its own verdict here. + */ +export function getSenderVerification( + auth: AuthenticationResults | undefined, + fromEmail: string | undefined, +): SenderVerification | null { + if (!auth || !fromEmail || (!auth.spf && !auth.dkim && !auth.dmarc)) return null; + const domain = domainOf(fromEmail); + if (!domain) return null; + + // DMARC only counts the MAIL FROM identity; a HELO pass proves nothing + // about who wrote the message. + const mailFrom = auth.spf?.all?.find((entry) => entry.identity === 'mailfrom'); + const envelope = mailFrom?.domain ?? auth.spf?.domain; + const envelopeDomain = envelope ? domainOf(envelope) : undefined; + const sentFrom = envelopeDomain && envelopeDomain !== domain ? envelopeDomain : undefined; + + if (isAuthenticationSpoofed(auth)) return { status: 'failed', domain, sentFrom }; + // Only a pass that aligns with the From domain counts: `spf=pass` for the + // sender's own envelope domain under someone else's From is the forgery + // this notice exists for, and a DKIM signature by a third domain is no + // better. + if (hasAlignedAuthenticationPass(auth, fromEmail)) return null; + return { status: 'unverified', domain, sentFrom }; +} + +interface ResInfo { + method: string; + result: string; + props: Record; +} + +/** + * Split one Authentication-Results header into its `;`-separated parts + * (RFC 8601), dropping comments. A `;` inside a quoted string or a comment + * does not split: both can carry sender-chosen text such as the envelope + * address. + */ +function splitResinfo(header: string): string[] { + const parts: string[] = []; + let current = ''; + let depth = 0; + let quoted = false; + for (let i = 0; i < header.length; i++) { + const c = header[i]; + if (c === '\\' && (quoted || depth > 0)) { + if (depth === 0) current += c + (header[i + 1] ?? ''); + i++; + continue; + } + if (quoted) { + current += c; + if (c === '"') quoted = false; + continue; + } + if (c === '(') { + depth++; + continue; + } + if (depth > 0) { + if (c === ')' && --depth === 0) current += ' '; + continue; + } + if (c === '"') quoted = true; + if (c === ';') { + parts.push(current); + current = ''; + continue; + } + current += c; + } + parts.push(current); + return parts.map((part) => part.trim()).filter(Boolean); +} + +const METHOD_RE = /^([a-z0-9][a-z0-9_-]*)(?:\/\d+)?\s*=\s*([a-z]+)(?=\s|$)/i; +const PROP_RE = /\s*([^\s=]+)\s*=\s*("(?:[^"\\]|\\.)*"|\S*)/y; + +/** + * Read one resinfo: the method must open the part, so a `dmarc=pass` that + * appears inside a property value (say an envelope local part in + * `smtp.mailfrom=`) is never taken for a result. + */ +function parseResinfo(part: string): ResInfo | null { + const match = METHOD_RE.exec(part); + if (!match) return null; + const props: Record = {}; + const rest = part.slice(match[0].length); + PROP_RE.lastIndex = 0; + let prop: RegExpExecArray | null; + while ((prop = PROP_RE.exec(rest)) !== null && prop[0].length > 0) { + const key = prop[1].toLowerCase(); + let value = prop[2]; + if (value.startsWith('"')) value = value.slice(1, -1).replace(/\\(.)/g, '$1'); + if (!(key in props)) props[key] = value; + } + return { method: match[1].toLowerCase(), result: match[2].toLowerCase(), props }; +} + +function parseResinfos(header: string): ResInfo[] { + return splitResinfo(header) + .map(parseResinfo) + .filter((info): info is ResInfo => info !== null); +} + +const DMARC_SEVERITY: Record = { + fail: 3, + permerror: 2, + temperror: 2, + none: 1, + pass: 0, +}; + +/** + * Parse Authentication-Results headers into SPF, DKIM, DMARC and iprev + * results (port of the webmail's parser, 8f7124c87). + * + * Pass the headers in message order. The topmost one is the receiving + * server's own; anything below it may have been written by the sender, so + * DKIM, DMARC and iprev come from the topmost header only, and the others + * can only escalate SPF to a failure, never supply a pass. A method counts + * only where it opens a `;`-separated part, so `dkim=pass` written into a + * comment or an envelope address is not a result. + */ +export function parseAuthenticationResults(headers: string | readonly string[]): AuthenticationResults { const results: AuthenticationResults = {}; + const list = typeof headers === 'string' ? [headers] : headers; + const perHeader = list.map(parseResinfos); + const own = perHeader[0] ?? []; + const foreign = perHeader.slice(1).flat(); // A single header can carry more than one SPF result when the server // evaluates multiple identities (HELO and MAIL FROM). Collect them all so a // hard fail on any identity isn't softened to an ambiguous state recorded // for another one. - const spfRegex = /spf=(\w+)(?:\s+\([^)]*\))?(?:\s+smtp\.(mailfrom|helo)=([^\s;]+))?/g; - const spfResults: SpfEntry[] = []; - let spfM: RegExpExecArray | null; - while ((spfM = spfRegex.exec(header)) !== null) { - spfResults.push({ - result: spfM[1] as SpfResult, - identity: spfM[2] as SpfEntry['identity'], - domain: spfM[3], - }); - } + const severity = (r: string) => SPF_SEVERITY[r as SpfResult] ?? -1; + const isFailure = (r: string) => severity(r) >= SPF_SEVERITY.temperror; + const toSpfEntry = (info: ResInfo): SpfEntry => { + const identity = info.props['smtp.mailfrom'] !== undefined + ? 'mailfrom' + : info.props['smtp.helo'] !== undefined ? 'helo' : undefined; + return { + result: info.result as SpfResult, + identity, + domain: identity ? info.props[`smtp.${identity}`] || undefined : undefined, + }; + }; + const spfResults: SpfEntry[] = [ + ...own.filter((info) => info.method === 'spf').map(toSpfEntry), + ...foreign.filter((info) => info.method === 'spf').map(toSpfEntry).filter((e) => isFailure(e.result)), + ]; if (spfResults.length > 0) { - const severity = (r: string) => SPF_SEVERITY[r as SpfResult] ?? -1; // MAIL FROM is the primary SPF identity. Another identity (HELO) may only // escalate the headline to a genuine failure state - a HELO `none` or // `neutral` must not downgrade a MAIL FROM `pass`. - const isFailure = (r: string) => severity(r) >= SPF_SEVERITY.temperror; let primary = spfResults.find((e) => e.identity === 'mailfrom') ?? spfResults[0]; for (const cur of spfResults) { if (isFailure(cur.result) && severity(cur.result) > severity(primary.result)) { @@ -140,29 +352,44 @@ export function parseAuthenticationResults(header: string): AuthenticationResult }; } - const dkimMatch = header.match(/dkim=(\w+)(?:\s+header\.d=([^\s;]+))?(?:\s+header\.s=([^\s;]+))?/); - if (dkimMatch) { + // A message can carry several signatures (the author's domain and the + // sending service's). The first one stays the headline; keep them all so + // a pass further down still counts. + const dkimResults: DkimEntry[] = own + .filter((info) => info.method === 'dkim') + .map((info) => ({ + result: info.result as DkimResult, + domain: info.props['header.d'], + selector: info.props['header.s'], + })); + if (dkimResults.length > 0) { results.dkim = { - result: dkimMatch[1] as DkimResult, - domain: dkimMatch[2], - selector: dkimMatch[3], + ...dkimResults[0], + ...(dkimResults.length > 1 ? { all: dkimResults } : {}), }; } - const dmarcMatch = header.match(/dmarc=(\w+)(?:\s+header\.from=([^\s;]+))?(?:\s+policy\.dmarc=(\w+))?/); - if (dmarcMatch) { + // One DMARC verdict per message; should a header carry several, the most + // severe stands. + const dmarc = own + .filter((info) => info.method === 'dmarc') + .reduce( + (worst, info) => (!worst || (DMARC_SEVERITY[info.result] ?? -1) > (DMARC_SEVERITY[worst.result] ?? -1) ? info : worst), + undefined, + ); + if (dmarc) { results.dmarc = { - result: dmarcMatch[1] as DmarcResult, - domain: dmarcMatch[2], - policy: dmarcMatch[3] as DmarcPolicy | undefined, + result: dmarc.result as DmarcResult, + domain: dmarc.props['header.from'], + policy: dmarc.props['policy.dmarc'] as DmarcPolicy | undefined, }; } - const iprevMatch = header.match(/iprev=(\w+)(?:\s+policy\.iprev=([\d.]+))?/); - if (iprevMatch) { + const iprev = own.find((info) => info.method === 'iprev'); + if (iprev) { results.iprev = { - result: iprevMatch[1] as 'pass' | 'fail', - ip: iprevMatch[2], + result: iprev.result as 'pass' | 'fail', + ip: iprev.props['policy.iprev'], }; } @@ -261,8 +488,8 @@ export function deriveHeaderInfo(email: Pick): E const headers = email.headers; const authHeaders = headerValues(headers, 'Authentication-Results'); // The last hop's results are prepended, so the first header is the - // receiving server's own verdict. - const auth = authHeaders.length ? parseAuthenticationResults(authHeaders.join('; ')) : undefined; + // receiving server's own verdict; the parser trusts only that one. + const auth = authHeaders.length ? parseAuthenticationResults(authHeaders) : undefined; const spamRaw = headerValue(headers, 'X-Spam-Status') ?? headerValue(headers, 'X-Spam-Result')