mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 08:15:55 +00:00
feat: warn when a message's sender can't be verified
This commit is contained in:
@@ -22,7 +22,7 @@ import { TagPicker } from "./tag-picker";
|
||||
import { useMeasuredTagDisplay } from "@/hooks/use-tag-display";
|
||||
import { useKeywordFormat } from "@/hooks/use-keyword-format";
|
||||
import { getEmailTagIds } from "@/lib/thread-utils";
|
||||
import { getSecurityStatus, extractListHeaders } from "@/lib/email-headers";
|
||||
import { getSecurityStatus, extractListHeaders, getSenderVerification } from "@/lib/email-headers";
|
||||
import { emailToReadView } from "@/lib/plugin-projection";
|
||||
import { generateEmailSource } from "@/lib/email-source";
|
||||
import {
|
||||
@@ -3020,6 +3020,19 @@ export function EmailViewer({
|
||||
}
|
||||
|
||||
const sender = email.from?.[0];
|
||||
const senderVerification = getSenderVerification(email.authenticationResults, sender?.email);
|
||||
const senderVerificationLabel = senderVerification?.status === 'failed'
|
||||
? t('sender_check.failed_label')
|
||||
: t('sender_check.unverified_label');
|
||||
const senderVerificationMessage = !senderVerification
|
||||
? ''
|
||||
: senderVerification.status === 'failed'
|
||||
? senderVerification.sentFrom
|
||||
? t('sender_check.failed_sent_from', { domain: senderVerification.domain, host: senderVerification.sentFrom })
|
||||
: t('sender_check.failed', { domain: senderVerification.domain })
|
||||
: senderVerification.sentFrom
|
||||
? t('sender_check.unverified_sent_from', { domain: senderVerification.domain, host: senderVerification.sentFrom })
|
||||
: t('sender_check.unverified', { domain: senderVerification.domain });
|
||||
const isStarred = email.keywords?.$flagged;
|
||||
const isUnread = !email.keywords?.$seen;
|
||||
const isImportant = email.keywords?.["$important"];
|
||||
@@ -4511,6 +4524,20 @@ export function EmailViewer({
|
||||
onViewContact={handleViewContactSidebar}
|
||||
className="text-sm text-start"
|
||||
/>
|
||||
{senderVerification && (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 px-1.5 py-0.5 rounded-md border text-xs whitespace-nowrap cursor-help",
|
||||
senderVerification.status === 'failed'
|
||||
? "bg-red-500/[0.07] border-red-500/30 text-red-700 dark:text-red-400"
|
||||
: "bg-amber-500/[0.07] border-amber-500/30 text-amber-700 dark:text-amber-400",
|
||||
)}
|
||||
title={senderVerificationMessage}
|
||||
>
|
||||
<AlertTriangle className="w-3 h-3 flex-shrink-0" />
|
||||
{senderVerificationLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Row>
|
||||
{replyToDifferent && (
|
||||
@@ -4816,12 +4843,36 @@ export function EmailViewer({
|
||||
)}
|
||||
|
||||
{/* Unified Notification Banner - External Content + Calendar Invitation + Read Receipt */}
|
||||
{((hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow') ||
|
||||
{(senderVerification ||
|
||||
(hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow') ||
|
||||
hasCalendarInvitation ||
|
||||
(readReceiptResponse === 'ask' && shouldOfferReadReceipt)) && (
|
||||
<div className="border-b border-border bg-muted/30 isolate">
|
||||
<div className="px-6 py-1.5">
|
||||
<div className="flex flex-col gap-3 isolate">
|
||||
{/* Sender the server's checks don't back */}
|
||||
{senderVerification && (
|
||||
<div className="flex items-start gap-3 py-1">
|
||||
<div className={cn(
|
||||
"w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 shadow-sm",
|
||||
senderVerification.status === 'failed' ? "bg-destructive/15 text-destructive" : "bg-warning/15 text-warning",
|
||||
)}>
|
||||
<ShieldAlert className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{senderVerificationLabel}
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground break-words">
|
||||
{senderVerificationMessage}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground break-words">
|
||||
{t('sender_check.caution')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* External Content Controls */}
|
||||
{hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow' && (
|
||||
<div className="flex items-start gap-3 py-1">
|
||||
@@ -4847,7 +4898,9 @@ export function EmailViewer({
|
||||
{t('load_external_content')}
|
||||
</button>
|
||||
)}
|
||||
{email.from?.[0]?.email && (
|
||||
{/* Trusting a forged address would load remote content
|
||||
for the next forgery too. */}
|
||||
{email.from?.[0]?.email && !senderVerification && (
|
||||
<button
|
||||
onClick={() => {
|
||||
const senderEmail = email.from?.[0]?.email;
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
parseSpamLLM,
|
||||
extractListHeaders,
|
||||
isAuthenticationSpoofed,
|
||||
getSenderVerification,
|
||||
} from '../email-headers';
|
||||
|
||||
describe('parseAuthenticationResults', () => {
|
||||
@@ -393,3 +394,61 @@ describe('parseAuthenticationResults - sender-controlled text', () => {
|
||||
expect(result.dmarc?.result).toBe('fail');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSenderVerification', () => {
|
||||
// Shape of a real phish: forged bank From, sent by a web host's PHP
|
||||
// mailer, no signature. mail-auth reports dmarc=none because neither SPF
|
||||
// nor DKIM passed, although the From domain publishes a DMARC record.
|
||||
const phish = parseAuthenticationResults(
|
||||
'mx.example.org;\r\n\tspf=none (mx.example.org: no SPF records found for www-data@web1.hoster.example) smtp.mailfrom=www-data@web1.hoster.example;\r\n\tiprev=permerror policy.iprev=203.0.113.7;\r\n\tdmarc=none header.from=bank.example policy.dmarc=none',
|
||||
);
|
||||
|
||||
it('flags a message that passes neither SPF nor DKIM', () => {
|
||||
expect(isAuthenticationSpoofed(phish)).toBe(false);
|
||||
expect(getSenderVerification(phish, 'support@bank.example')).toEqual({
|
||||
status: 'unverified',
|
||||
domain: 'bank.example',
|
||||
sentFrom: 'web1.hoster.example',
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves out the envelope host when it is the From domain', () => {
|
||||
const auth = parseAuthenticationResults('mx; spf=softfail smtp.mailfrom=billing@Bank.Example; dmarc=none header.from=bank.example');
|
||||
expect(getSenderVerification(auth, 'billing@bank.example')).toEqual({ status: 'unverified', domain: 'bank.example' });
|
||||
});
|
||||
|
||||
it('reports a DMARC fail as failed', () => {
|
||||
const auth = parseAuthenticationResults('mx; spf=pass smtp.mailfrom=bounce@evil.example; dmarc=fail header.from=bank.example');
|
||||
expect(getSenderVerification(auth, 'support@bank.example')).toEqual({
|
||||
status: 'failed',
|
||||
domain: 'bank.example',
|
||||
sentFrom: 'evil.example',
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts any passing SPF, DKIM or DMARC result', () => {
|
||||
const from = 'news@shop.example';
|
||||
expect(getSenderVerification(parseAuthenticationResults('mx; spf=pass smtp.mailfrom=bounces.esp.example'), from)).toBeNull();
|
||||
expect(getSenderVerification(parseAuthenticationResults('mx; spf=none smtp.mailfrom=x.example; dkim=pass header.d=esp.example'), from)).toBeNull();
|
||||
expect(getSenderVerification(parseAuthenticationResults('mx; dmarc=pass header.from=shop.example'), from)).toBeNull();
|
||||
});
|
||||
|
||||
it('counts a passing signature that is not the first one', () => {
|
||||
const auth = parseAuthenticationResults('mx; dkim=fail header.d=shop.example; dkim=pass header.d=esp.example; spf=none smtp.mailfrom=x.example');
|
||||
expect(auth.dkim?.result).toBe('fail');
|
||||
expect(auth.dkim?.all).toHaveLength(2);
|
||||
expect(getSenderVerification(auth, 'news@shop.example')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not take a HELO pass for a MAIL FROM pass', () => {
|
||||
const auth = parseAuthenticationResults('mx; spf=pass smtp.helo=web1.hoster.example; spf=none smtp.mailfrom=www-data@web1.hoster.example');
|
||||
expect(getSenderVerification(auth, 'support@bank.example')?.status).toBe('unverified');
|
||||
});
|
||||
|
||||
it('says nothing without results or a From address', () => {
|
||||
expect(getSenderVerification(undefined, 'a@b.example')).toBeNull();
|
||||
expect(getSenderVerification({}, 'a@b.example')).toBeNull();
|
||||
expect(getSenderVerification({ iprev: { result: 'fail' } }, 'a@b.example')).toBeNull();
|
||||
expect(getSenderVerification(phish, undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
+66
-6
@@ -33,10 +33,62 @@ export function isAuthenticationSpoofed(auth?: AuthenticationResults): boolean {
|
||||
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;
|
||||
if (auth.spf?.result === 'fail' && !hasDkimPass(auth)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasDkimPass(auth: AuthenticationResults): boolean {
|
||||
return auth.dkim?.result === 'pass' || !!auth.dkim?.all?.some((entry) => entry.result === 'pass');
|
||||
}
|
||||
|
||||
function domainOf(address: string): string | undefined {
|
||||
const domain = address.slice(address.lastIndexOf('@') + 1).trim().toLowerCase().replace(/\.$/, '');
|
||||
return /^[^\s<>@]+\.[^\s<>@]+$/.test(domain) ? domain : undefined;
|
||||
}
|
||||
|
||||
export interface SenderVerification {
|
||||
/**
|
||||
* `failed`: the message fails the From domain's checks (see
|
||||
* isAuthenticationSpoofed). `unverified`: neither SPF nor DKIM passes, so
|
||||
* nothing ties the message to any domain, let alone the one in From.
|
||||
*/
|
||||
status: 'failed' | 'unverified';
|
||||
/** Domain of the visible From address. */
|
||||
domain: string;
|
||||
/** Envelope (MAIL FROM) host, when it differs from `domain`. */
|
||||
sentFrom?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the receiving server's checks back the visible From domain.
|
||||
* Returns null when they do, or when there are no results to judge by.
|
||||
*
|
||||
* DMARC alone can't answer this: mail-auth (Stalwart) only checks alignment
|
||||
* once SPF or DKIM passes, so a message that passes neither reports
|
||||
* `dmarc=none` even when the From domain publishes a policy. That is the
|
||||
* plainest kind of forgery, so it gets its own verdict here.
|
||||
*/
|
||||
export function getSenderVerification(
|
||||
auth: AuthenticationResults | undefined,
|
||||
fromEmail: string | undefined,
|
||||
): SenderVerification | null {
|
||||
if (!auth || !fromEmail || (!auth.spf && !auth.dkim && !auth.dmarc)) return null;
|
||||
const domain = domainOf(fromEmail);
|
||||
if (!domain) return null;
|
||||
|
||||
// DMARC only counts the MAIL FROM identity; a HELO pass proves nothing
|
||||
// about who wrote the message.
|
||||
const mailFrom = auth.spf?.all?.find((entry) => entry.identity === 'mailfrom');
|
||||
const spfPass = auth.spf?.all ? mailFrom?.result === 'pass' : auth.spf?.result === 'pass';
|
||||
const envelope = mailFrom?.domain ?? auth.spf?.domain;
|
||||
const envelopeDomain = envelope ? domainOf(envelope) : undefined;
|
||||
const sentFrom = envelopeDomain && envelopeDomain !== domain ? envelopeDomain : undefined;
|
||||
|
||||
if (isAuthenticationSpoofed(auth)) return { status: 'failed', domain, sentFrom };
|
||||
if (auth.dmarc?.result === 'pass' || spfPass || hasDkimPass(auth)) return null;
|
||||
return { status: 'unverified', domain, sentFrom };
|
||||
}
|
||||
|
||||
interface ResInfo {
|
||||
method: string;
|
||||
result: string;
|
||||
@@ -182,12 +234,20 @@ export function parseAuthenticationResults(headers: string | readonly string[]):
|
||||
};
|
||||
}
|
||||
|
||||
const dkim = own.find((info) => info.method === 'dkim');
|
||||
if (dkim) {
|
||||
// A message can carry several signatures (the author's domain and the
|
||||
// sending service's). The first one stays the headline; keep them all so
|
||||
// a pass further down still counts.
|
||||
const dkimResults = own
|
||||
.filter((info) => info.method === 'dkim')
|
||||
.map((info) => ({
|
||||
result: info.result as DkimResult,
|
||||
domain: info.props['header.d'],
|
||||
selector: info.props['header.s'],
|
||||
}));
|
||||
if (dkimResults.length > 0) {
|
||||
results.dkim = {
|
||||
result: dkim.result as DkimResult,
|
||||
domain: dkim.props['header.d'],
|
||||
selector: dkim.props['header.s'],
|
||||
...dkimResults[0],
|
||||
...(dkimResults.length > 1 ? { all: dkimResults } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -163,6 +163,15 @@ export interface AuthenticationResults {
|
||||
result: 'pass' | 'fail' | 'policy' | 'neutral' | 'temperror' | 'permerror';
|
||||
domain?: string;
|
||||
selector?: string;
|
||||
/**
|
||||
* Every DKIM result when the message carried more than one signature;
|
||||
* `result` above is the first.
|
||||
*/
|
||||
all?: Array<{
|
||||
result: 'pass' | 'fail' | 'policy' | 'neutral' | 'temperror' | 'permerror';
|
||||
domain?: string;
|
||||
selector?: string;
|
||||
}>;
|
||||
};
|
||||
dmarc?: {
|
||||
result: 'pass' | 'fail' | 'none';
|
||||
|
||||
@@ -345,6 +345,15 @@
|
||||
"external_content_warning": "Bilder und externe Inhalte wurden blockiert",
|
||||
"load_external_content": "Bilder laden",
|
||||
"trust_sender": "Diesem Absender immer vertrauen",
|
||||
"sender_check": {
|
||||
"unverified_label": "Absender nicht bestätigt",
|
||||
"failed_label": "Absenderprüfung fehlgeschlagen",
|
||||
"unverified": "Diese Nachricht gibt vor, von {domain} zu kommen, aber nichts bestätigt das. Sie trägt keine gültige Signatur von {domain} und kam nicht von einem Server, den {domain} zulässt.",
|
||||
"unverified_sent_from": "Diese Nachricht gibt vor, von {domain} zu kommen, wurde aber von {host} verschickt und trägt keine gültige Signatur von {domain}.",
|
||||
"failed": "Diese Nachricht gibt vor, von {domain} zu kommen, hat aber die Absenderprüfung dieser Domain nicht bestanden. Sie ist möglicherweise gefälscht.",
|
||||
"failed_sent_from": "Diese Nachricht gibt vor, von {domain} zu kommen, wurde aber von {host} verschickt und hat die Absenderprüfung von {domain} nicht bestanden. Sie ist möglicherweise gefälscht.",
|
||||
"caution": "Seien Sie vorsichtig mit Links, Anhängen und Fragen nach Passwörtern oder Zahlungsdaten."
|
||||
},
|
||||
"back_to_list": "Zurück zur Liste",
|
||||
"view_contact": "Kontakt anzeigen",
|
||||
"message_details": "Nachrichtendetails",
|
||||
|
||||
@@ -345,6 +345,15 @@
|
||||
"external_content_warning": "Images and external content have been blocked",
|
||||
"load_external_content": "Load images",
|
||||
"trust_sender": "Always trust this sender",
|
||||
"sender_check": {
|
||||
"unverified_label": "Unverified sender",
|
||||
"failed_label": "Sender check failed",
|
||||
"unverified": "This message claims to be from {domain}, but nothing confirms that. It has no valid signature from {domain} and didn't come from a server {domain} authorizes.",
|
||||
"unverified_sent_from": "This message claims to be from {domain}, but it was sent from {host} and has no valid signature from {domain}.",
|
||||
"failed": "This message claims to be from {domain}, but it failed that domain's sender checks. It may be forged.",
|
||||
"failed_sent_from": "This message claims to be from {domain}, but it was sent from {host} and failed {domain}'s sender checks. It may be forged.",
|
||||
"caution": "Be careful with links, attachments and requests for passwords or payment details."
|
||||
},
|
||||
"back_to_list": "Back to list",
|
||||
"view_contact": "View contact",
|
||||
"message_details": "Message Details",
|
||||
|
||||
Reference in New Issue
Block a user