mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 08:15:41 +00:00
A security and an accessibility review of the proofreading hook found gaps, fixed here. What leaves the browser: - A checker gets a name for each composer of its own, not the composer's id, so it can't reach the message through the other api.composer calls after proofreading stops. - Checking pauses while a plugin that can sign or encrypt is turned on but still starting, and while the policy couldn't be fetched (the built-in defaults could undo an admin's lock). - No plugin can hold up turning proofreading off (onBeforeSettingChange). - Suggestions can't carry zero-width, bidi or line-separator characters; messages lose bidi overrides; a flood of annotations is cut off early. - The notices name the plugin as installed, not a label it chose, and say that text written before signing or encryption was turned on may already have been checked. - A checker registered during start-up no longer fails its first check. Keyboard and screen readers: - Escape in the issue panel closes the panel, not the message. - Underlines carry aria-invalid, as the browser's own markers do. - The focus goes somewhere sensible when a button or an issue goes away, Tab past the popup's edges goes back to the text, and the shortcuts work in the popup and the panel. - Ctrl+' and Ctrl+; follow the character typed, so other layouts keep their own keys (Ctrl+S on Dvorak), with Ä and Ö on German keyboards. - The popup is described by its message, sits inside a modal composer, fits narrow screens, and the panel fits a phone.
61 lines
2.5 KiB
TypeScript
61 lines
2.5 KiB
TypeScript
// Whether the message being written will be signed or encrypted, so that
|
|
// proofreading sends none of it.
|
|
//
|
|
// The webmail doesn't sign or encrypt itself; plugins do (S/MIME, OpenPGP).
|
|
// Their choice reaches the host only as ticked entries in the composer's
|
|
// Options menu:
|
|
// - an entry with `protects: 'sign' | 'encrypt'` that is ticked;
|
|
// - until plugins say so, a ticked entry with the id `sign` or `encrypt`
|
|
// from a plugin that can sign (crypto:full).
|
|
// A running crypto:full plugin that publishes neither (OpenPGP keeps its
|
|
// toggles in its own frame) may sign or encrypt any message without the
|
|
// host seeing it. Then nothing is checked anywhere: we can't tell, so we
|
|
// don't send.
|
|
//
|
|
// The plugins' choice isn't per composer (S/MIME keeps one intent for the
|
|
// next message), so one answer covers every open composer.
|
|
//
|
|
// A crypto:full plugin that is turned on but still starting can't have said
|
|
// anything yet (S/MIME may encrypt by default): it pauses checking too.
|
|
|
|
import type { RegisteredAction } from '@/lib/plugin-sandbox/actions';
|
|
import type { InstalledPlugin } from '@/lib/plugin-types';
|
|
|
|
export const OPTIONS_SURFACE = 'composer-options-menu';
|
|
|
|
export type Protection =
|
|
| { protected: false }
|
|
| { protected: true; reason: 'sign' | 'encrypt' | 'unknown'; pluginName: string };
|
|
|
|
const LEGACY_IDS: Record<string, 'sign' | 'encrypt'> = { sign: 'sign', encrypt: 'encrypt' };
|
|
|
|
function canSign(plugin: InstalledPlugin): boolean {
|
|
return (plugin.permissions ?? []).includes('crypto:full');
|
|
}
|
|
|
|
/**
|
|
* `options`: the actions on the composer's Options menu. `running`: the
|
|
* plugins currently loaded. `starting`: plugins turned on that aren't
|
|
* running yet.
|
|
*/
|
|
export function draftProtection(
|
|
options: readonly RegisteredAction[],
|
|
running: readonly InstalledPlugin[],
|
|
starting: readonly InstalledPlugin[] = [],
|
|
): Protection {
|
|
const loading = starting.find(canSign);
|
|
if (loading) return { protected: true, reason: 'unknown', pluginName: loading.name };
|
|
const signers = new Map(running.filter(canSign).map((p) => [p.id, p]));
|
|
const speaks = new Set<string>();
|
|
for (const a of options) {
|
|
const kind = a.protects ?? (signers.has(a.pluginId) ? LEGACY_IDS[a.id] : undefined);
|
|
if (!kind) continue;
|
|
speaks.add(a.pluginId);
|
|
if (a.checked) return { protected: true, reason: kind, pluginName: a.pluginName };
|
|
}
|
|
for (const plugin of signers.values()) {
|
|
if (!speaks.has(plugin.id)) return { protected: true, reason: 'unknown', pluginName: plugin.name };
|
|
}
|
|
return { protected: false };
|
|
}
|