mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 00:06:17 +00:00
fix: trust an invitation only on checks aligned with its sender and stop flagging colleagues' invitations
This commit is contained in:
@@ -156,8 +156,8 @@ export function CalendarInvitationBanner({ email, jmapAccountId }: Props) {
|
||||
}, [storeEvents, event?.uid, serverMatch]);
|
||||
|
||||
const trust = React.useMemo(
|
||||
() => (event ? getInvitationTrustAssessment(event, email, method) : null),
|
||||
[event, email, method],
|
||||
() => (event ? getInvitationTrustAssessment(event, email, method, currentUserEmails) : null),
|
||||
[event, email, method, currentUserEmails],
|
||||
);
|
||||
|
||||
if (!attachment || !enabled || state === 'error') return null;
|
||||
|
||||
@@ -63,7 +63,7 @@ describe('authentication results', () => {
|
||||
'mx.example.com; dkim=pass header.d=example.com header.s=sel; spf=pass smtp.mailfrom=example.com; dmarc=pass header.from=example.com',
|
||||
);
|
||||
expect(parsed.spf?.result).toBe('pass');
|
||||
expect(parsed.dkim).toEqual({ result: 'pass', domain: 'example.com', selector: 'sel' });
|
||||
expect(parsed.dkim).toMatchObject({ result: 'pass', domain: 'example.com', selector: 'sel' });
|
||||
expect(parsed.dmarc?.result).toBe('pass');
|
||||
});
|
||||
|
||||
@@ -113,6 +113,51 @@ describe('getInvitationTrustAssessment', () => {
|
||||
expect(getInvitationTrustAssessment(request, e, 'request').reason).toBe('authentication_missing');
|
||||
expect(getInvitationTrustAssessment(request, e, 'unknown').level).toBe('trusted');
|
||||
});
|
||||
|
||||
// #1374: a forger passes SPF for an envelope domain of their own, or signs
|
||||
// with their own key, and writes the organizer's address in From.
|
||||
const forged = (results: string) => email({
|
||||
from: [{ email: 'alice@example.com' }],
|
||||
headers: [{ name: 'Authentication-Results', value: `mx.example.org; ${results}` }],
|
||||
});
|
||||
|
||||
it('trusts only a pass aligned with the From domain', () => {
|
||||
expect(getInvitationTrustAssessment(request, forged('spf=pass smtp.mailfrom=bounce@forger.example; dmarc=none header.from=example.com'), 'request'))
|
||||
.toMatchObject({ level: 'caution', reason: 'authentication_missing' });
|
||||
expect(getInvitationTrustAssessment(request, forged('dkim=pass header.d=forger.example header.s=k1; dmarc=none'), 'request').level)
|
||||
.toBe('caution');
|
||||
// A HELO pass never vouches for the sender.
|
||||
expect(getInvitationTrustAssessment(request, forged('spf=pass smtp.helo=example.com'), 'request').level).toBe('caution');
|
||||
|
||||
// Aligned: the same domain, or a parent or child of it.
|
||||
expect(getInvitationTrustAssessment(request, forged('spf=pass smtp.mailfrom=bounce@mail.example.com; dmarc=none'), 'request').level)
|
||||
.toBe('trusted');
|
||||
expect(getInvitationTrustAssessment(request, forged('dkim=pass header.d=forger.example; dkim=pass header.d=example.com'), 'request').level)
|
||||
.toBe('trusted');
|
||||
});
|
||||
|
||||
it('warns about a forged organizer whose mail passes only for the forger\'s domain', () => {
|
||||
const e = email({
|
||||
from: [{ email: 'mallory@forger.example' }],
|
||||
headers: [{ name: 'Authentication-Results', value: 'mx.example.org; spf=pass smtp.mailfrom=forger.example; dmarc=none' }],
|
||||
});
|
||||
// Aligned with its own From, so only a caution about the mismatch.
|
||||
expect(getInvitationTrustAssessment(request, e, 'request')).toMatchObject({ level: 'caution', reason: 'sender_mismatch' });
|
||||
const unaligned = email({
|
||||
from: [{ email: 'mallory@forger.example' }],
|
||||
headers: [{ name: 'Authentication-Results', value: 'mx.example.org; spf=pass smtp.mailfrom=other.example; dmarc=none' }],
|
||||
});
|
||||
expect(getInvitationTrustAssessment(request, unaligned, 'request')).toMatchObject({ level: 'warning', reason: 'sender_mismatch_unverified' });
|
||||
});
|
||||
|
||||
it('does not flag a colleague\'s invitation that never left the server', () => {
|
||||
const colleague = email({ from: [{ email: 'alice@example.com' }] });
|
||||
expect(getInvitationTrustAssessment(request, colleague, 'request', ['bob@example.com']).level).toBe('trusted');
|
||||
// From outside, or with a header saying it came in from outside: as before.
|
||||
expect(getInvitationTrustAssessment(request, colleague, 'request', ['bob@elsewhere.example']).reason).toBe('authentication_missing');
|
||||
expect(getInvitationTrustAssessment(request, forged('spf=none smtp.mailfrom=example.com'), 'request', ['bob@example.com']).reason)
|
||||
.toBe('authentication_missing');
|
||||
});
|
||||
});
|
||||
|
||||
describe('calendarInvitationKey', () => {
|
||||
|
||||
@@ -266,9 +266,23 @@ export function calendarInvitationKey(
|
||||
|
||||
// ─── Authentication-Results / trust ──────────────────────
|
||||
|
||||
interface SpfCheck {
|
||||
result: string;
|
||||
identity?: string;
|
||||
domain?: string;
|
||||
}
|
||||
|
||||
interface DkimCheck {
|
||||
result: string;
|
||||
domain?: string;
|
||||
selector?: string;
|
||||
}
|
||||
|
||||
export interface AuthenticationResults {
|
||||
spf?: { result: string; domain?: string };
|
||||
dkim?: { result: string; domain?: string; selector?: string };
|
||||
/** The headline result; `all` every identity checked (MAIL FROM, HELO). */
|
||||
spf?: SpfCheck & { all?: SpfCheck[] };
|
||||
/** The first signature; `all` every one the header lists. */
|
||||
dkim?: DkimCheck & { all?: DkimCheck[] };
|
||||
dmarc?: { result: string; domain?: string; policy?: string };
|
||||
}
|
||||
|
||||
@@ -281,7 +295,7 @@ export function parseAuthenticationResults(header: string): AuthenticationResult
|
||||
const severity: Record<string, number> = {
|
||||
fail: 6, softfail: 5, permerror: 4, temperror: 3, neutral: 2, none: 1, pass: 0,
|
||||
};
|
||||
const spf: Array<{ result: string; identity?: string; domain?: string }> = [];
|
||||
const spf: SpfCheck[] = [];
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = spfRegex.exec(header)) !== null) {
|
||||
spf.push({ result: m[1].toLowerCase(), identity: m[2], domain: m[3] });
|
||||
@@ -292,10 +306,16 @@ export function parseAuthenticationResults(header: string): AuthenticationResult
|
||||
const s = severity[cur.result] ?? -1;
|
||||
if (s >= severity.temperror && s > (severity[primary.result] ?? -1)) primary = cur;
|
||||
}
|
||||
results.spf = { result: primary.result, domain: primary.domain };
|
||||
results.spf = { result: primary.result, identity: primary.identity, domain: primary.domain, all: spf };
|
||||
}
|
||||
const dkim = header.match(/dkim=(\w+)(?:\s+header\.d=([^\s;]+))?(?:\s+header\.s=([^\s;]+))?/);
|
||||
if (dkim) results.dkim = { result: dkim[1].toLowerCase(), domain: dkim[2], selector: dkim[3] };
|
||||
// A message can carry several signatures (the sender's and a mailing
|
||||
// list's or a sending service's): alignment looks at each.
|
||||
const dkimRegex = /dkim=(\w+)(?:\s+\([^)]*\))?(?:\s+header\.d=([^\s;]+))?(?:\s+header\.s=([^\s;]+))?/g;
|
||||
const dkim: DkimCheck[] = [];
|
||||
while ((m = dkimRegex.exec(header)) !== null) {
|
||||
dkim.push({ result: m[1].toLowerCase(), domain: m[2], selector: m[3] });
|
||||
}
|
||||
if (dkim.length > 0) results.dkim = { ...dkim[0], all: dkim };
|
||||
const dmarc = header.match(/dmarc=(\w+)(?:\s+header\.from=([^\s;]+))?(?:\s+policy\.dmarc=(\w+))?/);
|
||||
if (dmarc) results.dmarc = { result: dmarc[1].toLowerCase(), domain: dmarc[2], policy: dmarc[3] };
|
||||
return results;
|
||||
@@ -322,10 +342,43 @@ export function getEmailAuthenticationResults(
|
||||
return merged;
|
||||
}
|
||||
|
||||
function hasVerifiedAuthentication(auth?: AuthenticationResults | null): boolean {
|
||||
return Boolean(
|
||||
auth?.dmarc?.result === 'pass' || auth?.dkim?.result === 'pass' || auth?.spf?.result === 'pass',
|
||||
);
|
||||
function domainOf(address: string | undefined): string | undefined {
|
||||
if (!address) return undefined;
|
||||
const domain = address.slice(address.lastIndexOf('@') + 1).trim().toLowerCase().replace(/\.$/, '');
|
||||
return /^[^\s<>@]+\.[^\s<>@]+$/.test(domain) ? domain : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `candidate` is the From domain or a parent or child of it: the
|
||||
* relaxed alignment DMARC applies, without a public-suffix list.
|
||||
*/
|
||||
function alignedWith(fromDomain: string, candidate: string | undefined): boolean {
|
||||
const other = domainOf(candidate);
|
||||
if (!other) return false;
|
||||
return other === fromDomain || other.endsWith(`.${fromDomain}`) || fromDomain.endsWith(`.${other}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the receiving server's checks tie the message to the domain of its
|
||||
* From address: a DMARC pass, or an SPF (MAIL FROM, never HELO) or DKIM pass
|
||||
* for a domain aligned with it. A bare pass says nothing about the visible
|
||||
* sender: anyone passes SPF for an envelope domain of their own, or signs
|
||||
* with their own key, and still writes the organizer's address in From; with
|
||||
* no DMARC policy on that domain the verdict is `dmarc=none` (#1374). Mirrors
|
||||
* the webmail's hasAlignedAuthenticationPass.
|
||||
*/
|
||||
export function hasAlignedAuthenticationPass(
|
||||
auth: AuthenticationResults | null | undefined,
|
||||
fromEmail: string | null | undefined,
|
||||
): boolean {
|
||||
if (!auth || !fromEmail) return false;
|
||||
const fromDomain = domainOf(fromEmail);
|
||||
if (!fromDomain) return false;
|
||||
if (auth.dmarc?.result === 'pass') return true;
|
||||
const spf = auth.spf?.all ?? (auth.spf ? [auth.spf] : []);
|
||||
if (spf.some((e) => e.identity !== 'helo' && e.result === 'pass' && alignedWith(fromDomain, e.domain))) return true;
|
||||
const dkim = auth.dkim?.all ?? (auth.dkim ? [auth.dkim] : []);
|
||||
return dkim.some((e) => e.result === 'pass' && alignedWith(fromDomain, e.domain));
|
||||
}
|
||||
|
||||
function hasAuthenticationFailure(auth?: AuthenticationResults | null): boolean {
|
||||
@@ -361,19 +414,28 @@ export interface InvitationTrustAssessment {
|
||||
* email plus whether the sender matches the event's organizer. A spoofed
|
||||
* invitation from an unauthenticated sender must not look like a real one.
|
||||
* Port of the webmail's getInvitationTrustAssessment.
|
||||
*
|
||||
* `ownAddresses` are the reader's own addresses. Mail from another user on
|
||||
* the reader's domains that carries no Authentication-Results header at all
|
||||
* was handed in by a signed-in user rather than received from outside:
|
||||
* Stalwart adds the header to everything that arrives on port 25 (#1374).
|
||||
*/
|
||||
export function getInvitationTrustAssessment(
|
||||
event: Partial<CalendarEvent>,
|
||||
email?: Pick<Email, 'from' | 'replyTo' | 'headers'> | null,
|
||||
method: InvitationMethod = inferInvitationMethod(event),
|
||||
ownAddresses: readonly string[] = [],
|
||||
): InvitationTrustAssessment {
|
||||
const organizerEmail = getOrganizerEmail(event);
|
||||
const senderEmail = getPrimaryAddressEmail(email?.from) || getPrimaryAddressEmail(email?.replyTo);
|
||||
const auth = getEmailAuthenticationResults(email);
|
||||
const verified = hasVerifiedAuthentication(auth);
|
||||
const verified = hasAlignedAuthenticationPass(auth, getPrimaryAddressEmail(email?.from));
|
||||
const failed = hasAuthenticationFailure(auth);
|
||||
const senderMismatch = Boolean(senderEmail && organizerEmail && senderEmail !== organizerEmail);
|
||||
const expectsAuthenticatedTransport = method !== 'unknown';
|
||||
const senderDomain = domainOf(senderEmail ?? undefined);
|
||||
const internal = Boolean(email && !auth && senderDomain
|
||||
&& ownAddresses.some((address) => domainOf(address) === senderDomain));
|
||||
const expectsAuthenticatedTransport = method !== 'unknown' && !internal;
|
||||
|
||||
if (senderMismatch && (failed || !verified)) {
|
||||
return { level: 'warning', reason: 'sender_mismatch_unverified', senderEmail, organizerEmail };
|
||||
|
||||
Reference in New Issue
Block a user