mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 00:14:23 +00:00
feat: offer the ARF report format for phishing reports
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Email } from '@/lib/jmap/types';
|
||||
import { DEFAULT_POLICY } from '@/lib/admin/types';
|
||||
import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { useToastStore } from '@/stores/toast-store';
|
||||
import { usePhishingReportStore } from '@/stores/phishing-report-store';
|
||||
|
||||
// The report flow through the real dialog: it names the address, Cancel
|
||||
// sends nothing, each message gets its own report, each moves to Junk with
|
||||
@@ -27,6 +28,7 @@ const hoisted = vi.hoisted(() => {
|
||||
copyBlobs: async () => ({}),
|
||||
sendEmail: vi.fn(async () => ({ scheduled: false })),
|
||||
submitRawEmail: vi.fn(async () => ({ scheduled: false })),
|
||||
fetchBlobArrayBuffer: vi.fn(async () => new TextEncoder().encode('Subject: x' + String.fromCharCode(13, 10, 13, 10) + 'hi').buffer),
|
||||
markAsSpam: vi.fn(async () => {}),
|
||||
undoSpam: vi.fn(async () => {}),
|
||||
};
|
||||
@@ -54,9 +56,9 @@ vi.mock('@/stores/email-store', () => ({
|
||||
const { PhishingReportHost } = await import('../phishing-report-host');
|
||||
const { requestPhishingReport } = await import('@/hooks/use-phishing-report');
|
||||
|
||||
function setAddress(address: string) {
|
||||
function setAddress(address: string, format: 'forward' | 'arf' = 'forward') {
|
||||
usePolicyStore.setState({
|
||||
policy: { ...DEFAULT_POLICY, organisation: { ...DEFAULT_ORGANISATION_POLICY, phishingReport: { address, format: 'forward' } } },
|
||||
policy: { ...DEFAULT_POLICY, organisation: { ...DEFAULT_ORGANISATION_POLICY, phishingReport: { address, format } } },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -68,6 +70,7 @@ afterEach(() => {
|
||||
cleanup();
|
||||
usePolicyStore.setState({ policy: DEFAULT_POLICY });
|
||||
useToastStore.setState({ toasts: [] });
|
||||
usePhishingReportStore.setState({ pending: null, busy: false, opener: null });
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
@@ -127,6 +130,18 @@ describe('Report phishing', () => {
|
||||
expect(hoisted.client.undoSpam).toHaveBeenCalledWith('e1', 'inbox', undefined, ['$phishing']);
|
||||
});
|
||||
|
||||
it('sends an ARF report raw when the admin picked ARF', async () => {
|
||||
setAddress('abuse@example.org', 'arf');
|
||||
render(<PhishingReportHost />);
|
||||
act(() => requestPhishingReport([e1], 'inbox'));
|
||||
await act(async () => { fireEvent.click(screen.getByTestId('phishing-report-confirm')); });
|
||||
await waitFor(() => expect(hoisted.client.submitRawEmail).toHaveBeenCalledTimes(1));
|
||||
expect(hoisted.client.sendEmail).not.toHaveBeenCalled();
|
||||
const raw = await ((hoisted.client.submitRawEmail.mock.calls[0] as unknown[])[0] as Blob).text();
|
||||
expect(raw).toContain('report-type=feedback-report');
|
||||
await waitFor(() => expect(hoisted.store.markAsSpam).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it('skips messages of your own folders', () => {
|
||||
render(<PhishingReportHost />);
|
||||
act(() => requestPhishingReport([e1], 'sent'));
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { Email, Identity } from '@/lib/jmap/types';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
import { buildArfParts, buildArfReportBlob } from '../phishing-report-arf';
|
||||
import { sendPhishingReport } from '../phishing-report';
|
||||
|
||||
// The ARF report (RFC 5965): multipart/report with report-type
|
||||
// feedback-report, a text part, message/feedback-report with
|
||||
// Feedback-Type: fraud, User-Agent and Version: 1, and the original whole
|
||||
// as message/rfc822; submitted raw without a Sent copy.
|
||||
|
||||
const email = {
|
||||
id: 'e1', blobId: 'blob-1', size: 40, subject: 'Konto gesperrt – jetzt prüfen', receivedAt: '2026-10-01T10:00:00Z',
|
||||
from: [{ email: 'bad@phish.example' }], keywords: {}, mailboxIds: { inbox: true },
|
||||
} as unknown as Email;
|
||||
const reporter = { id: 'id-me', name: 'Alice', email: 'alice@example.org' } as Identity;
|
||||
const ORIGINAL = 'From: bad@phish.example\r\nSubject: x\r\n\r\nclick here\r\n';
|
||||
|
||||
describe('buildArfParts', () => {
|
||||
it('has the three parts and the required feedback fields', () => {
|
||||
const { head, tail } = buildArfParts({ email, reporter, to: 'abuse@example.org', eightBit: false, now: new Date('2026-11-01T08:00:00Z') });
|
||||
expect(head).toMatch(/^Date: Sun, 01 Nov 2026 08:00:00 \+0000\r\n/);
|
||||
expect(head).toContain('From: Alice <alice@example.org>\r\n');
|
||||
expect(head).toContain('To: abuse@example.org\r\n');
|
||||
// Non-ASCII subject as an encoded word, after "Phishing:".
|
||||
expect(head).toMatch(/\r\nSubject: =\?UTF-8\?B\?[A-Za-z0-9+/=]+\?=\r\n/);
|
||||
const subject = /Subject: =\?UTF-8\?B\?([^?]+)\?=/.exec(head)![1];
|
||||
expect(new TextDecoder().decode(Uint8Array.from(atob(subject), (c) => c.charCodeAt(0)))).toBe('Phishing: Konto gesperrt – jetzt prüfen');
|
||||
expect(head).toContain('Content-Type: multipart/report; report-type=feedback-report;');
|
||||
const boundary = /boundary="([^"]+)"/.exec(head)![1];
|
||||
expect(head.split(`--${boundary}\r\n`)).toHaveLength(4);
|
||||
expect(head).toContain('Content-Type: message/feedback-report\r\n');
|
||||
expect(head).toContain('\r\nFeedback-Type: fraud\r\n');
|
||||
expect(head).toMatch(/\r\nUser-Agent: Bulwark\/\S+\r\n/);
|
||||
expect(head).toContain('\r\nVersion: 1\r\n');
|
||||
expect(head).toContain('\r\nArrival-Date: Thu, 01 Oct 2026 10:00:00 +0000\r\n');
|
||||
expect(head).toContain('\r\nOriginal-Rcpt-To: alice@example.org\r\n');
|
||||
expect(head).toContain('\r\nReported-Domain: phish.example\r\n');
|
||||
expect(head).toMatch(/Content-Type: message\/rfc822\r\n[\s\S]*Content-Transfer-Encoding: 7bit\r\n\r\n$/);
|
||||
expect(tail).toBe(`\r\n--${boundary}--\r\n`);
|
||||
});
|
||||
|
||||
it('marks an 8-bit original as 8bit, never base64', () => {
|
||||
const { head } = buildArfParts({ email, reporter, to: 'abuse@example.org', eightBit: true });
|
||||
expect(head).toMatch(/Content-Transfer-Encoding: 8bit\r\n\r\n$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('an ARF report', () => {
|
||||
it('carries the original byte for byte and is submitted raw, from the original account', async () => {
|
||||
const client = {
|
||||
getIdentities: vi.fn(async () => [reporter]),
|
||||
getUsername: () => 'alice@example.org',
|
||||
getAccountId: () => 'acc-own',
|
||||
fetchBlobArrayBuffer: vi.fn(async () => new TextEncoder().encode(ORIGINAL).buffer),
|
||||
submitRawEmail: vi.fn(async (..._args: unknown[]) => ({ scheduled: false })),
|
||||
sendEmail: vi.fn(),
|
||||
};
|
||||
await sendPhishingReport(email, {
|
||||
client: client as unknown as IJMAPClient,
|
||||
sourceAccountId: 'acc-group',
|
||||
setup: { address: 'abuse@example.org', format: 'arf' },
|
||||
buildArf: buildArfReportBlob,
|
||||
});
|
||||
expect(client.fetchBlobArrayBuffer).toHaveBeenCalledWith('blob-1', 'message.eml', 'message/rfc822', 'acc-group');
|
||||
expect(client.sendEmail).not.toHaveBeenCalled();
|
||||
const [blob, identityId, delayed, envelope] = client.submitRawEmail.mock.calls[0] as [Blob, string, unknown, string[]];
|
||||
expect(identityId).toBe('id-me');
|
||||
expect(delayed).toBeUndefined();
|
||||
expect(envelope).toEqual(['abuse@example.org']);
|
||||
const text = await blob.text();
|
||||
const boundary = /boundary="([^"]+)"/.exec(text)![1];
|
||||
expect(text).toContain(`message/rfc822\r\nContent-Disposition: attachment; filename="reported-message.eml"\r\nContent-Transfer-Encoding: 7bit\r\n\r\n${ORIGINAL}--${boundary}--\r\n`);
|
||||
});
|
||||
});
|
||||
+7
-7
@@ -40,7 +40,7 @@ const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||
|
||||
/** RFC 5322 date in UTC, e.g. "Thu, 28 May 2026 14:23:00 +0000". */
|
||||
function rfc5322Date(d: Date = new Date()): string {
|
||||
export function rfc5322Date(d: Date = new Date()): string {
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${DAYS[d.getUTCDay()]}, ${pad(d.getUTCDate())} ${MONTHS[d.getUTCMonth()]} ${d.getUTCFullYear()} ` +
|
||||
`${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())} +0000`;
|
||||
@@ -55,7 +55,7 @@ function utf8ToBase64(value: string): string {
|
||||
}
|
||||
|
||||
/** UTF-8 base64 body, wrapped at 76 chars per RFC 2045. */
|
||||
function base64Body(text: string): string {
|
||||
export function base64Body(text: string): string {
|
||||
return (utf8ToBase64(text).match(/.{1,76}/g) || []).join("\r\n");
|
||||
}
|
||||
|
||||
@@ -71,13 +71,13 @@ function base64Body(text: string): string {
|
||||
* append headers and a body of their choosing to a message the victim's own
|
||||
* account submits (GHSA-w38p-hpqv-g89c).
|
||||
*/
|
||||
function stripHeaderControls(value: string): string {
|
||||
export function stripHeaderControls(value: string): string {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return value.replace(/[\x00-\x1F\x7F]+/g, " ").trim();
|
||||
}
|
||||
|
||||
/** Address-like atoms (mailboxes, message-ids) may contain no whitespace at all. */
|
||||
function headerAtom(value: string): string {
|
||||
export function headerAtom(value: string): string {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return value.replace(/[\x00-\x20\x7F]+/g, "");
|
||||
}
|
||||
@@ -86,14 +86,14 @@ function headerAtom(value: string): string {
|
||||
* Header value safe to place after "Name: ". Control characters are stripped
|
||||
* first; the result is RFC 2047-encoded when it contains non-ASCII.
|
||||
*/
|
||||
function encodeHeaderWord(value: string): string {
|
||||
export function encodeHeaderWord(value: string): string {
|
||||
const clean = stripHeaderControls(value);
|
||||
if (!/[^\x20-\x7E]/.test(clean)) return clean;
|
||||
return `=?UTF-8?B?${utf8ToBase64(clean)}?=`;
|
||||
}
|
||||
|
||||
/** Display-name for a mailbox: encoded-word if non-ASCII, quoted if it uses specials. */
|
||||
function displayName(value: string): string {
|
||||
export function displayName(value: string): string {
|
||||
const encoded = encodeHeaderWord(value);
|
||||
if (encoded.startsWith("=?") || !/[()<>[\]:;@\\,."]/.test(encoded)) return encoded;
|
||||
return `"${encoded.replace(/["\\]/g, "\\$&")}"`;
|
||||
@@ -109,7 +109,7 @@ function ensureAngles(messageId: string | string[] | undefined): string {
|
||||
return trimmed.startsWith("<") ? trimmed : `<${trimmed}>`;
|
||||
}
|
||||
|
||||
function randomToken(): string {
|
||||
export function randomToken(): string {
|
||||
const rnd = Math.random().toString(36).slice(2);
|
||||
return `${Date.now().toString(36)}.${rnd}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* The ARF format of a phishing report (RFC 5965, an admin option of
|
||||
* Organisation > Security > Phishing reports): a multipart/report with
|
||||
* report-type=feedback-report and three parts, a short text for people, the
|
||||
* machine-readable message/feedback-report (Feedback-Type: fraud, which
|
||||
* RFC 5965 §7.3 defines as "fraud or phishing"), and the original whole and
|
||||
* unchanged as message/rfc822.
|
||||
*
|
||||
* JMAP can't set the report-type parameter through Email/set, so this is
|
||||
* raw MIME: the original's bytes are downloaded, wrapped and uploaded, then
|
||||
* submitted with submitRawEmail (no Sent copy). The upload counts against
|
||||
* the server's upload quota and is limited by maxSizeUpload.
|
||||
*/
|
||||
|
||||
import type { Email } from '@/lib/jmap/types';
|
||||
import { base64Body, displayName, encodeHeaderWord, headerAtom, randomToken, rfc5322Date } from '@/lib/mdn';
|
||||
import { reportSubject, type ArfInput } from './phishing-report';
|
||||
|
||||
export interface ArfParts {
|
||||
/** Everything before the original's bytes. */
|
||||
head: string;
|
||||
/** Everything after them. */
|
||||
tail: string;
|
||||
}
|
||||
|
||||
function appVersion(): string {
|
||||
return (process.env.NEXT_PUBLIC_APP_VERSION || '').trim() || 'unknown';
|
||||
}
|
||||
|
||||
/** "Reported-Domain": the domain of the original's From, when it has one. */
|
||||
function senderDomain(email: Pick<Email, 'from'>): string | null {
|
||||
const address = email.from?.[0]?.email ?? '';
|
||||
const domain = address.slice(address.lastIndexOf('@') + 1);
|
||||
return address.includes('@') && /^[A-Za-z0-9.-]+$/.test(domain) ? domain.toLowerCase() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The report around the original. `eightBit`: the original has bytes
|
||||
* outside 7-bit ASCII (message/rfc822 may not be base64-encoded, RFC 2046
|
||||
* §5.2.1, so its part says 8bit then).
|
||||
*/
|
||||
export function buildArfParts(input: {
|
||||
email: Pick<Email, 'subject' | 'receivedAt' | 'from' | 'messageId'>;
|
||||
reporter: { name?: string; email: string };
|
||||
to: string;
|
||||
eightBit: boolean;
|
||||
now?: Date;
|
||||
}): ArfParts {
|
||||
const { email, reporter } = input;
|
||||
const fromEmail = headerAtom(reporter.email);
|
||||
const domain = fromEmail.split('@')[1] || 'localhost';
|
||||
const boundary = `----=_ARF_${randomToken()}`;
|
||||
const fromName = reporter.name?.trim() ? displayName(reporter.name.trim()) : '';
|
||||
const arrival = email.receivedAt ? new Date(email.receivedAt) : null;
|
||||
const reportedDomain = senderDomain(email);
|
||||
const human = [
|
||||
`This is a phishing report (RFC 5965) sent by ${fromEmail} with Bulwark.`,
|
||||
'',
|
||||
'The reported message is attached unchanged.',
|
||||
].join('\r\n');
|
||||
const report = [
|
||||
'Feedback-Type: fraud',
|
||||
`User-Agent: Bulwark/${appVersion()}`,
|
||||
'Version: 1',
|
||||
...(arrival && !Number.isNaN(arrival.getTime()) ? [`Arrival-Date: ${rfc5322Date(arrival)}`] : []),
|
||||
`Original-Rcpt-To: ${fromEmail}`,
|
||||
...(reportedDomain ? [`Reported-Domain: ${reportedDomain}`] : []),
|
||||
].join('\r\n');
|
||||
|
||||
const head = [
|
||||
`Date: ${rfc5322Date(input.now ?? new Date())}`,
|
||||
`From: ${fromName ? `${fromName} <${fromEmail}>` : fromEmail}`,
|
||||
`To: ${headerAtom(input.to)}`,
|
||||
`Subject: ${encodeHeaderWord(reportSubject(email.subject))}`,
|
||||
`Message-ID: <arf.${randomToken()}@${domain}>`,
|
||||
'MIME-Version: 1.0',
|
||||
'Content-Type: multipart/report; report-type=feedback-report;',
|
||||
`\tboundary="${boundary}"`,
|
||||
'',
|
||||
`--${boundary}`,
|
||||
'Content-Type: text/plain; charset=utf-8',
|
||||
'Content-Transfer-Encoding: base64',
|
||||
'',
|
||||
base64Body(human),
|
||||
'',
|
||||
`--${boundary}`,
|
||||
'Content-Type: message/feedback-report',
|
||||
'Content-Transfer-Encoding: 7bit',
|
||||
'',
|
||||
report,
|
||||
'',
|
||||
`--${boundary}`,
|
||||
'Content-Type: message/rfc822',
|
||||
'Content-Disposition: attachment; filename="reported-message.eml"',
|
||||
`Content-Transfer-Encoding: ${input.eightBit ? '8bit' : '7bit'}`,
|
||||
'',
|
||||
'',
|
||||
].join('\r\n');
|
||||
const tail = `\r\n--${boundary}--\r\n`;
|
||||
return { head, tail };
|
||||
}
|
||||
|
||||
function hasEightBit(bytes: Uint8Array): boolean {
|
||||
for (let i = 0; i < bytes.length; i++) if (bytes[i] > 0x7f) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** The whole report as a Blob, the original downloaded from its own account. */
|
||||
export async function buildArfReportBlob(input: ArfInput): Promise<Blob> {
|
||||
const { client, email, sourceAccountId } = input;
|
||||
if (!email.blobId) throw new Error('The message has no content to report');
|
||||
const original = new Uint8Array(await client.fetchBlobArrayBuffer(email.blobId, 'message.eml', 'message/rfc822', sourceAccountId));
|
||||
const parts = buildArfParts({ email, reporter: input.reporter, to: input.to, eightBit: hasEightBit(original) });
|
||||
// The original goes in byte for byte; the closing boundary needs a line break before it.
|
||||
const endsWithBreak = original.length >= 2 && original[original.length - 2] === 0x0d && original[original.length - 1] === 0x0a;
|
||||
return new Blob([parts.head, original, endsWithBreak ? parts.tail.slice(2) : parts.tail], { type: 'message/rfc822' });
|
||||
}
|
||||
@@ -51,9 +51,10 @@ export interface PhishingReportOutcome {
|
||||
moved: MovedReport[];
|
||||
}
|
||||
|
||||
// ARF arrives in the next step; until then an 'arf' setup fails the report.
|
||||
async function loadArfBuilder(): Promise<((input: ArfInput) => Promise<Blob>) | undefined> {
|
||||
return undefined;
|
||||
// Loaded only when an admin picked the ARF format.
|
||||
async function loadArfBuilder(): Promise<(input: ArfInput) => Promise<Blob>> {
|
||||
const { buildArfReportBlob } = await import('./phishing-report-arf');
|
||||
return buildArfReportBlob;
|
||||
}
|
||||
|
||||
/** The folder a message is in, for a move back that the store can't do. */
|
||||
|
||||
Reference in New Issue
Block a user