mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 00:06:23 +00:00
fix: stop senders from faking a DKIM or DMARC pass in the authentication chips
This commit is contained in:
@@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest';
|
|||||||
import {
|
import {
|
||||||
parseAuthenticationResults, parseSpamScore, parseSpamLLM, extractListHeaders,
|
parseAuthenticationResults, parseSpamScore, parseSpamLLM, extractListHeaders,
|
||||||
isAuthenticationSpoofed, headersToRecord, deriveHeaderInfo, deliveryDeltaMs, formatDelta,
|
isAuthenticationSpoofed, headersToRecord, deriveHeaderInfo, deliveryDeltaMs, formatDelta,
|
||||||
findReceivingIdentity,
|
findReceivingIdentity, getSenderVerification, hasAlignedAuthenticationPass,
|
||||||
} from '../email-headers';
|
} from '../email-headers';
|
||||||
|
|
||||||
describe('parseAuthenticationResults', () => {
|
describe('parseAuthenticationResults', () => {
|
||||||
@@ -23,6 +23,71 @@ describe('parseAuthenticationResults', () => {
|
|||||||
const none = parseAuthenticationResults('spf=pass smtp.mailfrom=a.example; spf=none smtp.helo=b.example');
|
const none = parseAuthenticationResults('spf=pass smtp.mailfrom=a.example; spf=none smtp.helo=b.example');
|
||||||
expect(none.spf?.result).toBe('pass');
|
expect(none.spf?.result).toBe('pass');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The envelope address and comments are sender-chosen text: a result
|
||||||
|
// counts only where its method opens a `;`-separated part.
|
||||||
|
it('ignores dkim= and dmarc= written into an envelope address or a comment', () => {
|
||||||
|
const r = parseAuthenticationResults(
|
||||||
|
'mx.example; spf=pass smtp.mailfrom="dkim=pass;dmarc=pass"@evil.example; '
|
||||||
|
+ 'dkim=none (no signature; dkim=pass header.d=bank.example); dmarc=none header.from=bank.example',
|
||||||
|
);
|
||||||
|
expect(r.dkim?.result).toBe('none');
|
||||||
|
expect(r.dmarc?.result).toBe('none');
|
||||||
|
const local = parseAuthenticationResults('mx; spf=pass smtp.mailfrom=dmarc=pass@evil.example');
|
||||||
|
expect(local.dmarc).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes DKIM and DMARC from the receiving server's own (topmost) header only", () => {
|
||||||
|
const r = parseAuthenticationResults([
|
||||||
|
'mx.example; spf=pass smtp.mailfrom=evil.example; dkim=none; dmarc=none header.from=bank.example',
|
||||||
|
'forged.example; dkim=pass header.d=bank.example; dmarc=pass header.from=bank.example',
|
||||||
|
]);
|
||||||
|
expect(r.dkim?.result).toBe('none');
|
||||||
|
expect(r.dmarc?.result).toBe('none');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets a lower header escalate SPF to a failure but never pass it', () => {
|
||||||
|
const r = parseAuthenticationResults([
|
||||||
|
'mx.example; spf=none smtp.mailfrom=a.example',
|
||||||
|
'other; spf=pass smtp.mailfrom=a.example; spf=fail smtp.mailfrom=b.example',
|
||||||
|
]);
|
||||||
|
expect(r.spf?.result).toBe('fail');
|
||||||
|
expect(r.spf?.all?.map((e) => e.result)).toEqual(['none', 'fail']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps every DKIM signature so a later pass still counts', () => {
|
||||||
|
const r = parseAuthenticationResults('mx; dkim=fail header.d=esp.example; dkim=pass header.d=shop.example');
|
||||||
|
expect(r.dkim?.result).toBe('fail');
|
||||||
|
expect(r.dkim?.all?.map((e) => e.domain)).toEqual(['esp.example', 'shop.example']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getSenderVerification', () => {
|
||||||
|
it("flags mail whose SPF passes only for the sender's own envelope domain", () => {
|
||||||
|
const auth = parseAuthenticationResults('mx; spf=pass smtp.mailfrom=evil.example; dkim=none; dmarc=none');
|
||||||
|
expect(hasAlignedAuthenticationPass(auth, 'ceo@bank.example')).toBe(false);
|
||||||
|
expect(getSenderVerification(auth, 'ceo@bank.example'))
|
||||||
|
.toEqual({ status: 'unverified', domain: 'bank.example', sentFrom: 'evil.example' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a DMARC fail as failed', () => {
|
||||||
|
const auth = parseAuthenticationResults('mx; spf=fail smtp.mailfrom=bank.example; dmarc=fail header.from=bank.example');
|
||||||
|
expect(getSenderVerification(auth, 'ceo@bank.example')?.status).toBe('failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts an aligned pass, a subdomain included, and judges nothing without results', () => {
|
||||||
|
const spf = parseAuthenticationResults('mx; spf=pass smtp.mailfrom=bounce.bank.example; dmarc=none');
|
||||||
|
expect(getSenderVerification(spf, 'ceo@bank.example')).toBeNull();
|
||||||
|
const dkim = parseAuthenticationResults('mx; dkim=fail header.d=esp.example; dkim=pass header.d=bank.example');
|
||||||
|
expect(getSenderVerification(dkim, 'ceo@bank.example')).toBeNull();
|
||||||
|
expect(getSenderVerification(parseAuthenticationResults('mx; iprev=pass'), 'a@bank.example')).toBeNull();
|
||||||
|
expect(getSenderVerification(undefined, 'a@bank.example')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not count a HELO pass', () => {
|
||||||
|
const auth = parseAuthenticationResults('mx; spf=pass smtp.helo=bank.example; spf=none smtp.mailfrom=bank.example');
|
||||||
|
expect(getSenderVerification(auth, 'a@bank.example')?.status).toBe('unverified');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('isAuthenticationSpoofed', () => {
|
describe('isAuthenticationSpoofed', () => {
|
||||||
@@ -69,6 +134,7 @@ describe('deriveHeaderInfo', () => {
|
|||||||
{ name: 'Disposition-Notification-To', value: 'Sender <s@x.example>' },
|
{ name: 'Disposition-Notification-To', value: 'Sender <s@x.example>' },
|
||||||
{ name: 'X-Spam-Status', value: 'No, score=0.1' },
|
{ name: 'X-Spam-Status', value: 'No, score=0.1' },
|
||||||
{ name: 'Authentication-Results', value: 'mx; dmarc=fail header.from=x.example' },
|
{ name: 'Authentication-Results', value: 'mx; dmarc=fail header.from=x.example' },
|
||||||
|
{ name: 'Authentication-Results', value: 'relay; dmarc=pass header.from=x.example' },
|
||||||
],
|
],
|
||||||
messageId: ['abc@x.example'],
|
messageId: ['abc@x.example'],
|
||||||
});
|
});
|
||||||
|
|||||||
+259
-32
@@ -38,9 +38,16 @@ export interface SpfEntry {
|
|||||||
domain?: string;
|
domain?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DkimEntry {
|
||||||
|
result: DkimResult;
|
||||||
|
domain?: string;
|
||||||
|
selector?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AuthenticationResults {
|
export interface AuthenticationResults {
|
||||||
spf?: { result: SpfResult; domain?: string; all?: SpfEntry[] };
|
spf?: { result: SpfResult; domain?: string; all?: SpfEntry[] };
|
||||||
dkim?: { result: DkimResult; domain?: string; selector?: string };
|
/** The first signature's result; `all` lists every one when there are several. */
|
||||||
|
dkim?: DkimEntry & { all?: DkimEntry[] };
|
||||||
dmarc?: { result: DmarcResult; domain?: string; policy?: DmarcPolicy };
|
dmarc?: { result: DmarcResult; domain?: string; policy?: DmarcPolicy };
|
||||||
iprev?: { result: 'pass' | 'fail'; ip?: string };
|
iprev?: { result: 'pass' | 'fail'; ip?: string };
|
||||||
}
|
}
|
||||||
@@ -98,35 +105,240 @@ const SPF_SEVERITY: Record<SpfResult, number> = {
|
|||||||
*/
|
*/
|
||||||
export function isAuthenticationSpoofed(auth?: AuthenticationResults): boolean {
|
export function isAuthenticationSpoofed(auth?: AuthenticationResults): boolean {
|
||||||
if (!auth) return false;
|
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;
|
if (auth.dmarc?.result === 'fail') return true;
|
||||||
if (auth.spf?.result === 'fail' && auth.dkim?.result !== 'pass') 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' && !hasDkimPass(auth)) return true;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Parse an Authentication-Results header into SPF, DKIM, DMARC, iprev results. */
|
function hasDkimPass(auth: AuthenticationResults): boolean {
|
||||||
export function parseAuthenticationResults(header: string): AuthenticationResults {
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether `candidate` is the From domain or a parent/child of it: the
|
||||||
|
* relaxed alignment DMARC applies, without a public-suffix list.
|
||||||
|
*/
|
||||||
|
function alignedWith(fromDomain: string, candidate: string | undefined): boolean {
|
||||||
|
const other = candidate ? domainOf(`@${candidate}`) : undefined;
|
||||||
|
if (!other) return false;
|
||||||
|
return other === fromDomain || other.endsWith(`.${fromDomain}`) || fromDomain.endsWith(`.${other}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An SPF pass for the MAIL FROM identity whose domain aligns with the From
|
||||||
|
* domain. Any sender can pass SPF for a domain of their own and still write
|
||||||
|
* someone else's address in From; only an aligned pass says anything about
|
||||||
|
* the visible sender. A HELO pass never does.
|
||||||
|
*/
|
||||||
|
function hasAlignedSpfPass(auth: AuthenticationResults, fromDomain: string): boolean {
|
||||||
|
if (auth.spf?.all) {
|
||||||
|
return auth.spf.all.some((entry) => entry.identity !== 'helo' && entry.result === 'pass' && alignedWith(fromDomain, entry.domain));
|
||||||
|
}
|
||||||
|
return auth.spf?.result === 'pass' && alignedWith(fromDomain, auth.spf.domain);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A DKIM pass for a signing domain that aligns with the From domain. */
|
||||||
|
function hasAlignedDkimPass(auth: AuthenticationResults, fromDomain: string): boolean {
|
||||||
|
const entries = auth.dkim?.all ?? (auth.dkim ? [auth.dkim] : []);
|
||||||
|
return entries.some((entry) => entry.result === 'pass' && alignedWith(fromDomain, entry.domain));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the receiving server's checks tie the message to the visible From
|
||||||
|
* domain: an aligned DMARC, SPF or DKIM pass. Shared by the sender
|
||||||
|
* verification notice and the calendar invitation trust assessment.
|
||||||
|
*/
|
||||||
|
export function hasAlignedAuthenticationPass(auth: AuthenticationResults | undefined, fromEmail: string | undefined): boolean {
|
||||||
|
if (!auth || !fromEmail) return false;
|
||||||
|
const domain = domainOf(fromEmail);
|
||||||
|
if (!domain) return false;
|
||||||
|
return auth.dmarc?.result === 'pass' || hasAlignedSpfPass(auth, domain) || hasAlignedDkimPass(auth, domain);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SenderVerification {
|
||||||
|
/**
|
||||||
|
* `failed`: the message fails the From domain's checks (see
|
||||||
|
* isAuthenticationSpoofed). `unverified`: no SPF or DKIM pass ties the
|
||||||
|
* message to the domain 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 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 };
|
||||||
|
// Only a pass that aligns with the From domain counts: `spf=pass` for the
|
||||||
|
// sender's own envelope domain under someone else's From is the forgery
|
||||||
|
// this notice exists for, and a DKIM signature by a third domain is no
|
||||||
|
// better.
|
||||||
|
if (hasAlignedAuthenticationPass(auth, fromEmail)) return null;
|
||||||
|
return { status: 'unverified', domain, sentFrom };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResInfo {
|
||||||
|
method: string;
|
||||||
|
result: string;
|
||||||
|
props: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split one Authentication-Results header into its `;`-separated parts
|
||||||
|
* (RFC 8601), dropping comments. A `;` inside a quoted string or a comment
|
||||||
|
* does not split: both can carry sender-chosen text such as the envelope
|
||||||
|
* address.
|
||||||
|
*/
|
||||||
|
function splitResinfo(header: string): string[] {
|
||||||
|
const parts: string[] = [];
|
||||||
|
let current = '';
|
||||||
|
let depth = 0;
|
||||||
|
let quoted = false;
|
||||||
|
for (let i = 0; i < header.length; i++) {
|
||||||
|
const c = header[i];
|
||||||
|
if (c === '\\' && (quoted || depth > 0)) {
|
||||||
|
if (depth === 0) current += c + (header[i + 1] ?? '');
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (quoted) {
|
||||||
|
current += c;
|
||||||
|
if (c === '"') quoted = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (c === '(') {
|
||||||
|
depth++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (depth > 0) {
|
||||||
|
if (c === ')' && --depth === 0) current += ' ';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (c === '"') quoted = true;
|
||||||
|
if (c === ';') {
|
||||||
|
parts.push(current);
|
||||||
|
current = '';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
current += c;
|
||||||
|
}
|
||||||
|
parts.push(current);
|
||||||
|
return parts.map((part) => part.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
const METHOD_RE = /^([a-z0-9][a-z0-9_-]*)(?:\/\d+)?\s*=\s*([a-z]+)(?=\s|$)/i;
|
||||||
|
const PROP_RE = /\s*([^\s=]+)\s*=\s*("(?:[^"\\]|\\.)*"|\S*)/y;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read one resinfo: the method must open the part, so a `dmarc=pass` that
|
||||||
|
* appears inside a property value (say an envelope local part in
|
||||||
|
* `smtp.mailfrom=`) is never taken for a result.
|
||||||
|
*/
|
||||||
|
function parseResinfo(part: string): ResInfo | null {
|
||||||
|
const match = METHOD_RE.exec(part);
|
||||||
|
if (!match) return null;
|
||||||
|
const props: Record<string, string> = {};
|
||||||
|
const rest = part.slice(match[0].length);
|
||||||
|
PROP_RE.lastIndex = 0;
|
||||||
|
let prop: RegExpExecArray | null;
|
||||||
|
while ((prop = PROP_RE.exec(rest)) !== null && prop[0].length > 0) {
|
||||||
|
const key = prop[1].toLowerCase();
|
||||||
|
let value = prop[2];
|
||||||
|
if (value.startsWith('"')) value = value.slice(1, -1).replace(/\\(.)/g, '$1');
|
||||||
|
if (!(key in props)) props[key] = value;
|
||||||
|
}
|
||||||
|
return { method: match[1].toLowerCase(), result: match[2].toLowerCase(), props };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseResinfos(header: string): ResInfo[] {
|
||||||
|
return splitResinfo(header)
|
||||||
|
.map(parseResinfo)
|
||||||
|
.filter((info): info is ResInfo => info !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const DMARC_SEVERITY: Record<string, number> = {
|
||||||
|
fail: 3,
|
||||||
|
permerror: 2,
|
||||||
|
temperror: 2,
|
||||||
|
none: 1,
|
||||||
|
pass: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse Authentication-Results headers into SPF, DKIM, DMARC and iprev
|
||||||
|
* results (port of the webmail's parser, 8f7124c87).
|
||||||
|
*
|
||||||
|
* Pass the headers in message order. The topmost one is the receiving
|
||||||
|
* server's own; anything below it may have been written by the sender, so
|
||||||
|
* DKIM, DMARC and iprev come from the topmost header only, and the others
|
||||||
|
* can only escalate SPF to a failure, never supply a pass. A method counts
|
||||||
|
* only where it opens a `;`-separated part, so `dkim=pass` written into a
|
||||||
|
* comment or an envelope address is not a result.
|
||||||
|
*/
|
||||||
|
export function parseAuthenticationResults(headers: string | readonly string[]): AuthenticationResults {
|
||||||
const results: AuthenticationResults = {};
|
const results: AuthenticationResults = {};
|
||||||
|
const list = typeof headers === 'string' ? [headers] : headers;
|
||||||
|
const perHeader = list.map(parseResinfos);
|
||||||
|
const own = perHeader[0] ?? [];
|
||||||
|
const foreign = perHeader.slice(1).flat();
|
||||||
|
|
||||||
// A single header can carry more than one SPF result when the server
|
// A single header can carry more than one SPF result when the server
|
||||||
// evaluates multiple identities (HELO and MAIL FROM). Collect them all so a
|
// evaluates multiple identities (HELO and MAIL FROM). Collect them all so a
|
||||||
// hard fail on any identity isn't softened to an ambiguous state recorded
|
// hard fail on any identity isn't softened to an ambiguous state recorded
|
||||||
// for another one.
|
// for another one.
|
||||||
const spfRegex = /spf=(\w+)(?:\s+\([^)]*\))?(?:\s+smtp\.(mailfrom|helo)=([^\s;]+))?/g;
|
const severity = (r: string) => SPF_SEVERITY[r as SpfResult] ?? -1;
|
||||||
const spfResults: SpfEntry[] = [];
|
const isFailure = (r: string) => severity(r) >= SPF_SEVERITY.temperror;
|
||||||
let spfM: RegExpExecArray | null;
|
const toSpfEntry = (info: ResInfo): SpfEntry => {
|
||||||
while ((spfM = spfRegex.exec(header)) !== null) {
|
const identity = info.props['smtp.mailfrom'] !== undefined
|
||||||
spfResults.push({
|
? 'mailfrom'
|
||||||
result: spfM[1] as SpfResult,
|
: info.props['smtp.helo'] !== undefined ? 'helo' : undefined;
|
||||||
identity: spfM[2] as SpfEntry['identity'],
|
return {
|
||||||
domain: spfM[3],
|
result: info.result as SpfResult,
|
||||||
});
|
identity,
|
||||||
}
|
domain: identity ? info.props[`smtp.${identity}`] || undefined : undefined,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const spfResults: SpfEntry[] = [
|
||||||
|
...own.filter((info) => info.method === 'spf').map(toSpfEntry),
|
||||||
|
...foreign.filter((info) => info.method === 'spf').map(toSpfEntry).filter((e) => isFailure(e.result)),
|
||||||
|
];
|
||||||
if (spfResults.length > 0) {
|
if (spfResults.length > 0) {
|
||||||
const severity = (r: string) => SPF_SEVERITY[r as SpfResult] ?? -1;
|
|
||||||
// MAIL FROM is the primary SPF identity. Another identity (HELO) may only
|
// MAIL FROM is the primary SPF identity. Another identity (HELO) may only
|
||||||
// escalate the headline to a genuine failure state - a HELO `none` or
|
// escalate the headline to a genuine failure state - a HELO `none` or
|
||||||
// `neutral` must not downgrade a MAIL FROM `pass`.
|
// `neutral` must not downgrade a MAIL FROM `pass`.
|
||||||
const isFailure = (r: string) => severity(r) >= SPF_SEVERITY.temperror;
|
|
||||||
let primary = spfResults.find((e) => e.identity === 'mailfrom') ?? spfResults[0];
|
let primary = spfResults.find((e) => e.identity === 'mailfrom') ?? spfResults[0];
|
||||||
for (const cur of spfResults) {
|
for (const cur of spfResults) {
|
||||||
if (isFailure(cur.result) && severity(cur.result) > severity(primary.result)) {
|
if (isFailure(cur.result) && severity(cur.result) > severity(primary.result)) {
|
||||||
@@ -140,29 +352,44 @@ export function parseAuthenticationResults(header: string): AuthenticationResult
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const dkimMatch = header.match(/dkim=(\w+)(?:\s+header\.d=([^\s;]+))?(?:\s+header\.s=([^\s;]+))?/);
|
// A message can carry several signatures (the author's domain and the
|
||||||
if (dkimMatch) {
|
// sending service's). The first one stays the headline; keep them all so
|
||||||
|
// a pass further down still counts.
|
||||||
|
const dkimResults: DkimEntry[] = 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 = {
|
results.dkim = {
|
||||||
result: dkimMatch[1] as DkimResult,
|
...dkimResults[0],
|
||||||
domain: dkimMatch[2],
|
...(dkimResults.length > 1 ? { all: dkimResults } : {}),
|
||||||
selector: dkimMatch[3],
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const dmarcMatch = header.match(/dmarc=(\w+)(?:\s+header\.from=([^\s;]+))?(?:\s+policy\.dmarc=(\w+))?/);
|
// One DMARC verdict per message; should a header carry several, the most
|
||||||
if (dmarcMatch) {
|
// severe stands.
|
||||||
|
const dmarc = own
|
||||||
|
.filter((info) => info.method === 'dmarc')
|
||||||
|
.reduce<ResInfo | undefined>(
|
||||||
|
(worst, info) => (!worst || (DMARC_SEVERITY[info.result] ?? -1) > (DMARC_SEVERITY[worst.result] ?? -1) ? info : worst),
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
if (dmarc) {
|
||||||
results.dmarc = {
|
results.dmarc = {
|
||||||
result: dmarcMatch[1] as DmarcResult,
|
result: dmarc.result as DmarcResult,
|
||||||
domain: dmarcMatch[2],
|
domain: dmarc.props['header.from'],
|
||||||
policy: dmarcMatch[3] as DmarcPolicy | undefined,
|
policy: dmarc.props['policy.dmarc'] as DmarcPolicy | undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const iprevMatch = header.match(/iprev=(\w+)(?:\s+policy\.iprev=([\d.]+))?/);
|
const iprev = own.find((info) => info.method === 'iprev');
|
||||||
if (iprevMatch) {
|
if (iprev) {
|
||||||
results.iprev = {
|
results.iprev = {
|
||||||
result: iprevMatch[1] as 'pass' | 'fail',
|
result: iprev.result as 'pass' | 'fail',
|
||||||
ip: iprevMatch[2],
|
ip: iprev.props['policy.iprev'],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,8 +488,8 @@ export function deriveHeaderInfo(email: Pick<Email, 'headers' | 'messageId'>): E
|
|||||||
const headers = email.headers;
|
const headers = email.headers;
|
||||||
const authHeaders = headerValues(headers, 'Authentication-Results');
|
const authHeaders = headerValues(headers, 'Authentication-Results');
|
||||||
// The last hop's results are prepended, so the first header is the
|
// The last hop's results are prepended, so the first header is the
|
||||||
// receiving server's own verdict.
|
// receiving server's own verdict; the parser trusts only that one.
|
||||||
const auth = authHeaders.length ? parseAuthenticationResults(authHeaders.join('; ')) : undefined;
|
const auth = authHeaders.length ? parseAuthenticationResults(authHeaders) : undefined;
|
||||||
|
|
||||||
const spamRaw = headerValue(headers, 'X-Spam-Status')
|
const spamRaw = headerValue(headers, 'X-Spam-Status')
|
||||||
?? headerValue(headers, 'X-Spam-Result')
|
?? headerValue(headers, 'X-Spam-Result')
|
||||||
|
|||||||
Reference in New Issue
Block a user