feat: surface most severe SPF result and hide "via" badge on spoofed mail

This commit is contained in:
Linus Rath
2026-06-01 17:46:57 +02:00
parent 4659b81538
commit 3035fb046f
6 changed files with 173 additions and 14 deletions
+12 -4
View File
@@ -5,6 +5,7 @@ import { Mail, Tag } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { Email, Identity } from '@/lib/jmap/types'; import type { Email, Identity } from '@/lib/jmap/types';
import { parseSubAddress } from '@/lib/sub-addressing'; import { parseSubAddress } from '@/lib/sub-addressing';
import { isAuthenticationSpoofed } from '@/lib/email-headers';
import { useSettingsStore } from '@/stores/settings-store'; import { useSettingsStore } from '@/stores/settings-store';
interface EmailIdentityBadgeProps { interface EmailIdentityBadgeProps {
@@ -29,10 +30,17 @@ export function EmailIdentityBadge({
// Parse the from address to check for sub-addressing // Parse the from address to check for sub-addressing
const parsedFrom = parseSubAddress(fromAddress, subAddressDelimiter); const parsedFrom = parseSubAddress(fromAddress, subAddressDelimiter);
// Find matching identity (email sent BY the user) // Find matching identity (email sent BY the user). When the message is
const matchingIdentity = identities.find( // likely spoofed, the From address can't be trusted, so we ignore any
(identity) => identity.email === fromAddress || identity.email === `${parsedFrom.baseUser}@${parsedFrom.domain}` // identity match — otherwise a forged From matching one of the user's own
); // addresses would render a misleading "via <identity>" badge that implies
// legitimacy.
const spoofed = isAuthenticationSpoofed(email.authenticationResults);
const matchingIdentity = spoofed
? undefined
: identities.find(
(identity) => identity.email === fromAddress || identity.email === `${parsedFrom.baseUser}@${parsedFrom.domain}`
);
// Check if email was sent TO a sub-address (received email) // Check if email was sent TO a sub-address (received email)
let receivedToTag: string | null = null; let receivedToTag: string | null = null;
+21 -3
View File
@@ -4817,9 +4817,27 @@ export function EmailViewer({
<section className="min-w-0"> <section className="min-w-0">
<SectionHeader>{t('details.authentication_security')}</SectionHeader> <SectionHeader>{t('details.authentication_security')}</SectionHeader>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{auth?.spf && ( {auth?.spf && (() => {
<AuthChip name="SPF" result={auth.spf.result} extra={auth.spf.domain} tooltip={t('authentication.tooltip_spf')} /> // When multiple identities (HELO + MAIL FROM) were
)} // evaluated, list each result in the tooltip for full
// transparency; the chip itself shows the most severe.
const breakdown = auth.spf.all && auth.spf.all.length > 1
? auth.spf.all
.map((r) => {
const label = r.identity === 'mailfrom' ? 'MAIL FROM' : r.identity === 'helo' ? 'HELO' : 'SPF';
return `${label}: ${translateAuthResult(r.result)}${r.domain ? ` (${r.domain})` : ''}`;
})
.join('\n')
: null;
return (
<AuthChip
name="SPF"
result={auth.spf.result}
extra={auth.spf.domain}
tooltip={breakdown ? `${t('authentication.tooltip_spf')}\n\n${breakdown}` : t('authentication.tooltip_spf')}
/>
);
})()}
{auth?.dkim && ( {auth?.dkim && (
<AuthChip name="DKIM" result={auth.dkim.result} extra={auth.dkim.domain} tooltip={t('authentication.tooltip_dkim')} /> <AuthChip name="DKIM" result={auth.dkim.result} extra={auth.dkim.domain} tooltip={t('authentication.tooltip_dkim')} />
)} )}
+61
View File
@@ -7,6 +7,7 @@ import {
getSecurityStatus, getSecurityStatus,
parseSpamLLM, parseSpamLLM,
extractListHeaders, extractListHeaders,
isAuthenticationSpoofed,
} from '../email-headers'; } from '../email-headers';
describe('parseAuthenticationResults', () => { describe('parseAuthenticationResults', () => {
@@ -52,6 +53,66 @@ describe('parseAuthenticationResults', () => {
const result = parseAuthenticationResults('spf=softfail smtp.mailfrom=example.com'); const result = parseAuthenticationResults('spf=softfail smtp.mailfrom=example.com');
expect(result.spf?.result).toBe('softfail'); expect(result.spf?.result).toBe('softfail');
}); });
it('surfaces the most severe result when multiple SPF identities exist', () => {
// HELO temperror, MAIL FROM fail — the harder fail must be the headline.
const header =
'mx.example.com; spf=temperror (mx: dns timeout) smtp.helo=mail.spoof.com; spf=fail (mx: not authorized) smtp.mailfrom=victim.com';
const result = parseAuthenticationResults(header);
expect(result.spf?.result).toBe('fail');
expect(result.spf?.domain).toBe('victim.com');
});
it('exposes all SPF results when more than one identity is evaluated', () => {
const header =
'spf=temperror smtp.helo=mail.spoof.com; spf=fail smtp.mailfrom=victim.com';
const result = parseAuthenticationResults(header);
expect(result.spf?.all).toEqual([
{ result: 'temperror', identity: 'helo', domain: 'mail.spoof.com' },
{ result: 'fail', identity: 'mailfrom', domain: 'victim.com' },
]);
});
it('does not set `all` for a single SPF result', () => {
const result = parseAuthenticationResults('spf=pass smtp.mailfrom=example.com');
expect(result.spf?.all).toBeUndefined();
});
it('prefers the MAIL FROM identity when severities tie', () => {
const header = 'spf=pass smtp.helo=mail.example.com; spf=pass smtp.mailfrom=example.com';
const result = parseAuthenticationResults(header);
expect(result.spf?.domain).toBe('example.com');
});
});
describe('isAuthenticationSpoofed', () => {
it('returns false when no auth results are present', () => {
expect(isAuthenticationSpoofed(undefined)).toBe(false);
expect(isAuthenticationSpoofed({})).toBe(false);
});
it('flags a DMARC fail as spoofed', () => {
expect(isAuthenticationSpoofed({ dmarc: { result: 'fail' } })).toBe(true);
});
it('flags a hard SPF fail without a passing DKIM as spoofed', () => {
expect(isAuthenticationSpoofed({ spf: { result: 'fail' } })).toBe(true);
expect(
isAuthenticationSpoofed({ spf: { result: 'fail' }, dkim: { result: 'fail' } })
).toBe(true);
});
it('does not flag an SPF fail rescued by a passing DKIM', () => {
expect(
isAuthenticationSpoofed({ spf: { result: 'fail' }, dkim: { result: 'pass' } })
).toBe(false);
});
it('does not flag passing or ambiguous results', () => {
expect(isAuthenticationSpoofed({ spf: { result: 'pass' }, dmarc: { result: 'pass' } })).toBe(false);
expect(isAuthenticationSpoofed({ spf: { result: 'softfail' } })).toBe(false);
expect(isAuthenticationSpoofed({ spf: { result: 'temperror' } })).toBe(false);
});
}); });
describe('parseSpamScore', () => { describe('parseSpamScore', () => {
+66 -6
View File
@@ -1,23 +1,83 @@
import { AuthenticationResults } from './jmap/types'; import { AuthenticationResults } from './jmap/types';
import { parseUnsubscribeUrls } from './validation'; import { parseUnsubscribeUrls } from './validation';
type SpfResult = 'pass' | 'fail' | 'softfail' | 'neutral' | 'none' | 'temperror' | 'permerror';
type SpfEntry = NonNullable<NonNullable<AuthenticationResults['spf']>['all']>[number];
/**
* Severity ranking for SPF results. Higher = more severe / more actionable.
* A hard `fail` is a definitive policy violation and must outrank ambiguous
* states like `temperror`, so a spoofed message isn't softened to a
* "temporary failure" headline when one identity hard-fails.
*/
const SPF_SEVERITY: Record<SpfResult, number> = {
fail: 6,
softfail: 5,
permerror: 4,
temperror: 3,
neutral: 2,
none: 1,
pass: 0,
};
/**
* Whether the authentication results indicate the visible From identity can't
* be trusted (i.e. the message is likely spoofed). Used to suppress UI that
* would otherwise imply the message legitimately came from one of the user's
* own identities (e.g. the "via <identity>" badge).
*/
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;
// 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' && auth.dkim?.result !== 'pass') return true;
return false;
}
/** /**
* Parse Authentication-Results header to extract SPF, DKIM, DMARC results * Parse Authentication-Results header to extract SPF, DKIM, DMARC results
*/ */
export function parseAuthenticationResults(header: string): AuthenticationResults { export function parseAuthenticationResults(header: string): AuthenticationResults {
const results: AuthenticationResults = {}; const results: AuthenticationResults = {};
type SpfResult = 'pass' | 'fail' | 'softfail' | 'neutral' | 'none' | 'temperror' | 'permerror';
type DkimResult = 'pass' | 'fail' | 'policy' | 'neutral' | 'temperror' | 'permerror'; type DkimResult = 'pass' | 'fail' | 'policy' | 'neutral' | 'temperror' | 'permerror';
type DmarcResult = 'pass' | 'fail' | 'none'; type DmarcResult = 'pass' | 'fail' | 'none';
type DmarcPolicy = 'reject' | 'quarantine' | 'none'; type DmarcPolicy = 'reject' | 'quarantine' | 'none';
// Parse SPF // Parse SPF. A single Authentication-Results header can carry more than one
const spfMatch = header.match(/spf=(\w+)(?:\s+\([^)]*\))?\s+(?:smtp\.(?:mailfrom|helo)=([^\s;]+))?/); // SPF result when the server evaluates multiple identities (HELO and MAIL
if (spfMatch) { // FROM). Collect them all and surface the most severe as the headline so a
// hard MAIL FROM `fail` isn't softened to a HELO `temperror`.
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],
});
}
if (spfResults.length > 0) {
const severity = (r: string) => SPF_SEVERITY[r as SpfResult] ?? -1;
// Most severe wins; on a tie prefer the MAIL FROM identity (more meaningful
// than HELO) and otherwise keep the first occurrence.
const primary = spfResults.reduce((best, cur) => {
if (severity(cur.result) > severity(best.result)) return cur;
if (
severity(cur.result) === severity(best.result) &&
best.identity !== 'mailfrom' &&
cur.identity === 'mailfrom'
) return cur;
return best;
});
results.spf = { results.spf = {
result: spfMatch[1] as SpfResult, result: primary.result,
domain: spfMatch[2] domain: primary.domain,
...(spfResults.length > 1 ? { all: spfResults } : {}),
}; };
} }
+3 -1
View File
@@ -1134,7 +1134,9 @@ export class JMAPClient implements IJMAPClient {
const authResultsHeader = headersRecord['Authentication-Results']; const authResultsHeader = headersRecord['Authentication-Results'];
if (authResultsHeader) { if (authResultsHeader) {
const value = Array.isArray(authResultsHeader) ? authResultsHeader[0] : authResultsHeader; // Multiple Authentication-Results headers (or multiple SPF identities in
// one header) must all be considered so the most severe result wins.
const value = Array.isArray(authResultsHeader) ? authResultsHeader.join('; ') : authResultsHeader;
email.authenticationResults = parseAuthenticationResults(value); email.authenticationResults = parseAuthenticationResults(value);
} }
+10
View File
@@ -75,6 +75,16 @@ export interface AuthenticationResults {
result: 'pass' | 'fail' | 'softfail' | 'neutral' | 'none' | 'temperror' | 'permerror'; result: 'pass' | 'fail' | 'softfail' | 'neutral' | 'none' | 'temperror' | 'permerror';
domain?: string; domain?: string;
ip?: string; ip?: string;
/**
* All SPF results when the server evaluated multiple identities (HELO and
* MAIL FROM). Present only when more than one result was found; `result`
* above is the most severe of these.
*/
all?: Array<{
result: 'pass' | 'fail' | 'softfail' | 'neutral' | 'none' | 'temperror' | 'permerror';
domain?: string;
identity?: 'helo' | 'mailfrom';
}>;
}; };
dkim?: { dkim?: {
result: 'pass' | 'fail' | 'policy' | 'neutral' | 'temperror' | 'permerror'; result: 'pass' | 'fail' | 'policy' | 'neutral' | 'temperror' | 'permerror';