Close the smaller gaps from the security review

Ask for the account password before minting an app password, and keep
sessions the proxy checks from writing the account's own registry objects,
so a session left open on someone else's machine cannot take a credential
away from it. The password is compared with what the session holds; Stalwart
is asked only when 2FA moved the session onto an app password.

Serve attachments and proxied images with no-store on a device that is not
the person's own. Give files from a winmail.dat only the types the server
would show inline. Strip direction controls from sender and attachment
names and from saved filenames.

On signing out, send what is inside its undo window, then close every
composer, so the next person to sign in does not find the last one's draft.

Group sessions by the account Stalwart names and its server, so "sign out
other sessions" also reaches a session opened as a bare or differently
cased username.
This commit is contained in:
2026-09-16 08:47:23 -07:00
parent 9691a7bbf5
commit dfe885a921
18 changed files with 334 additions and 46 deletions
+12 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { formatAddress, initials, isValidEmail, parseAddressList, parseMailto } from "../address";
import { displayName, formatAddress, initials, isValidEmail, parseAddressList, parseMailto } from "../address";
describe("address parsing", () => {
it("parses mixed lists", () => {
@@ -55,3 +55,14 @@ describe("mailto URLs", () => {
expect(m.to).toHaveLength(1);
});
});
describe("names that reorder themselves", () => {
const spoof = { name: "[email protected]\u202E", email: "[email protected]" };
it("lose their direction controls when displayed", () => {
expect(displayName({ name: "\u202Egnp.exe\u202C Ann", email: "[email protected]" })).toBe("gnp.exe Ann");
expect(formatAddress(spoof)).toBe("[email protected] <[email protected]>");
});
it("fall back to the address when nothing else is left", () => {
expect(displayName({ name: "\u200F\u202E", email: "[email protected]" })).toBe("[email protected]");
});
});
+6
View File
@@ -79,6 +79,12 @@ describe("isTnef", () => {
});
describe("parseTnef", () => {
it("takes the direction overrides out of a name", () => {
const out = parseTnef(tnef(file("x.bin", "MZ", [
{ id: ATT.attachment, data: mapi([{ id: 0x3707, type: 0x001f, value: "Invoice_\u202Efdp.exe" }]) },
])));
expect(out[0]!.name).toBe("Invoice_fdp.exe");
});
it("pulls one attachment out, with its name and bytes", () => {
const out = parseTnef(tnef(file("report.pdf", "hello")));
expect(out).toHaveLength(1);
+7 -4
View File
@@ -1,4 +1,5 @@
import type { EmailAddress } from "@/jmap/types";
import { withoutBidiControls } from "@/lib/text/text";
const EMAIL_RE = /^[^\s@<>"',;]+@[^\s@<>"',;]+\.[^\s@<>"',;]+$/;
@@ -48,9 +49,10 @@ export function parseOne(raw: string): EmailAddress | null {
export function formatAddress(a: EmailAddress | null | undefined): string {
if (!a) return "";
if (!a.name) return a.email;
const needsQuote = /[,;<>"()\\]/.test(a.name);
const name = needsQuote ? `"${a.name.replace(/(["\\])/g, "\\$1")}"` : a.name;
const clean = a.name ? withoutBidiControls(a.name) : "";
if (!clean) return a.email;
const needsQuote = /[,;<>"()\\]/.test(clean);
const name = needsQuote ? `"${clean.replace(/(["\\])/g, "\\$1")}"` : clean;
return `${name} <${a.email}>`;
}
@@ -60,7 +62,8 @@ export function formatAddressList(list: EmailAddress[] | null | undefined): stri
export function displayName(a: EmailAddress | null | undefined, fallback = "(unknown)"): string {
if (!a) return fallback;
if (a.name?.trim()) return a.name.trim();
const name = a.name ? withoutBidiControls(a.name).trim() : "";
if (name) return name;
return a.email || fallback;
}
+9
View File
@@ -1,3 +1,12 @@
/**
* Remove the characters that reorder text around them: direction overrides,
* embeddings, isolates and marks. A sender-supplied name has no honest use for
* them, and `Invoice_\u202Efdp.exe` displays as "Invoice_exe.pdf".
*/
export function withoutBidiControls(s: string): string {
return s.replace(/[\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]/g, "");
}
export function escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
}
+2 -1
View File
@@ -1,3 +1,4 @@
import { withoutBidiControls } from "@/lib/text/text";
/**
* `winmail.dat`, opened.
*
@@ -210,7 +211,7 @@ export function parseTnef(input: ArrayBuffer | Uint8Array): TnefAttachment[] {
current = null;
return;
}
const name = (current.mapiName || current.title || "attachment").trim() || "attachment";
const name = withoutBidiControls(current.mapiName || current.title || "attachment").trim() || "attachment";
out.push({
name,
type: current.mapiType || guessType(name),
@@ -0,0 +1,53 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useCompose } from "@/store/compose";
import { useSession } from "@/store/session";
/**
* A message being written belongs to the session it was written in. On a
* shared machine the next person to sign in -- after an idle sign-out, with no
* reload in between -- used to find the last one's composer still open.
*/
beforeEach(() => {
vi.useFakeTimers();
useSession.setState({ status: "authenticated" });
});
afterEach(() => {
useCompose.setState({ drafts: [], activeKey: null, pendingSends: {} });
vi.useRealTimers();
});
describe("signing out", () => {
it("closes every composer and stops sends that are still waiting", () => {
const run = vi.fn(async () => {});
const timer = window.setTimeout(() => void run(), 5000);
useCompose.setState({
drafts: [{ key: "d1", subject: "Half written" } as never],
activeKey: "d1",
pendingSends: { d2: { timer, toastId: 1, draft: { key: "d2" } as never, run } },
});
useSession.setState({ status: "anonymous" });
expect(useCompose.getState().drafts).toEqual([]);
expect(useCompose.getState().activeKey).toBeNull();
expect(useCompose.getState().pendingSends).toEqual({});
vi.advanceTimersByTime(10_000);
expect(run).not.toHaveBeenCalled();
});
it("leaves the composer alone while still signed in", () => {
useCompose.setState({ drafts: [{ key: "d1" } as never], activeKey: "d1" });
useSession.setState({ pushConnected: true });
expect(useCompose.getState().drafts).toHaveLength(1);
});
it("sends what is inside its undo window before the session goes", async () => {
const run = vi.fn(async () => {});
const timer = window.setTimeout(() => void run(), 5000);
useCompose.setState({ pendingSends: { d2: { timer, toastId: 1, draft: { key: "d2" } as never, run } } });
await useCompose.getState().flushPendingSends();
expect(run).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(10_000);
expect(run).toHaveBeenCalledTimes(1);
});
});
+39 -2
View File
@@ -7,6 +7,7 @@ import { escapeHtml, htmlToText, quoteText, replySubject, textToHtml } from "@/l
import { sanitizeEmailHtml, sanitizeEditorHtml } from "@/lib/text/html";
import { toast } from "@/ui/toast";
import { useMail, FULL_PROPS, BODY_PROPS } from "./mail";
import { useSession } from "./session";
import { ensureScheduledMailbox, useScheduled } from "./scheduled";
import { formatScheduleTime, holdUntil } from "@/lib/schedule";
import { t as translate } from "@/lib/i18n";
@@ -82,7 +83,9 @@ export interface Draft {
interface ComposeState {
drafts: Draft[];
activeKey: string | null;
pendingSends: Record<string, { timer: number; toastId: number; draft: Draft }>;
pendingSends: Record<string, { timer: number; toastId: number; draft: Draft; run: () => Promise<void> }>;
/** Send everything still inside its undo window now. For signing out, while the session can still send. */
flushPendingSends(): Promise<void>;
open(init?: Partial<Draft>): string;
/** Open a draft holding what the operating system's share sheet sent us. */
openFromShare(share: SharedContent): string;
@@ -632,7 +635,16 @@ export const useCompose = create<ComposeState>((set, get) => ({
}
const toastId = toast.show(translate("Sending…"), { duration: delay * 1000, progress: true, action: { label: translate("Undo"), onClick: () => get().undoSend(key) } });
const timer = window.setTimeout(() => void doSend(), delay * 1000);
set((s) => ({ pendingSends: { ...s.pendingSends, [key]: { timer, toastId, draft: d } } }));
set((s) => ({ pendingSends: { ...s.pendingSends, [key]: { timer, toastId, draft: d, run: doSend } } }));
},
async flushPendingSends() {
const pending = Object.values(get().pendingSends);
for (const p of pending) {
window.clearTimeout(p.timer);
toast.dismiss(p.toastId);
}
await Promise.all(pending.map((p) => p.run()));
},
undoSend(key) {
@@ -999,3 +1011,28 @@ export function draftFromMailto(url: string): Partial<Draft> {
...(body ? { html: body, text: m.body } : {}),
};
}
/*
* Nothing written in one session is left for the next.
*
* The other stores let go of their data when the session ends; this one used
* to keep its open composers, so on a shared machine the next person to sign
* in -- without a reload, after an idle sign-out, say -- found the last one's
* draft open and could send it. A draft that was saved is still in Drafts on
* the server. A send still in its undo window was sent on the way out if the
* sign-out was a deliberate one (see `logout`); if the session had already
* ended there is nothing left to send it with, so its timer is stopped rather
* than let it fire under whoever signs in next.
*/
useSession.subscribe((s) => {
if (s.status !== "anonymous") return;
const { drafts, pendingSends } = useCompose.getState();
if (!drafts.length && !Object.keys(pendingSends).length) return;
for (const t of autosaveTimers.values()) window.clearTimeout(t);
autosaveTimers.clear();
for (const p of Object.values(pendingSends)) {
window.clearTimeout(p.timer);
toast.dismiss(p.toastId);
}
useCompose.setState({ drafts: [], activeKey: null, pendingSends: {} });
});
+8
View File
@@ -78,6 +78,14 @@ export const useSession = create<SessionState>((set, get) => ({
/* never block signing out over this */
}
stopSettingsSync();
// A message still inside its undo window goes now, while there is a
// session to send it with; signing out is not an undo.
try {
const { useCompose } = await import("./compose");
await useCompose.getState().flushPendingSends();
} catch {
/* never block signing out over this */
}
try {
await apiFetch("/api/auth/logout", { method: "POST" });
} catch {
+3 -2
View File
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } fro
import { Code2, Download, Eye, Pencil, Printer, Save, Share2, X } from "lucide-react";
import { confirmDialog, Dialog } from "./dialog";
import { formatSize } from "@/lib/format";
import { withoutBidiControls } from "@/lib/text/text";
import { previewKind, TEXT_PREVIEW_CHARS, TEXT_PREVIEW_MAX } from "@/lib/preview";
import { isMarkdown, renderMarkdown } from "@/lib/text/markdown";
import { canShareFiles, shareFile } from "@/lib/share";
@@ -168,7 +169,7 @@ export function FilePreviewDialog({
const download = () => {
const l = document.createElement("a");
l.href = file.url;
l.download = file.name;
l.download = withoutBidiControls(file.name);
l.click();
};
try {
@@ -226,7 +227,7 @@ export function FilePreviewDialog({
<Dialog
open={Boolean(file)}
onClose={requestClose}
title={file?.name ?? t("Preview")}
title={file ? withoutBidiControls(file.name) : t("Preview")}
size="xl"
closeOnBackdrop={!editing}
footer={
+15 -8
View File
@@ -21,7 +21,7 @@ import { displayName, domainOf, formatAddress } from "@/lib/address";
import { EMAIL_BASE_CSS, TEXT_EMAIL_CSS, hasHtmlAlternative, htmlDeclaresColors, markKeptSurfaces, sanitizeEmailHtml } from "@/lib/text/html";
import { openableInTab, previewKind } from "@/lib/preview";
import { FilePreviewDialog } from "@/ui/filepreview";
import { findQuoteStart, htmlToText, textToHtml } from "@/lib/text/text";
import { findQuoteStart, htmlToText, textToHtml, withoutBidiControls } from "@/lib/text/text";
import { canShare, canShareFiles, shareFile, shareText } from "@/lib/share";
import { Avatar } from "@/ui/misc";
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
@@ -796,7 +796,13 @@ function TnefContents({ part, accountId }: { part: EmailBodyPart; accountId: Id
const blob = await client.fetchBlob(accountId, part.blobId, part.type);
const found = parseTnef(await blob.arrayBuffer());
setFiles(found);
setUrls(found.map((f) => URL.createObjectURL(new Blob([f.data as unknown as BlobPart], { type: f.type }))));
/*
* The type inside a winmail.dat is whatever the sender wrote, and never
* passed the server's check on what may be shown inline. Opened from its
* blob: URL, text/html would render as a page on this origin -- so only
* the types the server itself would show are kept.
*/
setUrls(found.map((f) => URL.createObjectURL(new Blob([f.data as unknown as BlobPart], { type: openableInTab(f.type) ? f.type : "application/octet-stream" }))));
setState("done");
} catch {
setState("error");
@@ -859,7 +865,7 @@ function AttachmentList({ attachments, accountId, email }: { attachments: EmailB
*/
const shareAttachment = async (a: EmailBodyPart) => {
if (!a.blobId) return;
const name = a.name ?? "attachment";
const name = withoutBidiControls(a.name ?? "") || "attachment";
const download = () => {
const l = document.createElement("a");
l.href = client.downloadUrl(accountId, a.blobId!, name, a.type);
@@ -885,16 +891,17 @@ function AttachmentList({ attachments, accountId, email }: { attachments: EmailB
))}
<div className="attachments">
{attachments.map((a, i) => {
const url = a.blobId ? client.downloadUrl(accountId, a.blobId, a.name ?? "attachment", a.type) : "#";
const inlineUrl = a.blobId ? client.downloadUrl(accountId, a.blobId, a.name ?? "attachment", a.type, true) : "#";
const name = a.name ? withoutBidiControls(a.name) : null;
const url = a.blobId ? client.downloadUrl(accountId, a.blobId, name ?? "attachment", a.type) : "#";
const inlineUrl = a.blobId ? client.downloadUrl(accountId, a.blobId, name ?? "attachment", a.type, true) : "#";
return (
<a key={a.blobId ?? i} className="attachment" href={url} download={a.name ?? undefined} title={`${a.name ?? translate("Attachment")} (${formatSize(a.size)})`} onClick={(ev) => { if (viewable(a)) { ev.preventDefault(); setPreview(a); } }}>
<a key={a.blobId ?? i} className="attachment" href={url} download={name ?? undefined} title={`${name ?? translate("Attachment")} (${formatSize(a.size)})`} onClick={(ev) => { if (viewable(a)) { ev.preventDefault(); setPreview(a); } }}>
<span className="att-icon">{a.type.startsWith("image/") && a.type !== "image/svg+xml" && a.blobId ? <img src={inlineUrl} alt="" loading="lazy" /> : attachmentIcon(a.type, a.name)}</span>
<span className="att-text">
<span className="att-name">{a.name ?? "(unnamed)"}</span>
<span className="att-name">{name ?? "(unnamed)"}</span>
<span className="att-size">{formatSize(a.size)}</span>
<span className="att-actions">
<button className="icon-btn xs" title={translate("Download")} onClick={(ev) => { ev.preventDefault(); ev.stopPropagation(); const l = document.createElement("a"); l.href = url; l.download = a.name ?? ""; l.click(); }}><Download size={14} /></button>
<button className="icon-btn xs" title={translate("Download")} onClick={(ev) => { ev.preventDefault(); ev.stopPropagation(); const l = document.createElement("a"); l.href = url; l.download = name ?? ""; l.click(); }}><Download size={14} /></button>
{canShareFiles() && a.blobId && <button className="icon-btn xs" title={tc("share sheet", "Share")} onClick={(ev) => { ev.preventDefault(); ev.stopPropagation(); void shareAttachment(a); }}><Share2 size={14} /></button>}
{openableInTab(a.type) && a.blobId && <button className="icon-btn xs" title={translate("Open in new tab")} onClick={(ev) => { ev.preventDefault(); ev.stopPropagation(); window.open(inlineUrl, "_blank", "noopener"); }}><ExternalLink size={14} /></button>}
</span>
+9 -2
View File
@@ -231,6 +231,7 @@ function TwoFactorOff({ reload }: { reload: () => Promise<void> }) {
function AppPasswords({ state, reload }: { state: SecurityState | null; reload: () => Promise<void> }) {
const [name, setName] = useState("");
const [current, setCurrent] = useState("");
const [busy, setBusy] = useState(false);
const [issued, setIssued] = useState<{ description: string; secret: string } | null>(null);
@@ -242,10 +243,11 @@ function AppPasswords({ state, reload }: { state: SecurityState | null; reload:
try {
const res = await apiFetch<{ id: string; secret: string }>("/api/account/app-passwords", {
method: "POST",
body: JSON.stringify({ description: name }),
body: JSON.stringify({ description: name, current }),
});
setIssued({ description: name, secret: res.secret });
setName("");
setCurrent("");
await reload();
} catch (err) {
toast.error((err as Error).message);
@@ -296,7 +298,12 @@ function AppPasswords({ state, reload }: { state: SecurityState | null; reload:
<label htmlFor="ap-name">{t("New app password for")}</label>
<input id="ap-name" value={name} onChange={(e) => setName(e.target.value)} placeholder={t("Thunderbird on my laptop")} required />
</div>
<button className="btn" disabled={busy || !name.trim()}>{busy ? "Creating…" : "Create"}</button>
{/* A credential that outlives this session: the server asks for the password first. */}
<div className="field" style={{ marginBottom: 0, minWidth: 200 }}>
<label htmlFor="ap-current">{t("Current password")}</label>
<input id="ap-current" type="password" autoComplete="current-password" value={current} onChange={(e) => setCurrent(e.target.value)} required />
</div>
<button className="btn" disabled={busy || !name.trim() || !current}>{busy ? "Creating…" : "Create"}</button>
</form>
<Dialog open={Boolean(issued)} onClose={() => setIssued(null)} title={t("Your new app password")} size="sm"