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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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: {} });
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user