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 { 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;
|
||||||
|
|||||||
@@ -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')} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
Reference in New Issue
Block a user