From 8adef1e13ffd8adc9bd5661bf0fa7bcb4d2e51fb Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 29 Oct 2026 04:41:02 +0100 Subject: [PATCH] fix: never count a helo spf pass as verifying the sender --- src/lib/__tests__/email-headers.test.ts | 6 +++++- src/lib/email-headers.ts | 11 ++++++----- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/lib/__tests__/email-headers.test.ts b/src/lib/__tests__/email-headers.test.ts index 884325b0..6ff67182 100644 --- a/src/lib/__tests__/email-headers.test.ts +++ b/src/lib/__tests__/email-headers.test.ts @@ -11,7 +11,7 @@ describe('parseAuthenticationResults', () => { const r = parseAuthenticationResults( 'mx.example; spf=pass smtp.mailfrom=news.example; dkim=pass header.d=news.example header.s=s1; dmarc=pass header.from=news.example policy.dmarc=none; iprev=pass policy.iprev=1.2.3.4', ); - expect(r.spf).toEqual({ result: 'pass', domain: 'news.example' }); + expect(r.spf).toEqual({ result: 'pass', identity: 'mailfrom', domain: 'news.example' }); expect(r.dkim).toEqual({ result: 'pass', domain: 'news.example', selector: 's1' }); expect(r.dmarc).toEqual({ result: 'pass', domain: 'news.example', policy: 'none' }); expect(r.iprev).toEqual({ result: 'pass', ip: '1.2.3.4' }); @@ -88,6 +88,10 @@ describe('getSenderVerification', () => { 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'); + // Nor a HELO pass that is the only SPF result. + const only = parseAuthenticationResults('mx; spf=pass smtp.helo=bank.example; dkim=none; dmarc=none'); + expect(hasAlignedAuthenticationPass(only, 'a@bank.example')).toBe(false); + expect(getSenderVerification(only, 'a@bank.example')?.status).toBe('unverified'); }); }); diff --git a/src/lib/email-headers.ts b/src/lib/email-headers.ts index 1c661829..bb3856ad 100644 --- a/src/lib/email-headers.ts +++ b/src/lib/email-headers.ts @@ -45,7 +45,7 @@ export interface DkimEntry { } export interface AuthenticationResults { - spf?: { result: SpfResult; domain?: string; all?: SpfEntry[] }; + spf?: { result: SpfResult; identity?: SpfEntry['identity']; domain?: string; all?: SpfEntry[] }; /** The first signature's result; `all` lists every one when there are several. */ dkim?: DkimEntry & { all?: DkimEntry[] }; dmarc?: { result: DmarcResult; domain?: string; policy?: DmarcPolicy }; @@ -140,10 +140,10 @@ function alignedWith(fromDomain: string, candidate: string | undefined): boolean * 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); + // With one SPF result there is no `all` list, and its identity is checked + // too: `spf=pass smtp.helo=` alone used to count. + const entries = auth.spf?.all ?? (auth.spf ? [auth.spf] : []); + return entries.some((entry) => entry.identity === 'mailfrom' && entry.result === 'pass' && alignedWith(fromDomain, entry.domain)); } /** A DKIM pass for a signing domain that aligns with the From domain. */ @@ -381,6 +381,7 @@ export function parseAuthenticationResults(headers: string | readonly string[]): } results.spf = { result: primary.result, + ...(primary.identity ? { identity: primary.identity } : {}), domain: primary.domain, ...(spfResults.length > 1 ? { all: spfResults } : {}), };