From 7a95d43f1abbbd988ec2d121caed0a0a36c54246 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 29 Oct 2026 03:22:46 +0100 Subject: [PATCH] fix: trust an invitation only on checks aligned with its sender and stop flagging colleagues' invitations --- .../email/CalendarInvitationBanner.tsx | 4 +- src/lib/__tests__/calendar-invitation.test.ts | 47 +++++++++- src/lib/calendar-invitation.ts | 86 ++++++++++++++++--- 3 files changed, 122 insertions(+), 15 deletions(-) diff --git a/src/components/email/CalendarInvitationBanner.tsx b/src/components/email/CalendarInvitationBanner.tsx index 9baaaddd..7e490753 100644 --- a/src/components/email/CalendarInvitationBanner.tsx +++ b/src/components/email/CalendarInvitationBanner.tsx @@ -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; diff --git a/src/lib/__tests__/calendar-invitation.test.ts b/src/lib/__tests__/calendar-invitation.test.ts index ab1b65c7..43fdb758 100644 --- a/src/lib/__tests__/calendar-invitation.test.ts +++ b/src/lib/__tests__/calendar-invitation.test.ts @@ -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', () => { diff --git a/src/lib/calendar-invitation.ts b/src/lib/calendar-invitation.ts index b5bdee51..9a82a344 100644 --- a/src/lib/calendar-invitation.ts +++ b/src/lib/calendar-invitation.ts @@ -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 = { 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, email?: Pick | 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 };