mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-01 16:12:02 +00:00
fix: keep plugin raw sends from dropping or forging the classification label
Raw sends (api.jmap.sendRaw and submitRaw) now always lose label headers a plugin wrote while the organisation uses labels. A crypto takeover is matched to its raw send by a labelToken the composer puts in the onComposeSend request (plugins that don't pass it back are matched by identity, and refused when two takeovers are open), not by comparing subjects. A raw send outside a takeover gets the default label, or is refused when a label is required. The extra header is folded on the raw path. The takeover code moves to lib/outgoing/classification-raw.ts; the old test that let a raw send outside a takeover through unlabelled now expects the forged header to be removed.
This commit is contained in:
@@ -111,7 +111,8 @@ import type { EmailTemplate } from "@/lib/template-types";
|
||||
import { appendPlainTextSignature, getPlainTextSignature, plainTextBodyHasSignature, plainTextBodyWithoutSignature } from "@/lib/signature-utils";
|
||||
import { wantsSignatureSeparator } from "@/lib/signature-delimiter";
|
||||
import { finalizeOutgoing, runPreSendChecks, type OutgoingContext } from "@/lib/outgoing/send-pipeline";
|
||||
import { beginLabelTakeover, classifyOutgoing, endLabelTakeover, isLabelRequiredError, labelMissing, labelsPolicyFor, resolveLabel, type OriginalMessage } from "@/lib/outgoing/classification";
|
||||
import { classifyOutgoing, isLabelRequiredError, labelMissing, labelsPolicyFor, resolveLabel, type OriginalMessage } from "@/lib/outgoing/classification";
|
||||
import { beginLabelTakeover, endLabelTakeover } from "@/lib/outgoing/classification-raw";
|
||||
import { whenPolicySettled } from "@/stores/policy-store";
|
||||
import { ClassificationLabelPicker, type ClassificationPickerHandle } from "./classification-label-picker";
|
||||
import { useComposerClassification } from "./use-composer-classification";
|
||||
@@ -3647,9 +3648,12 @@ export function EmailComposer({
|
||||
const labelPolicy = labelsPolicyFor({ email: currentIdentity?.email, accountId: composingAccountId });
|
||||
const sendLabel = resolveLabel(labelPolicy, classificationRef.current.choice, labelOriginal);
|
||||
// A crypto plugin builds the MIME itself: it gets the labelled
|
||||
// subject, and its raw send gets the label's headers (keyed to the token).
|
||||
// subject, and its raw send gets the label's headers, matched by the
|
||||
// token it can pass back (lib/outgoing/classification-raw.ts).
|
||||
const takeoverLabelled = classifyOutgoing(labelPolicy, sendLabel, { subject });
|
||||
const labelToken = beginLabelTakeover(labelPolicy, sendLabel, currentIdentity?.id ?? '');
|
||||
const composeSendRequest = {
|
||||
labelToken: labelToken ?? undefined,
|
||||
to: toAddresses.map(r => formatRecipient(r.name, r.email)),
|
||||
cc: ccAddresses.map(r => formatRecipient(r.name, r.email)),
|
||||
bcc: bccAddresses.map(r => formatRecipient(r.name, r.email)),
|
||||
@@ -3669,7 +3673,6 @@ export function EmailComposer({
|
||||
...inlineAttachments.map(a => ({ name: a.name, type: a.type, size: a.size, blobId: a.blobId, cid: a.cid })),
|
||||
],
|
||||
};
|
||||
const labelToken = beginLabelTakeover(labelPolicy, sendLabel, currentIdentityParts.rawId ?? currentIdentity?.id ?? '', takeoverLabelled.subject);
|
||||
let pluginOutcome: Awaited<ReturnType<typeof emailHooks.onComposeSend.takeOver>>;
|
||||
try {
|
||||
pluginOutcome = await emailHooks.onComposeSend.takeOver(composeSendRequest);
|
||||
|
||||
@@ -13,7 +13,7 @@ import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
|
||||
import { LABEL_PRESETS } from '@/lib/classification/presets';
|
||||
import { head, resolveHead, resetDialogLimits } from '../plugin-sandbox/host-dialog';
|
||||
import { resetPluginRateLimits } from '../plugin-sandbox/host-api-input';
|
||||
import { beginLabelTakeover, endLabelTakeover, resetLabelTakeovers } from '../outgoing/classification';
|
||||
import { beginLabelTakeover, endLabelTakeover, resetLabelTakeovers } from '../outgoing/classification-raw';
|
||||
|
||||
// Classification labels on the plugin paths: api.email.forward carries the
|
||||
// original's label (else the default) and refuses when a required one is
|
||||
@@ -123,18 +123,28 @@ describe('labels on the plugin paths', () => {
|
||||
expect(toast.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('puts the label into a raw send during a labelled takeover only', async () => {
|
||||
it('puts the takeover’s label into its raw sends and takes forged ones out of every raw send', async () => {
|
||||
useLabels({});
|
||||
const raw = new TextEncoder().encode('From: ann@example.com\r\nSubject: [Confidential] Hi\r\n\r\nENCRYPTED\r\n');
|
||||
await call(SMIME, 'jmap.sendRaw', raw, 'id-a', {});
|
||||
const forged = new TextEncoder().encode('From: ann@example.com\r\nSubject: [Confidential] Hi\r\nSIO-Label: marking="PUBLIC"\r\n\r\nENCRYPTED\r\n');
|
||||
// Outside a takeover, no default label: nothing stamped, the forged header gone.
|
||||
await call(SMIME, 'jmap.sendRaw', forged, 'id-a', {});
|
||||
expect(await (sendRawEmail.mock.calls[0][1] as Blob).text()).not.toContain('SIO-Label');
|
||||
|
||||
const token = beginLabelTakeover(usePolicyStore.getState().policy.organisation!.labels, LABEL_PRESETS[0].list[2], 'id-a', '[Confidential] Hi');
|
||||
await call(SMIME, 'jmap.sendRaw', raw, 'id-a', {});
|
||||
await call(SMIME, 'jmap.submitRaw', raw, 'id-a', {});
|
||||
const token = beginLabelTakeover(usePolicyStore.getState().policy.organisation!.labels, LABEL_PRESETS[0].list[2], 'id-a')!;
|
||||
await call(SMIME, 'jmap.sendRaw', forged, 'id-a', { labelToken: token });
|
||||
await call(SMIME, 'jmap.submitRaw', forged, 'id-a', { labelToken: token });
|
||||
endLabelTakeover(token);
|
||||
const sent = await (sendRawEmail.mock.calls[1][1] as Blob).text();
|
||||
expect(sent).toMatch(/^From: ann@example.com\r\nSubject: \[Confidential\] Hi\r\nSIO-Label: marking="CONFIDENTIAL"[^\r]*\r\nSensitivity: Company-Confidential\r\n\r\nENCRYPTED\r\n$/);
|
||||
expect(await (client.submitRawEmail.mock.calls[0][0] as Blob).text()).toContain('SIO-Label: marking="CONFIDENTIAL"');
|
||||
});
|
||||
|
||||
it('refuses a raw send outside the composer when a label is required', async () => {
|
||||
useLabels({ required: true });
|
||||
const bytes = new TextEncoder().encode('Subject: Hi\r\n\r\nX\r\n');
|
||||
await expect(call(SMIME, 'jmap.sendRaw', bytes, 'id-a', {})).rejects.toThrow(/requires a classification label/);
|
||||
await expect(call(SMIME, 'jmap.submitRaw', bytes, 'id-a', {})).rejects.toThrow(/requires a classification label/);
|
||||
expect(sendRawEmail).not.toHaveBeenCalled();
|
||||
expect(client.submitRawEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -64,7 +64,7 @@ function percentEncode(value: string): string {
|
||||
/** The marking parameter(s): quoted when ASCII, RFC 2231 (with continuations when long) otherwise. */
|
||||
function markingParameters(marking: string): string[] {
|
||||
const clean = marking.replace(/["\\\r\n]/g, '');
|
||||
|
||||
|
||||
if (/^[\x20-\x7e]*$/.test(clean)) return [`marking="${clean}"`];
|
||||
const encoded = percentEncode(clean);
|
||||
const MAX = 60;
|
||||
@@ -122,18 +122,50 @@ export function labelHeaderProperties(label: ClassificationLabel, extraHeader: s
|
||||
return out;
|
||||
}
|
||||
|
||||
function encodedWord(value: string): string {
|
||||
|
||||
if (/^[\x20-\x7e]*$/.test(value)) return value;
|
||||
let binary = '';
|
||||
for (const byte of new TextEncoder().encode(value)) binary += String.fromCharCode(byte);
|
||||
return `=?UTF-8?B?${btoa(binary)}?=`;
|
||||
/**
|
||||
* A header value for raw MIME, folded under 78 columns: ASCII as it is,
|
||||
* folded between words; anything else as RFC 2047 encoded words of at
|
||||
* most 75 characters each, split between characters.
|
||||
*/
|
||||
function foldedValue(name: string, value: string): string {
|
||||
if (/^[\x20-\x7e]*$/.test(value)) {
|
||||
let line = name.length + 1;
|
||||
let out = '';
|
||||
for (const word of value.split(' ')) {
|
||||
if (out && line + 1 + word.length > 78) {
|
||||
out += `\r\n ${word}`;
|
||||
line = 1 + word.length;
|
||||
} else {
|
||||
out += ` ${word}`;
|
||||
line += 1 + word.length;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const encoder = new TextEncoder();
|
||||
const words: string[] = [];
|
||||
let chunk: number[] = [];
|
||||
const flush = () => {
|
||||
if (chunk.length === 0) return;
|
||||
words.push(`=?UTF-8?B?${btoa(String.fromCharCode(...chunk))}?=`);
|
||||
chunk = [];
|
||||
};
|
||||
// 45 bytes are 60 base64 characters: 72 with "=?UTF-8?B?" and "?=",
|
||||
// which fits a folded line. The first word shares its line with the name.
|
||||
const firstLimit = Math.max(3, Math.min(45, Math.floor((78 - name.length - 2 - 12) / 4) * 3));
|
||||
for (const char of value) {
|
||||
const bytes = encoder.encode(char);
|
||||
if (chunk.length + bytes.length > (words.length === 0 ? firstLimit : 45)) flush();
|
||||
chunk.push(...bytes);
|
||||
}
|
||||
flush();
|
||||
return ` ${words.join('\r\n ')}`;
|
||||
}
|
||||
|
||||
/** The label's headers as raw header lines (CRLF-free each), for raw MIME. */
|
||||
/** The label's headers as raw header lines (folded with CRLF and a space), for raw MIME. */
|
||||
export function labelHeaderLines(label: ClassificationLabel, extraHeader: string): string[] {
|
||||
return labelHeaderPairs(label, extraHeader).map(([name, value, form]) =>
|
||||
form === 'raw' ? `${name}:${value}` : `${name}: ${encodedWord(value)}`);
|
||||
form === 'raw' ? `${name}:${value}` : `${name}:${foldedValue(name, value)}`);
|
||||
}
|
||||
|
||||
/** The draft marker property: the label id, or `none` for no label. */
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { DEFAULT_POLICY } from '@/lib/admin/types';
|
||||
import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
|
||||
import { LABEL_PRESETS } from '@/lib/classification/presets';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { beginLabelTakeover, endLabelTakeover, labelRawSend, resetLabelTakeovers } from '../classification-raw';
|
||||
|
||||
// Labels on raw sends (api.jmap.sendRaw / submitRaw), the way S/MIME and PGP
|
||||
// plugins send after a takeover: the takeover's label goes into the outer
|
||||
// header block, matched by the token the request carries, or by identity
|
||||
// when the plugin doesn't pass it (refused when that is ambiguous). Label
|
||||
// headers a plugin wrote are always taken out, and a raw send outside a
|
||||
// takeover gets the default or is refused when a label is required.
|
||||
|
||||
const generic = LABEL_PRESETS[0];
|
||||
const labels = { list: generic.list, defaultId: 'internal', required: false, extraHeader: 'X-Classification' };
|
||||
const label = (id: string) => generic.list.find((l) => l.id === id)!;
|
||||
|
||||
function useLabels(over: Partial<typeof labels> = {}) {
|
||||
usePolicyStore.setState({ policy: { ...DEFAULT_POLICY, organisation: { ...DEFAULT_ORGANISATION_POLICY, labels: { ...labels, ...over } } } });
|
||||
}
|
||||
|
||||
const raw = (head: string) => new TextEncoder().encode(`${head}\r\n\r\nContent-Type: application/pkcs7-mime\r\n\r\nSIGNED-BYTES\r\n`);
|
||||
const decode = (bytes: Uint8Array) => new TextDecoder().decode(bytes);
|
||||
const headOf = (bytes: Uint8Array) => decode(bytes).split('\r\n\r\n')[0];
|
||||
const refusal = (fn: () => unknown) => {
|
||||
try { fn(); } catch (error) { return error as Error; }
|
||||
return null;
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
usePolicyStore.setState({ policy: DEFAULT_POLICY });
|
||||
resetLabelTakeovers();
|
||||
});
|
||||
|
||||
describe('a raw send during a takeover', () => {
|
||||
it('gets the label in the outer header block, the signed part untouched', () => {
|
||||
useLabels();
|
||||
const token = beginLabelTakeover(labels, label('confidential'), 'id-1')!;
|
||||
const out = decode(labelRawSend(raw('From: a@example.org\r\nSubject: [Confidential] Budget'), 'id-1', token));
|
||||
const [head, ...rest] = out.split('\r\n\r\n');
|
||||
expect(head).toContain('SIO-Label: marking="CONFIDENTIAL"');
|
||||
expect(head).toContain('Sensitivity: Company-Confidential');
|
||||
expect(head).toContain('X-Classification: Confidential');
|
||||
expect(rest.join('\r\n\r\n')).toBe('Content-Type: application/pkcs7-mime\r\n\r\nSIGNED-BYTES\r\n');
|
||||
endLabelTakeover(token);
|
||||
});
|
||||
|
||||
it('matches two takeovers at once by their tokens, whatever the subjects are', () => {
|
||||
useLabels();
|
||||
const first = beginLabelTakeover(labels, label('public'), 'id-1')!;
|
||||
const second = beginLabelTakeover(labels, label('confidential'), 'id-1')!;
|
||||
// Subjects as plugins write them: raw UTF-8 and an encoded word.
|
||||
const a = headOf(labelRawSend(raw('Subject: Grüße aus Köln'), 'id-1', second));
|
||||
const b = headOf(labelRawSend(raw('Subject: =?UTF-8?B?R3LDvMOfZQ==?='), 'id-1', first));
|
||||
expect(a).toContain('marking="CONFIDENTIAL"');
|
||||
expect(a).toContain('Subject: Grüße aus Köln');
|
||||
expect(b).toContain('marking="PUBLIC"');
|
||||
});
|
||||
|
||||
it('matches a plugin that passes no token by its identity, and refuses when two are open', () => {
|
||||
useLabels();
|
||||
const one = beginLabelTakeover(labels, label('confidential'), 'acct-2::id-1')!;
|
||||
// The request's identity carries the other account's prefix; the plugin passes the raw id.
|
||||
expect(headOf(labelRawSend(raw('Subject: Grüße'), 'id-1'))).toContain('marking="CONFIDENTIAL"');
|
||||
beginLabelTakeover(labels, label('public'), 'id-1');
|
||||
expect(refusal(() => labelRawSend(raw('Subject: Grüße'), 'id-1'))?.name).toBe('RawSendLabelError');
|
||||
endLabelTakeover(one);
|
||||
});
|
||||
|
||||
it('takes out label headers the plugin wrote, also for "no label"', () => {
|
||||
useLabels();
|
||||
const token = beginLabelTakeover(labels, null, 'id-1')!;
|
||||
const out = headOf(labelRawSend(raw('Subject: x\r\nSIO-Label: marking="FORGED";\r\n bgcolor=red\r\nSensitivity: Private\r\nX-Classification: Secret'), 'id-1', token));
|
||||
expect(out).toBe('Subject: x');
|
||||
});
|
||||
|
||||
it('refuses a token that is no longer open', () => {
|
||||
useLabels();
|
||||
const token = beginLabelTakeover(labels, label('public'), 'id-1')!;
|
||||
endLabelTakeover(token);
|
||||
expect(refusal(() => labelRawSend(raw('Subject: x'), 'id-1', token))?.name).toBe('RawSendLabelError');
|
||||
});
|
||||
});
|
||||
|
||||
describe('a raw send outside any takeover', () => {
|
||||
it('gets the default label and loses forged ones', () => {
|
||||
useLabels();
|
||||
const out = headOf(labelRawSend(raw('Subject: x\r\nSIO-Label: marking="PUBLIC"'), 'id-1'));
|
||||
expect(out).toContain('marking="INTERNAL"');
|
||||
expect(out).not.toContain('PUBLIC');
|
||||
});
|
||||
|
||||
it('is refused when a label is required', () => {
|
||||
useLabels({ required: true, defaultId: '' });
|
||||
const error = refusal(() => labelRawSend(raw('Subject: x'), 'id-1'));
|
||||
expect(error?.name).toBe('RawSendLabelError');
|
||||
expect(error?.message).toContain('requires a classification label');
|
||||
});
|
||||
|
||||
it('is left alone while the organisation has no labels', () => {
|
||||
const bytes = raw('Subject: x\r\nSensitivity: Private');
|
||||
expect(labelRawSend(bytes, 'id-1')).toBe(bytes);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the extra header on raw sends', () => {
|
||||
it('is folded, non-ASCII as encoded words of at most 75 characters', () => {
|
||||
useLabels();
|
||||
const long = { ...label('confidential'), extraHeaderValue: 'Vertraulich – nur für den internen Gebrauch der Abteilung Öffentlichkeitsarbeit und Presse' };
|
||||
const token = beginLabelTakeover(labels, long, 'id-1')!;
|
||||
const head = headOf(labelRawSend(raw('Subject: x'), 'id-1', token));
|
||||
const extra = head.slice(head.indexOf('X-Classification:')).split('\r\n').filter((line, i) => i === 0 || line.startsWith(' '));
|
||||
expect(extra.length).toBeGreaterThan(1);
|
||||
for (const line of extra) expect(line.length).toBeLessThanOrEqual(78);
|
||||
for (const word of head.match(/=\?UTF-8\?B\?[^?]*\?=/g) ?? []) expect(word.length).toBeLessThanOrEqual(75);
|
||||
const decoded = (head.match(/=\?UTF-8\?B\?([^?]*)\?=/g) ?? [])
|
||||
.map((w) => Buffer.from(w.slice(10, -2), 'base64'));
|
||||
expect(Buffer.concat(decoded).toString('utf8')).toBe(long.extraHeaderValue);
|
||||
});
|
||||
});
|
||||
@@ -7,20 +7,16 @@ import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { customHeaderProperties, withLabelHeaders } from '../custom-headers';
|
||||
import {
|
||||
automaticLabel,
|
||||
beginLabelTakeover,
|
||||
classifyOutgoing,
|
||||
endLabelTakeover,
|
||||
injectTakeoverLabel,
|
||||
isLabelRequiredError,
|
||||
labelSourceHeaders,
|
||||
resetLabelTakeovers,
|
||||
reopenedLabelChoice,
|
||||
resolveLabel,
|
||||
} from '../classification';
|
||||
|
||||
// The label stage of sending: which label goes out, the required label
|
||||
// failing closed, plugin headers that can't override it, the policy of the
|
||||
// sending account, and the headers a crypto takeover's raw send gets.
|
||||
// sending account (raw sends: classification-raw.test.ts).
|
||||
|
||||
const generic = LABEL_PRESETS[0];
|
||||
const policy = { list: generic.list, defaultId: 'internal', required: false, extraHeader: 'X-Classification' };
|
||||
@@ -37,7 +33,6 @@ beforeEach(() => {
|
||||
afterEach(() => {
|
||||
usePolicyStore.setState({ policy: DEFAULT_POLICY, rawPolicy: DEFAULT_POLICY, subject: null });
|
||||
useAuthStore.setState({ identities: [] } as never);
|
||||
resetLabelTakeovers();
|
||||
});
|
||||
|
||||
describe('the label of the original', () => {
|
||||
@@ -125,46 +120,3 @@ describe('the policy of the sending account', () => {
|
||||
expect(labelSourceHeaders({ Received: 'z' })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('crypto takeovers', () => {
|
||||
const raw = (head: string) => new TextEncoder().encode(`${head}\r\n\r\nContent-Type: application/pkcs7-mime\r\n\r\nSIGNED-BYTES\r\n`);
|
||||
const decode = (bytes: Uint8Array) => new TextDecoder().decode(bytes);
|
||||
|
||||
it('add the label headers to the top-level header block, outside the signed part', () => {
|
||||
const token = beginLabelTakeover(policy, label('confidential'), 'id-1', '[Confidential] Budget');
|
||||
const out = decode(injectTakeoverLabel(raw('From: a@example.org\r\nSubject: [Confidential] Budget'), 'id-1'));
|
||||
const [head, ...rest] = out.split('\r\n\r\n');
|
||||
expect(head).toContain('SIO-Label: marking="CONFIDENTIAL"');
|
||||
expect(head).toContain('Sensitivity: Company-Confidential');
|
||||
expect(head).toContain('X-Classification: Confidential');
|
||||
expect(rest.join('\r\n\r\n')).toBe('Content-Type: application/pkcs7-mime\r\n\r\nSIGNED-BYTES\r\n');
|
||||
endLabelTakeover(token);
|
||||
});
|
||||
|
||||
it('replace label headers the plugin wrote, and leave 8-bit header bytes alone', () => {
|
||||
beginLabelTakeover(policy, label('public'), 'id-1', 'Grüße');
|
||||
const input = raw('Subject: Grüße\r\nSIO-Label: marking="FORGED";\r\n bgcolor=red\r\nX-Classification: Secret');
|
||||
const out = decode(injectTakeoverLabel(input, 'id-1'));
|
||||
expect(out).not.toContain('FORGED');
|
||||
expect(out).not.toContain('bgcolor=red');
|
||||
expect(out).not.toContain('Secret');
|
||||
expect(out).toContain('Subject: Grüße');
|
||||
expect(out.match(/SIO-Label:/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('touch nothing outside a takeover, or for another identity', () => {
|
||||
const input = raw('Subject: x');
|
||||
expect(injectTakeoverLabel(input, 'id-1')).toBe(input);
|
||||
const token = beginLabelTakeover(policy, label('public'), 'id-1', 'x');
|
||||
expect(injectTakeoverLabel(input, 'id-2')).toBe(input);
|
||||
endLabelTakeover(token);
|
||||
expect(injectTakeoverLabel(input, 'id-1')).toBe(input);
|
||||
});
|
||||
|
||||
it('pick the right takeover by subject when two run at once', () => {
|
||||
beginLabelTakeover(policy, label('public'), 'id-1', 'First');
|
||||
beginLabelTakeover(policy, label('confidential'), 'id-1', '[Confidential] Second');
|
||||
const out = decode(injectTakeoverLabel(raw('Subject: [Confidential] Second'), 'id-1'));
|
||||
expect(out).toContain('marking="CONFIDENTIAL"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Classification labels on raw sends (api.jmap.sendRaw and submitRaw), the
|
||||
* way S/MIME and PGP plugins send after they took the send over from the
|
||||
* composer (onComposeSend). They build the MIME themselves, so the host
|
||||
* puts the label into the top-level header block of what they hand it,
|
||||
* outside the signed or encrypted part.
|
||||
*
|
||||
* - The composer registers each takeover before it runs
|
||||
* (beginLabelTakeover) and hands the plugin its `labelToken` in the
|
||||
* request. A raw send that passes the token back gets that takeover's
|
||||
* label. Plugins that don't pass it yet are matched by their sending
|
||||
* identity; with more than one takeover open for it, the send is refused
|
||||
* rather than guessed.
|
||||
* - While the organisation uses labels, label headers a plugin wrote itself
|
||||
* are always taken out.
|
||||
* - A raw send outside any takeover gets the default label, and is refused
|
||||
* when a label is required: it can't carry the user's pick.
|
||||
*/
|
||||
|
||||
import type { ClassificationLabel } from '@/lib/admin/organisation-policy';
|
||||
import { labelHeaderLines, type LabelsPolicy } from '@/lib/classification/labels';
|
||||
import { stripCrossAccountIdentityPrefix } from '@/hooks/use-pro-multi-account-identities';
|
||||
import { generateUUID } from '@/lib/utils';
|
||||
import { labelsPolicyFor } from './classification';
|
||||
import { LABEL_HEADER_NAMES } from './custom-headers';
|
||||
|
||||
interface Takeover {
|
||||
identityId: string;
|
||||
/** The label's header lines; empty for "no label". */
|
||||
lines: string[];
|
||||
}
|
||||
|
||||
const takeovers = new Map<string, Takeover>();
|
||||
|
||||
/** An identity id without the "<account>::" prefix of another signed-in account's identities. */
|
||||
function rawIdentityId(identityId: string): string {
|
||||
return stripCrossAccountIdentityPrefix(identityId).rawId ?? identityId;
|
||||
}
|
||||
|
||||
/** A raw send the host refuses; the plugin gets the message. */
|
||||
export class RawSendLabelError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'RawSendLabelError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a takeover about to run, with the label the message goes out
|
||||
* with (null: none). Returns the token to put in the request and to pass
|
||||
* to endLabelTakeover when the takeover ended; null while labels are off.
|
||||
*/
|
||||
export function beginLabelTakeover(
|
||||
policy: LabelsPolicy,
|
||||
label: ClassificationLabel | null,
|
||||
identityId: string,
|
||||
): string | null {
|
||||
if (policy.list.length === 0) return null;
|
||||
const token = generateUUID();
|
||||
takeovers.set(token, { identityId: rawIdentityId(identityId), lines: label ? labelHeaderLines(label, policy.extraHeader) : [] });
|
||||
return token;
|
||||
}
|
||||
|
||||
export function endLabelTakeover(token: string | null): void {
|
||||
if (token) takeovers.delete(token);
|
||||
}
|
||||
|
||||
/** For tests. */
|
||||
export function resetLabelTakeovers(): void {
|
||||
takeovers.clear();
|
||||
}
|
||||
|
||||
/** The header lines a raw send gets: its takeover's, or the default's outside one. */
|
||||
function linesFor(policy: LabelsPolicy, identityId: string, token: string | undefined): string[] {
|
||||
if (token) {
|
||||
const takeover = takeovers.get(token);
|
||||
if (!takeover) throw new RawSendLabelError('This send is no longer open in the composer. Send the message again.');
|
||||
return takeover.lines;
|
||||
}
|
||||
const id = rawIdentityId(identityId);
|
||||
const open = [...takeovers.values()].filter((t) => t.identityId === id);
|
||||
if (open.length === 1) return open[0].lines;
|
||||
if (open.length > 1) {
|
||||
throw new RawSendLabelError('Several messages are being sent from this address at once, and this one can\'t be matched to its classification label. Send it again.');
|
||||
}
|
||||
if (policy.required) {
|
||||
throw new RawSendLabelError('Your organisation requires a classification label, and a message sent outside the composer can\'t carry one.');
|
||||
}
|
||||
const fallback = policy.list.find((label) => label.id === policy.defaultId);
|
||||
return fallback ? labelHeaderLines(fallback, policy.extraHeader) : [];
|
||||
}
|
||||
|
||||
/** Where the header block of raw message bytes ends, and its line break. */
|
||||
function headerEnd(bytes: Uint8Array): { end: number; eol: string } | null {
|
||||
for (let i = 0; i < bytes.length - 1; i++) {
|
||||
if (bytes[i] === 0x0d && bytes[i + 1] === 0x0a && bytes[i + 2] === 0x0d && bytes[i + 3] === 0x0a) return { end: i, eol: '\r\n' };
|
||||
if (bytes[i] === 0x0a && bytes[i + 1] === 0x0a) return { end: i, eol: '\n' };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The raw message a plugin hands to sendRaw or submitRaw, with the label
|
||||
* the organisation's policy (of the active account, which sends it) asks
|
||||
* for. The same bytes while labels are off. Throws RawSendLabelError when
|
||||
* the send has to be refused.
|
||||
*/
|
||||
export function labelRawSend(bytes: Uint8Array, identityId: string, token?: string): Uint8Array {
|
||||
const policy = labelsPolicyFor({});
|
||||
if (policy.list.length === 0) return bytes;
|
||||
const lines = linesFor(policy, identityId, typeof token === 'string' && token ? token : undefined);
|
||||
const block = headerEnd(bytes);
|
||||
if (!block) throw new RawSendLabelError('The message has no header block.');
|
||||
// Byte for byte, so 8-bit header bytes (a raw UTF-8 subject) come back unchanged.
|
||||
let head = '';
|
||||
for (let i = 0; i < block.end; i++) head += String.fromCharCode(bytes[i]);
|
||||
const reserved = new Set<string>([...LABEL_HEADER_NAMES, ...(policy.extraHeader ? [policy.extraHeader.toLowerCase()] : [])]);
|
||||
const kept: string[] = [];
|
||||
let skipping = false;
|
||||
for (const line of head.split(/\r?\n/)) {
|
||||
if (/^[ \t]/.test(line)) {
|
||||
if (!skipping) kept.push(line);
|
||||
continue;
|
||||
}
|
||||
skipping = reserved.has(line.slice(0, line.indexOf(':')).trim().toLowerCase());
|
||||
if (!skipping) kept.push(line);
|
||||
}
|
||||
const text = [...kept, ...lines.map((line) => line.replace(/\r\n/g, block.eol))].join(block.eol);
|
||||
const newHead = Uint8Array.from(text, (ch) => ch.charCodeAt(0) & 0xff);
|
||||
const out = new Uint8Array(newHead.length + (bytes.length - block.end));
|
||||
out.set(newHead, 0);
|
||||
out.set(bytes.subarray(block.end), newHead.length);
|
||||
return out;
|
||||
}
|
||||
@@ -14,10 +14,8 @@
|
||||
* replies, read receipts, List-Unsubscribe mails and phishing reports.
|
||||
*
|
||||
* S/MIME and PGP plugins take over the send (onComposeSend) and build raw
|
||||
* MIME themselves: the composer hands them the prefixed subject, and the
|
||||
* host adds the label headers to the top-level header block of the raw
|
||||
* message in api.jmap.sendRaw / submitRaw while that takeover runs, keyed
|
||||
* to its token (beginLabelTakeover), outside the signed or encrypted part.
|
||||
* MIME themselves: the composer hands them the prefixed subject, and
|
||||
* lib/outgoing/classification-raw.ts puts the label into what they send.
|
||||
*/
|
||||
|
||||
import type { ClassificationLabel } from '@/lib/admin/organisation-policy';
|
||||
@@ -27,11 +25,9 @@ import { policyForSender } from '@/lib/org/signature-state';
|
||||
import { activeExternalMailContext, contextInternalDomains, externalSenders, isOtherAccount } from '@/lib/external-mail';
|
||||
import { cachedIdentitiesForAccount } from '@/hooks/use-pro-multi-account-identities';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { generateUUID } from '@/lib/utils';
|
||||
import { withoutLabelHeaders } from './custom-headers';
|
||||
import {
|
||||
applyLabelPrefix,
|
||||
labelHeaderLines,
|
||||
labelHeaderProperties,
|
||||
draftLabelChoice,
|
||||
receivedLabel,
|
||||
@@ -183,105 +179,6 @@ export function automaticLabel(
|
||||
return { policy, label, missing: labelMissing(policy, label) };
|
||||
}
|
||||
|
||||
// ─── Crypto takeovers ─────────────────────────────────────────
|
||||
|
||||
interface Takeover {
|
||||
identityId: string;
|
||||
subject: string;
|
||||
lines: string[];
|
||||
}
|
||||
|
||||
const takeovers = new Map<string, Takeover>();
|
||||
|
||||
/**
|
||||
* Registers the label of a takeover about to run (onComposeSend). Raw
|
||||
* sends during it from the same identity get the label's header lines.
|
||||
* Returns the token; pass it to endLabelTakeover when the takeover ended.
|
||||
*/
|
||||
export function beginLabelTakeover(
|
||||
policy: LabelsPolicy,
|
||||
label: ClassificationLabel | null,
|
||||
identityId: string,
|
||||
subject: string,
|
||||
): string | null {
|
||||
if (!label) return null;
|
||||
const token = generateUUID();
|
||||
takeovers.set(token, { identityId, subject, lines: labelHeaderLines(label, policy.extraHeader) });
|
||||
return token;
|
||||
}
|
||||
|
||||
export function endLabelTakeover(token: string | null): void {
|
||||
if (token) takeovers.delete(token);
|
||||
}
|
||||
|
||||
const LABEL_LINE_NAMES = ['sio-label', 'sio-label-history', 'sensitivity', 'x-bulwark-label'];
|
||||
|
||||
/** The Subject header of a raw message's top-level block, unfolded and decoded enough to compare. */
|
||||
function rawSubject(head: string): string {
|
||||
const m = /(?:^|\r?\n)Subject:([^\r\n]*(?:\r?\n[ \t][^\r\n]*)*)/i.exec(head);
|
||||
return m ? m[1].replace(/\r?\n[ \t]+/g, ' ').trim() : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* The takeover a raw send belongs to: the one running for `identityId`;
|
||||
* when several are (two composers sending at once), the one whose subject
|
||||
* is the message's.
|
||||
*/
|
||||
function takeoverFor(identityId: string, head: string): Takeover | null {
|
||||
const candidates = [...takeovers.values()].filter((t) => t.identityId === identityId);
|
||||
if (candidates.length <= 1) return candidates[0] ?? null;
|
||||
const subject = rawSubject(head);
|
||||
return candidates.find((t) => t.subject === subject) ?? candidates[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* `bytes` (a raw RFC 5322 message) with the label's headers added to its
|
||||
* top-level header block, when a labelled takeover is running for
|
||||
* `identityId`; the same bytes otherwise. Label headers the plugin wrote
|
||||
* itself are replaced.
|
||||
*/
|
||||
export function injectTakeoverLabel(bytes: Uint8Array, identityId: string): Uint8Array {
|
||||
if (takeovers.size === 0) return bytes;
|
||||
// The header block is ASCII (RFC 5322 §2.2); find its end without decoding the body.
|
||||
let end = -1;
|
||||
let breakLength = 0;
|
||||
for (let i = 0; i < bytes.length - 1; i++) {
|
||||
if (bytes[i] === 0x0d && bytes[i + 1] === 0x0a && bytes[i + 2] === 0x0d && bytes[i + 3] === 0x0a) { end = i; breakLength = 4; break; }
|
||||
if (bytes[i] === 0x0a && bytes[i + 1] === 0x0a) { end = i; breakLength = 2; break; }
|
||||
}
|
||||
if (end < 0) return bytes;
|
||||
// Byte for byte, so 8-bit header bytes (a raw UTF-8 subject) come back unchanged.
|
||||
let head = '';
|
||||
for (let i = 0; i < end; i++) head += String.fromCharCode(bytes[i]);
|
||||
const takeover = takeoverFor(identityId, head);
|
||||
if (!takeover) return bytes;
|
||||
const eol = breakLength === 4 ? '\r\n' : '\n';
|
||||
// Drop the label headers already there (with their folded lines).
|
||||
const kept: string[] = [];
|
||||
let skipping = false;
|
||||
for (const line of head.split(/\r?\n/)) {
|
||||
if (/^[ \t]/.test(line)) {
|
||||
if (!skipping) kept.push(line);
|
||||
continue;
|
||||
}
|
||||
const name = line.slice(0, line.indexOf(':')).trim().toLowerCase();
|
||||
skipping = LABEL_LINE_NAMES.includes(name) || takeover.lines.some((l) => l.slice(0, l.indexOf(':')).toLowerCase() === name);
|
||||
if (!skipping) kept.push(line);
|
||||
}
|
||||
const added = takeover.lines.map((line) => line.replace(/\r\n/g, eol));
|
||||
const text = [...kept, ...added].join(eol);
|
||||
const newHead = Uint8Array.from(text, (ch) => ch.charCodeAt(0) & 0xff);
|
||||
const out = new Uint8Array(newHead.length + (bytes.length - end));
|
||||
out.set(newHead, 0);
|
||||
out.set(bytes.subarray(end), newHead.length);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** For tests. */
|
||||
export function resetLabelTakeovers(): void {
|
||||
takeovers.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* The original's headers a reply or forward needs for its label: SIO-Label
|
||||
* and every extra header name any scope of the policy uses. Small enough
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// structured-cloneable data back to the iframe.
|
||||
|
||||
import type { AppInfoView, InstalledPlugin, Permission } from '../plugin-types';
|
||||
import { injectTakeoverLabel } from '@/lib/outgoing/classification';
|
||||
import { labelRawSend } from '@/lib/outgoing/classification-raw';
|
||||
import { fetchConfig } from '@/hooks/use-config';
|
||||
import { IS_LITE } from '../lite';
|
||||
import { doAppGetSettings, doAppSearchSettings } from './host-api-app';
|
||||
@@ -801,6 +801,8 @@ async function doJmapUploadBlob(content: unknown, name: unknown, type: unknown):
|
||||
interface JmapSubmitRawOptions {
|
||||
delayedUntil?: string;
|
||||
envelopeRecipients?: string[];
|
||||
/** The `labelToken` of the onComposeSend request this send completes. */
|
||||
labelToken?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -823,9 +825,9 @@ async function doJmapSendRaw(
|
||||
// ArrayBuffer (not SharedArrayBuffer) — also detaches from the caller's view.
|
||||
const copy = new Uint8Array(view.byteLength);
|
||||
copy.set(view);
|
||||
// During a labelled takeover (S/MIME, PGP) the label's headers go into the
|
||||
// top-level header block, outside what the plugin signed or encrypted.
|
||||
const labelled = injectTakeoverLabel(copy, identityId);
|
||||
// The classification label goes into the top-level header block, outside
|
||||
// what the plugin signed or encrypted (lib/outgoing/classification-raw.ts).
|
||||
const labelled = labelRawSend(copy, identityId, opts?.labelToken);
|
||||
const blob = new Blob([labelled.buffer as ArrayBuffer], { type: 'message/rfc822' });
|
||||
return useEmailStore.getState().sendRawEmail(
|
||||
client,
|
||||
@@ -860,7 +862,7 @@ async function doJmapSubmitRaw(
|
||||
|
||||
const copy = new Uint8Array(view.byteLength);
|
||||
copy.set(view);
|
||||
const labelled = injectTakeoverLabel(copy, identityId);
|
||||
const labelled = labelRawSend(copy, identityId, opts?.labelToken);
|
||||
const blob = new Blob([labelled.buffer as ArrayBuffer], { type: 'message/rfc822' });
|
||||
|
||||
return client.submitRawEmail(
|
||||
@@ -2045,12 +2047,12 @@ export async function dispatchApiCall(
|
||||
case 'jmap.sendRaw': return doJmapSendRaw(
|
||||
args[0] as ArrayBuffer | ArrayBufferView,
|
||||
args[1] as string,
|
||||
args[2] as { delayedUntil?: string; envelopeRecipients?: string[] } | undefined,
|
||||
args[2] as JmapSubmitRawOptions | undefined,
|
||||
);
|
||||
case 'jmap.submitRaw': return doJmapSubmitRaw(
|
||||
args[0] as ArrayBuffer | ArrayBufferView,
|
||||
args[1] as string,
|
||||
args[2] as { delayedUntil?: string; envelopeRecipients?: string[] } | undefined,
|
||||
args[2] as JmapSubmitRawOptions | undefined,
|
||||
);
|
||||
case 'jmap.importRaw': return doJmapImportRaw(
|
||||
args[0] as ArrayBuffer | ArrayBufferView,
|
||||
|
||||
Reference in New Issue
Block a user