From 6262e58ef9b41957128a2840e4e7032edf43dbcf Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 1 Nov 2026 05:02:32 +0100 Subject: [PATCH] feat: offer the ARF report format for phishing reports --- .../__tests__/phishing-report-flow.test.tsx | 21 +++- lib/__tests__/phishing-report-arf.test.ts | 75 +++++++++++ lib/mdn.ts | 14 +-- lib/phishing-report-arf.ts | 117 ++++++++++++++++++ lib/phishing-report-flow.ts | 7 +- 5 files changed, 221 insertions(+), 13 deletions(-) create mode 100644 lib/__tests__/phishing-report-arf.test.ts create mode 100644 lib/phishing-report-arf.ts diff --git a/components/email/__tests__/phishing-report-flow.test.tsx b/components/email/__tests__/phishing-report-flow.test.tsx index 7802efea7..9d1b9f4e6 100644 --- a/components/email/__tests__/phishing-report-flow.test.tsx +++ b/components/email/__tests__/phishing-report-flow.test.tsx @@ -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(); + 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(); act(() => requestPhishingReport([e1], 'sent')); diff --git a/lib/__tests__/phishing-report-arf.test.ts b/lib/__tests__/phishing-report-arf.test.ts new file mode 100644 index 000000000..0e0a811ba --- /dev/null +++ b/lib/__tests__/phishing-report-arf.test.ts @@ -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 \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`); + }); +}); diff --git a/lib/mdn.ts b/lib/mdn.ts index 5e05ab0d6..c255e4944 100644 --- a/lib/mdn.ts +++ b/lib/mdn.ts @@ -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}`; } diff --git a/lib/phishing-report-arf.ts b/lib/phishing-report-arf.ts new file mode 100644 index 000000000..e2424778f --- /dev/null +++ b/lib/phishing-report-arf.ts @@ -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): 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; + 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: `, + '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 { + 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' }); +} diff --git a/lib/phishing-report-flow.ts b/lib/phishing-report-flow.ts index 0ac3bdf8b..b894c525a 100644 --- a/lib/phishing-report-flow.ts +++ b/lib/phishing-report-flow.ts @@ -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) | undefined> { - return undefined; +// Loaded only when an admin picked the ARF format. +async function loadArfBuilder(): Promise<(input: ArfInput) => Promise> { + 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. */