Files
webmail/lib/proofreading/protection.ts
T
Linus Rath 6055dac1cb fix: keep proofreading text from plugins that shouldn't get it, and work it by keyboard #1397
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.
2026-11-02 20:26:21 +01:00

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 };
}