fix: stop HELO spf=none from downgrading a MAIL FROM spf=pass #650

This commit is contained in:
Linus Rath
2026-07-21 23:03:34 +02:00
parent e8f01871c2
commit 162e420a1f
2 changed files with 43 additions and 13 deletions
+29
View File
@@ -83,6 +83,35 @@ describe('parseAuthenticationResults', () => {
const result = parseAuthenticationResults(header); const result = parseAuthenticationResults(header);
expect(result.spf?.domain).toBe('example.com'); expect(result.spf?.domain).toBe('example.com');
}); });
it('does not let a HELO `none` downgrade a MAIL FROM `pass` (#650)', () => {
const header =
'mail.haxalot.com; spf=none (mail.haxalot.com: no SPF records found for postmaster@out-23.smtp.github.com) smtp.helo=out-23.smtp.github.com; spf=pass (mail.haxalot.com: domain of noreply@github.com designates 192.30.252.206 as permitted sender) smtp.mailfrom=noreply@github.com';
const result = parseAuthenticationResults(header);
expect(result.spf?.result).toBe('pass');
expect(result.spf?.domain).toBe('noreply@github.com');
expect(result.spf?.all).toHaveLength(2);
});
it('does not let a HELO `neutral` downgrade a MAIL FROM `pass`', () => {
const header = 'spf=neutral smtp.helo=mail.example.com; spf=pass smtp.mailfrom=example.com';
const result = parseAuthenticationResults(header);
expect(result.spf?.result).toBe('pass');
});
it('still escalates a HELO hard fail over a MAIL FROM pass', () => {
const header = 'spf=fail smtp.helo=mail.spoof.com; spf=pass smtp.mailfrom=example.com';
const result = parseAuthenticationResults(header);
expect(result.spf?.result).toBe('fail');
expect(result.spf?.domain).toBe('mail.spoof.com');
});
it('keeps MAIL FROM `none` as the headline even when HELO passes', () => {
const header = 'spf=pass smtp.helo=mail.example.com; spf=none smtp.mailfrom=example.com';
const result = parseAuthenticationResults(header);
expect(result.spf?.result).toBe('none');
expect(result.spf?.domain).toBe('example.com');
});
}); });
describe('isAuthenticationSpoofed', () => { describe('isAuthenticationSpoofed', () => {
+14 -13
View File
@@ -49,8 +49,8 @@ export function parseAuthenticationResults(header: string): AuthenticationResult
// Parse SPF. A single Authentication-Results header can carry more than one // Parse SPF. A single Authentication-Results header can carry more than one
// SPF result when the server evaluates multiple identities (HELO and MAIL // SPF result when the server evaluates multiple identities (HELO and MAIL
// FROM). Collect them all and surface the most severe as the headline so a // FROM). Collect them all so a hard fail on any identity isn't softened to
// hard MAIL FROM `fail` isn't softened to a HELO `temperror`. // an ambiguous state recorded for another one.
const spfRegex = /spf=(\w+)(?:\s+\([^)]*\))?(?:\s+smtp\.(mailfrom|helo)=([^\s;]+))?/g; const spfRegex = /spf=(\w+)(?:\s+\([^)]*\))?(?:\s+smtp\.(mailfrom|helo)=([^\s;]+))?/g;
const spfResults: SpfEntry[] = []; const spfResults: SpfEntry[] = [];
let spfM: RegExpExecArray | null; let spfM: RegExpExecArray | null;
@@ -63,17 +63,18 @@ export function parseAuthenticationResults(header: string): AuthenticationResult
} }
if (spfResults.length > 0) { if (spfResults.length > 0) {
const severity = (r: string) => SPF_SEVERITY[r as SpfResult] ?? -1; const severity = (r: string) => SPF_SEVERITY[r as SpfResult] ?? -1;
// Most severe wins; on a tie prefer the MAIL FROM identity (more meaningful // MAIL FROM is the primary SPF identity. Another identity (HELO) may only
// than HELO) and otherwise keep the first occurrence. // escalate the headline to a genuine failure state — a HELO `none` or
const primary = spfResults.reduce((best, cur) => { // `neutral` must not downgrade a MAIL FROM `pass`, since most senders
if (severity(cur.result) > severity(best.result)) return cur; // publish no SPF record for their EHLO hostname.
if ( const isFailure = (r: string) => severity(r) >= SPF_SEVERITY.temperror;
severity(cur.result) === severity(best.result) && let primary =
best.identity !== 'mailfrom' && spfResults.find((e) => e.identity === 'mailfrom') ?? spfResults[0];
cur.identity === 'mailfrom' for (const cur of spfResults) {
) return cur; if (isFailure(cur.result) && severity(cur.result) > severity(primary.result)) {
return best; primary = cur;
}); }
}
results.spf = { results.spf = {
result: primary.result, result: primary.result,
domain: primary.domain, domain: primary.domain,