feat: surface most severe SPF result and hide "via" badge on spoofed mail
This commit is contained in:
@@ -5,6 +5,7 @@ import { Mail, Tag } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Email, Identity } from '@/lib/jmap/types';
|
||||
import { parseSubAddress } from '@/lib/sub-addressing';
|
||||
import { isAuthenticationSpoofed } from '@/lib/email-headers';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
|
||||
interface EmailIdentityBadgeProps {
|
||||
@@ -29,10 +30,17 @@ export function EmailIdentityBadge({
|
||||
// Parse the from address to check for sub-addressing
|
||||
const parsedFrom = parseSubAddress(fromAddress, subAddressDelimiter);
|
||||
|
||||
// Find matching identity (email sent BY the user)
|
||||
const matchingIdentity = identities.find(
|
||||
(identity) => identity.email === fromAddress || identity.email === `${parsedFrom.baseUser}@${parsedFrom.domain}`
|
||||
);
|
||||
// Find matching identity (email sent BY the user). When the message is
|
||||
// likely spoofed, the From address can't be trusted, so we ignore any
|
||||
// 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)
|
||||
let receivedToTag: string | null = null;
|
||||
|
||||
@@ -4817,9 +4817,27 @@ export function EmailViewer({
|
||||
<section className="min-w-0">
|
||||
<SectionHeader>{t('details.authentication_security')}</SectionHeader>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{auth?.spf && (
|
||||
<AuthChip name="SPF" result={auth.spf.result} extra={auth.spf.domain} tooltip={t('authentication.tooltip_spf')} />
|
||||
)}
|
||||
{auth?.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 && (
|
||||
<AuthChip name="DKIM" result={auth.dkim.result} extra={auth.dkim.domain} tooltip={t('authentication.tooltip_dkim')} />
|
||||
)}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
getSecurityStatus,
|
||||
parseSpamLLM,
|
||||
extractListHeaders,
|
||||
isAuthenticationSpoofed,
|
||||
} from '../email-headers';
|
||||
|
||||
describe('parseAuthenticationResults', () => {
|
||||
@@ -52,6 +53,66 @@ describe('parseAuthenticationResults', () => {
|
||||
const result = parseAuthenticationResults('spf=softfail smtp.mailfrom=example.com');
|
||||
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', () => {
|
||||
|
||||
+66
-6
@@ -1,23 +1,83 @@
|
||||
import { AuthenticationResults } from './jmap/types';
|
||||
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
|
||||
*/
|
||||
export function parseAuthenticationResults(header: string): AuthenticationResults {
|
||||
const results: AuthenticationResults = {};
|
||||
|
||||
type SpfResult = 'pass' | 'fail' | 'softfail' | 'neutral' | 'none' | 'temperror' | 'permerror';
|
||||
type DkimResult = 'pass' | 'fail' | 'policy' | 'neutral' | 'temperror' | 'permerror';
|
||||
type DmarcResult = 'pass' | 'fail' | 'none';
|
||||
type DmarcPolicy = 'reject' | 'quarantine' | 'none';
|
||||
|
||||
// Parse SPF
|
||||
const spfMatch = header.match(/spf=(\w+)(?:\s+\([^)]*\))?\s+(?:smtp\.(?:mailfrom|helo)=([^\s;]+))?/);
|
||||
if (spfMatch) {
|
||||
// Parse SPF. A single Authentication-Results header can carry more than one
|
||||
// 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
|
||||
// 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 = {
|
||||
result: spfMatch[1] as SpfResult,
|
||||
domain: spfMatch[2]
|
||||
result: primary.result,
|
||||
domain: primary.domain,
|
||||
...(spfResults.length > 1 ? { all: spfResults } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+3
-1
@@ -1134,7 +1134,9 @@ export class JMAPClient implements IJMAPClient {
|
||||
|
||||
const authResultsHeader = headersRecord['Authentication-Results'];
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -75,6 +75,16 @@ export interface AuthenticationResults {
|
||||
result: 'pass' | 'fail' | 'softfail' | 'neutral' | 'none' | 'temperror' | 'permerror';
|
||||
domain?: 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?: {
|
||||
result: 'pass' | 'fail' | 'policy' | 'neutral' | 'temperror' | 'permerror';
|
||||
|
||||
Reference in New Issue
Block a user