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
+34 -2
View File
@@ -69,7 +69,7 @@ test("the registry reports an account with nothing set up yet", async () => {
}); });
test("app passwords are created, listed once with their secret, and revoked", async () => { test("app passwords are created, listed once with their secret, and revoked", async () => {
const created = await post("/api/account/app-passwords", { description: "Thunderbird" }); const created = await post("/api/account/app-passwords", { description: "Thunderbird", current: "demo-password" });
assert.equal(created.status, 200); assert.equal(created.status, 200);
assert.match(created.body.secret, /^\$app\$/, "the server's generated secret is returned"); assert.match(created.body.secret, /^\$app\$/, "the server's generated secret is returned");
assert.ok(created.body.id); assert.ok(created.body.id);
@@ -84,8 +84,40 @@ test("app passwords are created, listed once with their secret, and revoked", as
assert.deepEqual((await call("/api/account/security")).body.appPasswords, []); assert.deepEqual((await call("/api/account/security")).body.appPasswords, []);
}); });
test("an app password needs the account password", async () => {
const missing = await post("/api/account/app-passwords", { description: "Stolen" });
assert.equal(missing.status, 400);
assert.equal(missing.body.error, "missing_fields");
const wrong = await post("/api/account/app-passwords", { description: "Stolen", current: "not-my-password" });
assert.equal(wrong.status, 403);
assert.equal(wrong.body.error, "invalid_credentials");
assert.deepEqual((await call("/api/account/security")).body.appPasswords, [], "nothing was created");
});
test("a checked session cannot mint one through the JMAP proxy instead", async () => {
// Signed in without "my own device", so the proxy reads every request.
const res = await call("/api/jmap", {
method: "POST",
body: JSON.stringify({ using: ["urn:ietf:params:jmap:core"], methodCalls: [["x:AppPassword/set", { create: { n: { description: "Stolen" } } }, "0"]] }),
});
assert.equal(res.status, 403);
assert.deepEqual((await call("/api/account/security")).body.appPasswords, []);
});
test("attachments are kept out of the disk cache of a device that is not the person's own", async () => {
const up = await app.request("/api/upload/a1", { method: "POST", headers: { "x-requested-with": "ihasmail", "content-type": "text/plain", cookie }, body: "hello" });
assert.equal(up.status, 200);
const { blobId } = (await up.json()) as { blobId: string };
const name = encodeURIComponent("Invoice_\u202Efdp.exe");
const res = await app.request(`/api/blob/a1/${blobId}/${name}?accept=text/plain`, { headers: { cookie } });
assert.equal(res.status, 200);
assert.equal(res.headers.get("cache-control"), "no-store");
assert.equal(res.headers.get("content-disposition"), "attachment; filename*=UTF-8''Invoice_fdp.exe", "no direction override in the saved name");
await res.arrayBuffer();
});
test("an app password needs a name", async () => { test("an app password needs a name", async () => {
const res = await post("/api/account/app-passwords", { description: " " }); const res = await post("/api/account/app-passwords", { description: " ", current: "demo-password" });
assert.equal(res.status, 400); assert.equal(res.status, 400);
assert.equal(res.body.error, "missing_fields"); assert.equal(res.body.error, "missing_fields");
}); });
+12 -2
View File
@@ -14,8 +14,18 @@ test("mail, calendars and the rest pass untouched", () => {
assert.equal(r.ok, true); assert.equal(r.ok, true);
}); });
test("the account's own registry objects pass", () => { test("the account's own registry objects can be read", () => {
assert.equal(gateAdministration(req("x:AccountSettings/get", "x:AppPassword/set", "x:PublicKey/get", "x:MaskedEmail/set")).ok, true); assert.equal(gateAdministration(req("x:AccountSettings/get", "x:AppPassword/get", "x:PublicKey/get", "x:MaskedEmail/query")).ok, true);
});
test("but not written: a credential minted here would outlive a borrowed session", () => {
for (const m of ["x:AppPassword/set", "x:AccountPassword/set", "x:MaskedEmail/set"]) {
assert.deepEqual(gateAdministration(req("x:AccountSettings/get", m)), { ok: false, method: m });
}
});
test("API keys are not the account's to reach from here at all", () => {
assert.deepEqual(gateAdministration(req("x:ApiKey/get")), { ok: false, method: "x:ApiKey/get" });
}); });
test("directory and server objects are refused, and named", () => { test("directory and server objects are refused, and named", () => {
+10 -3
View File
@@ -18,7 +18,7 @@
* The standard JMAP methods (mail, calendars, contacts, files, sharing) are not * The standard JMAP methods (mail, calendars, contacts, files, sharing) are not
* touched: they act on what the account can already reach. * touched: they act on what the account can already reach.
*/ */
const SELF_SERVICE = new Set(["AccountSettings", "AccountPassword", "AppPassword", "ApiKey", "PublicKey", "MaskedEmail"]); const SELF_SERVICE = new Set(["AccountSettings", "AccountPassword", "AppPassword", "PublicKey", "MaskedEmail"]);
export type GateResult = { ok: true; body: string } | { ok: false; method: string | null }; export type GateResult = { ok: true; body: string } | { ok: false; method: string | null };
@@ -83,8 +83,15 @@ export function gateAdministration(raw: string): GateResult {
const name = Array.isArray(call) ? call[0] : undefined; const name = Array.isArray(call) ? call[0] : undefined;
if (typeof name !== "string") return { ok: false, method: null }; if (typeof name !== "string") return { ok: false, method: null };
if (!name.startsWith("x:")) continue; if (!name.startsWith("x:")) continue;
const object = name.slice(2).split("/")[0] ?? ""; const [object = "", op = ""] = name.slice(2).split("/");
if (!SELF_SERVICE.has(object)) return { ok: false, method: name }; /*
* Read, never write. The browser sends none of these itself -- password,
* app-password and 2FA changes go through /api/account, which checks the
* account password first -- so a write here could only come from
* somebody working the console of a session on a borrowed machine, and
* `x:AppPassword/set` would hand them a credential that outlives it.
*/
if (!SELF_SERVICE.has(object) || op === "set") return { ok: false, method: name };
} }
return { ok: true, body: JSON.stringify(parsed) }; return { ok: true, body: JSON.stringify(parsed) };
} }
+65 -10
View File
@@ -11,9 +11,10 @@ import { getConnInfo } from "@hono/node-server/conninfo";
import { config } from "./config.js"; import { config } from "./config.js";
import { fetchPermissions } from "./permissionSchema.js"; import { fetchPermissions } from "./permissionSchema.js";
import { administrationAllowed, gateAdministration, grantsAdministration } from "./adminGate.js"; import { administrationAllowed, gateAdministration, grantsAdministration } from "./adminGate.js";
import { SessionStore, type SessionBackend, type LiveSession } from "./sessions.js"; import { SessionStore, accountKey, type SessionBackend, type LiveSession } from "./sessions.js";
import { RateLimiter } from "./ratelimit.js"; import { RateLimiter } from "./ratelimit.js";
import { rateLimitKey, resolveClientIp } from "./clientip.js"; import { rateLimitKey, resolveClientIp } from "./clientip.js";
import { safeEqual } from "./crypto.js";
import { import {
type AccountInfo, type AccountInfo,
UpstreamError, UpstreamError,
@@ -385,6 +386,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
loginLimiter.reset(limitKey); loginLimiter.reset(limitKey);
const { cookie, session } = sessions.create({ const { cookie, session } = sessions.create({
username, username,
account: accountKey(upstreamFor(username), upstream.username || username),
password: effectivePassword, password: effectivePassword,
remember: Boolean(body.remember), remember: Boolean(body.remember),
userAgent: c.req.header("user-agent") ?? "", userAgent: c.req.header("user-agent") ?? "",
@@ -466,12 +468,12 @@ export function createApp(basePath = config.basePath): Hono<Env> {
api.get("/auth/sessions", requireSession, (c) => { api.get("/auth/sessions", requireSession, (c) => {
const session = c.get("session"); const session = c.get("session");
return c.json({ current: session.id, sessions: sessions.listForUser(session.username) }); return c.json({ current: session.id, sessions: sessions.listForUser(session.account) });
}); });
api.post("/auth/sessions/revoke-others", requireSession, (c) => { api.post("/auth/sessions/revoke-others", requireSession, (c) => {
const session = c.get("session"); const session = c.get("session");
const n = sessions.destroyAllForUser(session.username, session.id); const n = sessions.destroyAllForUser(session.account, session.id);
return c.json({ revoked: n }); return c.json({ revoked: n });
}); });
@@ -499,8 +501,8 @@ export function createApp(basePath = config.basePath): Hono<Env> {
}; };
/** Guard the endpoints that check a password against brute-forcing. */ /** Guard the endpoints that check a password against brute-forcing. */
const guarded = (c: Context<Env>): Response | null => { const guarded = (c: Context<Env>, scope = "account"): Response | null => {
const key = `account|${c.get("session").username.toLowerCase()}`; const key = `${scope}|${c.get("session").username.toLowerCase()}`;
if (accountLimiter.check(key)) return null; if (accountLimiter.check(key)) return null;
c.header("Retry-After", String(accountLimiter.retryAfterSeconds(key))); c.header("Retry-After", String(accountLimiter.retryAfterSeconds(key)));
return c.json({ error: "rate_limited", message: "Too many attempts. Please wait and try again." }, 429); return c.json({ error: "rate_limited", message: "Too many attempts. Please wait and try again." }, 429);
@@ -538,7 +540,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
const otpCode = body.otpCode?.trim(); const otpCode = body.otpCode?.trim();
sessions.reseal(getCookie(c, config.cookieName), otpCode ? `${next}$${otpCode}` : next); sessions.reseal(getCookie(c, config.cookieName), otpCode ? `${next}$${otpCode}` : next);
forgetUpstreamSession(session.id); forgetUpstreamSession(session.id);
const revoked = sessions.destroyAllForUser(session.username, session.id); const revoked = sessions.destroyAllForUser(session.account, session.id);
return c.json({ ok: true, revokedSessions: revoked }); return c.json({ ok: true, revokedSessions: revoked });
}); });
@@ -552,12 +554,26 @@ export function createApp(basePath = config.basePath): Hono<Env> {
} }
}); });
/*
* An app password is a credential that outlives this session, a password
* change and a sign-out -- so minting one asks for the account password, as
* changing the password does. Otherwise a session left open on somebody
* else's machine is enough to take a permanent key away from it.
*/
api.post("/account/app-passwords", requireSession, async (c) => { api.post("/account/app-passwords", requireSession, async (c) => {
// A budget of its own: guessing here never reaches Stalwart (see confirmsPassword).
const limited = guarded(c, "app-password");
if (limited) return limited;
const session = c.get("session"); const session = c.get("session");
const body = await readJson<{ description?: string }>(c); const body = await readJson<{ description?: string; current?: string }>(c);
if (!body) return c.json({ error: "bad_request" }, 400); if (!body) return c.json({ error: "bad_request" }, 400);
const description = (body.description ?? "").trim().slice(0, 120); const description = (body.description ?? "").trim().slice(0, 120);
if (!description) return c.json({ error: "missing_fields", message: "Give the app password a name." }, 400); if (!description) return c.json({ error: "missing_fields", message: "Give the app password a name." }, 400);
const current = body.current ?? "";
if (!current || current.length > 1024) return c.json({ error: "missing_fields", message: "Enter your current password." }, 400);
if (!(await confirmsPassword(session, current))) {
return c.json({ error: "invalid_credentials", message: "That password is not correct." }, 403);
}
try { try {
return c.json(await createAppPassword(await accountCtx(c), { description })); return c.json(await createAppPassword(await accountCtx(c), { description }));
} catch (err) { } catch (err) {
@@ -632,7 +648,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
if (sessionKept) forgetUpstreamSession(session.id); if (sessionKept) forgetUpstreamSession(session.id);
} }
// Other sessions still hold the bare password and will be refused. // Other sessions still hold the bare password and will be refused.
const revoked = sessions.destroyAllForUser(session.username, session.id); const revoked = sessions.destroyAllForUser(session.account, session.id);
return c.json({ ok: true, sessionKept, revokedSessions: revoked }); return c.json({ ok: true, sessionKept, revokedSessions: revoked });
}); });
@@ -803,7 +819,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
const safeInline = inline && isInlineSafe(type); const safeInline = inline && isInlineSafe(type);
headers.set( headers.set(
"Content-Disposition", "Content-Disposition",
`${safeInline ? "inline" : "attachment"}; filename*=UTF-8''${encodeURIComponent(name)}`, `${safeInline ? "inline" : "attachment"}; filename*=UTF-8''${encodeURIComponent(withoutBidiControls(name))}`,
); );
headers.set("X-Content-Type-Options", "nosniff"); headers.set("X-Content-Type-Options", "nosniff");
// Sandbox everything except the browser's built-in PDF viewer (which needs scripts to render). // Sandbox everything except the browser's built-in PDF viewer (which needs scripts to render).
@@ -822,7 +838,9 @@ export function createApp(basePath = config.basePath): Hono<Env> {
} else { } else {
headers.set("Content-Security-Policy", "sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src data:"); headers.set("Content-Security-Policy", "sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src data:");
} }
headers.set("Cache-Control", "private, max-age=3600"); // Kept out of the browser's disk cache on a device that is not the
// person's own: signing out wipes what the app stores, not that.
headers.set("Cache-Control", session.remember ? "private, max-age=3600" : "no-store");
return new Response(res.body, { status: 200, headers }); return new Response(res.body, { status: 200, headers });
} catch (err) { } catch (err) {
return upstreamFailure(c, err); return upstreamFailure(c, err);
@@ -908,6 +926,43 @@ async function readJson<T>(c: Context): Promise<T | null> {
} }
} }
/**
* Is `candidate` the password of the account this session is signed in to?
*
* Compared with the credential the session holds first, which costs nothing
* and tells Stalwart nothing -- its auto-ban counts failures against the
* proxy's address, which every user shares. That credential is the password,
* with a TOTP code after a `$` when one was given at sign-in. A session that
* turning on 2FA moved onto an app password (Stalwart's secrets start
* `$app$`) holds something else, and only then is the candidate put to the
* server.
*/
async function confirmsPassword(session: LiveSession, candidate: string): Promise<boolean> {
const decoded = Buffer.from(session.authorization.replace(/^Basic /, ""), "base64").toString("utf8");
const held = decoded.slice(decoded.indexOf(":") + 1);
if (safeEqual(held, candidate)) return true;
const withoutCode = held.replace(/\$\d{6,8}$/, "");
if (withoutCode !== held && safeEqual(withoutCode, candidate)) return true;
// Holding the password, the comparison above is the answer, and a wrong
// guess never reaches the server's auto-ban.
if (!held.startsWith("$app$")) return false;
try {
const authorization = `Basic ${Buffer.from(`${session.username}:${candidate}`, "utf8").toString("base64")}`;
await fetchUpstreamSession(authorization, upstreamFor(session.username));
return true;
} catch {
return false;
}
}
/**
* Direction overrides and isolates, which can make `Invoice_\u202Efdp.exe`
* read as a PDF in the downloads list. A filename has no use for them.
*/
function withoutBidiControls(name: string): string {
return name.replace(/[\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]/g, "");
}
/** Name the app password after the browser it will live in. */ /** Name the app password after the browser it will live in. */
function appPasswordName(c: Context): string { function appPasswordName(c: Context): string {
const ua = c.req.header("user-agent") ?? ""; const ua = c.req.header("user-agent") ?? "";
+2 -1
View File
@@ -217,7 +217,8 @@ export async function imageProxyHandler(c: Context) {
res.on("close", done); res.on("close", done);
const headers = new Headers({ const headers = new Headers({
"Content-Type": type, "Content-Type": type,
"Cache-Control": "private, max-age=86400", // As for attachments: nothing left in the disk cache of a device that is not the person's own.
"Cache-Control": (c.get("session") as { remember?: boolean } | undefined)?.remember ? "private, max-age=86400" : "no-store",
"X-Content-Type-Options": "nosniff", "X-Content-Type-Options": "nosniff",
"Content-Security-Policy": "sandbox; default-src 'none'", "Content-Security-Policy": "sandbox; default-src 'none'",
"Cross-Origin-Resource-Policy": "same-origin", "Cross-Origin-Resource-Policy": "same-origin",
+15 -1
View File
@@ -1,6 +1,6 @@
import { test } from "node:test"; import { test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { SessionStore } from "./sessions.js"; import { SessionStore, accountKey } from "./sessions.js";
import { normalizeLocale } from "./upstream.js"; import { normalizeLocale } from "./upstream.js";
import { deriveKey, open, seal, sha256 } from "./crypto.js"; import { deriveKey, open, seal, sha256 } from "./crypto.js";
import { RateLimiter } from "./ratelimit.js"; import { RateLimiter } from "./ratelimit.js";
@@ -30,6 +30,20 @@ test("session store creates, resolves, and refuses tampered cookies", () => {
assert.equal(store.resolve(cookie), null); assert.equal(store.resolve(cookie), null);
}); });
test("sessions group by the account, however its name was typed", () => {
const store = new SessionStore("");
const key = accountKey("https://mail.example.com", "[email protected]");
const a = store.create({ username: "alice", account: key, password: "pw", remember: false, userAgent: "", ip: "" });
const b = store.create({ username: "[email protected]", account: accountKey("https://mail.example.com", "[email protected]"), password: "pw", remember: false, userAgent: "", ip: "" });
// The same name on another configured server is another account.
store.create({ username: "[email protected]", account: accountKey("https://other.example.net", "[email protected]"), password: "pw", remember: false, userAgent: "", ip: "" });
assert.equal(a.session.account, b.session.account);
assert.equal(store.listForUser(a.session.account).length, 2);
assert.equal(store.destroyAllForUser(a.session.account, a.session.id), 1);
assert.equal(store.resolve(b.cookie), null, "the other spelling was signed out");
assert.ok(store.resolve(a.cookie), "this session was kept");
});
test("persisted session data does not contain the password", () => { test("persisted session data does not contain the password", () => {
const store = new SessionStore(""); const store = new SessionStore("");
store.create({ username: "u", password: "super-secret-pw", remember: true, userAgent: "", ip: "" }); store.create({ username: "u", password: "super-secret-pw", remember: true, userAgent: "", ip: "" });
+33 -7
View File
@@ -13,6 +13,8 @@ export interface StoredSession {
/** sealed JSON {username, password} */ /** sealed JSON {username, password} */
sealedCredentials: string; sealedCredentials: string;
username: string; username: string;
/** Which account this is; see `accountKey`. Absent on sessions saved before it existed. */
account?: string;
createdAt: number; createdAt: number;
lastSeenAt: number; lastSeenAt: number;
expiresAt: number; expiresAt: number;
@@ -24,6 +26,8 @@ export interface StoredSession {
export interface LiveSession { export interface LiveSession {
id: string; id: string;
username: string; username: string;
/** See `accountKey`. */
account: string;
/** Basic Authorization header value for upstream calls. */ /** Basic Authorization header value for upstream calls. */
authorization: string; authorization: string;
remember: boolean; remember: boolean;
@@ -46,8 +50,27 @@ export interface SessionSummary {
ip: string; ip: string;
} }
/**
* The key sessions are grouped by for "sign out everywhere else".
*
* Not the username as typed: Stalwart takes `[email protected]` and a bare
* `alice` as the same account, and a session opened either way was missing
* from the list and survived the sign-out. The server's own name for the
* account, lower-cased, and the server it lives on -- the same name on two
* configured servers is two accounts.
*/
export function accountKey(upstream: string, canonicalUsername: string): string {
return `${upstream}|${canonicalUsername.trim().toLowerCase()}`;
}
function accountOf(s: StoredSession): string {
return s.account ?? s.username.trim().toLowerCase();
}
export interface CreateSessionParams { export interface CreateSessionParams {
username: string; username: string;
/** From `accountKey`; defaults to the lower-cased username. */
account?: string;
password: string; password: string;
remember: boolean; remember: boolean;
userAgent: string; userAgent: string;
@@ -85,8 +108,9 @@ export interface SessionBackend {
resolve(cookie: string | undefined): LiveSession | null; resolve(cookie: string | undefined): LiveSession | null;
reseal(cookie: string | undefined, password: string): boolean; reseal(cookie: string | undefined, password: string): boolean;
destroy(id: string): void; destroy(id: string): void;
destroyAllForUser(username: string, exceptId?: string): number; /** `account` is an `accountKey`, as carried on `LiveSession.account`. */
listForUser(username: string): SessionSummary[]; destroyAllForUser(account: string, exceptId?: string): number;
listForUser(account: string): SessionSummary[];
} }
const COOKIE_SEP = "."; const COOKIE_SEP = ".";
@@ -172,6 +196,7 @@ export class SessionStore implements SessionBackend {
salt: salt.toString("base64"), salt: salt.toString("base64"),
sealedCredentials: seal(JSON.stringify({ u: params.username, p: params.password }), key), sealedCredentials: seal(JSON.stringify({ u: params.username, p: params.password }), key),
username: params.username, username: params.username,
account: params.account ?? params.username.trim().toLowerCase(),
createdAt: now, createdAt: now,
lastSeenAt: now, lastSeenAt: now,
expiresAt: now + ttl, expiresAt: now + ttl,
@@ -248,10 +273,10 @@ export class SessionStore implements SessionBackend {
if (this.sessions.delete(id)) this.scheduleSave(); if (this.sessions.delete(id)) this.scheduleSave();
} }
destroyAllForUser(username: string, exceptId?: string): number { destroyAllForUser(account: string, exceptId?: string): number {
let n = 0; let n = 0;
for (const [id, s] of this.sessions) { for (const [id, s] of this.sessions) {
if (s.username === username && id !== exceptId) { if (accountOf(s) === account && id !== exceptId) {
this.sessions.delete(id); this.sessions.delete(id);
n++; n++;
} }
@@ -260,11 +285,11 @@ export class SessionStore implements SessionBackend {
return n; return n;
} }
listForUser(username: string): SessionSummary[] { listForUser(account: string): SessionSummary[] {
const out = []; const out = [];
for (const s of this.sessions.values()) { for (const s of this.sessions.values()) {
if (s.username !== username) continue; if (accountOf(s) !== account) continue;
const { secretHash: _h, salt: _s, sealedCredentials: _c, ...rest } = s; const { secretHash: _h, salt: _s, sealedCredentials: _c, account: _a, ...rest } = s;
out.push(rest); out.push(rest);
} }
return out; return out;
@@ -274,6 +299,7 @@ export class SessionStore implements SessionBackend {
return { return {
id: s.id, id: s.id,
username, username,
account: accountOf(s),
authorization: `Basic ${Buffer.from(`${username}:${password}`, "utf8").toString("base64")}`, authorization: `Basic ${Buffer.from(`${username}:${password}`, "utf8").toString("base64")}`,
remember: s.remember, remember: s.remember,
createdAt: s.createdAt, createdAt: s.createdAt,
+12 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; 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", () => { describe("address parsing", () => {
it("parses mixed lists", () => { it("parses mixed lists", () => {
@@ -55,3 +55,14 @@ describe("mailto URLs", () => {
expect(m.to).toHaveLength(1); 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", () => { 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", () => { it("pulls one attachment out, with its name and bytes", () => {
const out = parseTnef(tnef(file("report.pdf", "hello"))); const out = parseTnef(tnef(file("report.pdf", "hello")));
expect(out).toHaveLength(1); expect(out).toHaveLength(1);
+7 -4
View File
@@ -1,4 +1,5 @@
import type { EmailAddress } from "@/jmap/types"; import type { EmailAddress } from "@/jmap/types";
import { withoutBidiControls } from "@/lib/text/text";
const EMAIL_RE = /^[^\s@<>"',;]+@[^\s@<>"',;]+\.[^\s@<>"',;]+$/; 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 { export function formatAddress(a: EmailAddress | null | undefined): string {
if (!a) return ""; if (!a) return "";
if (!a.name) return a.email; const clean = a.name ? withoutBidiControls(a.name) : "";
const needsQuote = /[,;<>"()\\]/.test(a.name); if (!clean) return a.email;
const name = needsQuote ? `"${a.name.replace(/(["\\])/g, "\\$1")}"` : a.name; const needsQuote = /[,;<>"()\\]/.test(clean);
const name = needsQuote ? `"${clean.replace(/(["\\])/g, "\\$1")}"` : clean;
return `${name} <${a.email}>`; 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 { export function displayName(a: EmailAddress | null | undefined, fallback = "(unknown)"): string {
if (!a) return fallback; 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; 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 { export function escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;"); 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. * `winmail.dat`, opened.
* *
@@ -210,7 +211,7 @@ export function parseTnef(input: ArrayBuffer | Uint8Array): TnefAttachment[] {
current = null; current = null;
return; return;
} }
const name = (current.mapiName || current.title || "attachment").trim() || "attachment"; const name = withoutBidiControls(current.mapiName || current.title || "attachment").trim() || "attachment";
out.push({ out.push({
name, name,
type: current.mapiType || guessType(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 { sanitizeEmailHtml, sanitizeEditorHtml } from "@/lib/text/html";
import { toast } from "@/ui/toast"; import { toast } from "@/ui/toast";
import { useMail, FULL_PROPS, BODY_PROPS } from "./mail"; import { useMail, FULL_PROPS, BODY_PROPS } from "./mail";
import { useSession } from "./session";
import { ensureScheduledMailbox, useScheduled } from "./scheduled"; import { ensureScheduledMailbox, useScheduled } from "./scheduled";
import { formatScheduleTime, holdUntil } from "@/lib/schedule"; import { formatScheduleTime, holdUntil } from "@/lib/schedule";
import { t as translate } from "@/lib/i18n"; import { t as translate } from "@/lib/i18n";
@@ -82,7 +83,9 @@ export interface Draft {
interface ComposeState { interface ComposeState {
drafts: Draft[]; drafts: Draft[];
activeKey: string | null; 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(init?: Partial<Draft>): string;
/** Open a draft holding what the operating system's share sheet sent us. */ /** Open a draft holding what the operating system's share sheet sent us. */
openFromShare(share: SharedContent): string; 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 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); 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) { undoSend(key) {
@@ -999,3 +1011,28 @@ export function draftFromMailto(url: string): Partial<Draft> {
...(body ? { html: body, text: m.body } : {}), ...(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 */ /* never block signing out over this */
} }
stopSettingsSync(); 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 { try {
await apiFetch("/api/auth/logout", { method: "POST" }); await apiFetch("/api/auth/logout", { method: "POST" });
} catch { } 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 { Code2, Download, Eye, Pencil, Printer, Save, Share2, X } from "lucide-react";
import { confirmDialog, Dialog } from "./dialog"; import { confirmDialog, Dialog } from "./dialog";
import { formatSize } from "@/lib/format"; import { formatSize } from "@/lib/format";
import { withoutBidiControls } from "@/lib/text/text";
import { previewKind, TEXT_PREVIEW_CHARS, TEXT_PREVIEW_MAX } from "@/lib/preview"; import { previewKind, TEXT_PREVIEW_CHARS, TEXT_PREVIEW_MAX } from "@/lib/preview";
import { isMarkdown, renderMarkdown } from "@/lib/text/markdown"; import { isMarkdown, renderMarkdown } from "@/lib/text/markdown";
import { canShareFiles, shareFile } from "@/lib/share"; import { canShareFiles, shareFile } from "@/lib/share";
@@ -168,7 +169,7 @@ export function FilePreviewDialog({
const download = () => { const download = () => {
const l = document.createElement("a"); const l = document.createElement("a");
l.href = file.url; l.href = file.url;
l.download = file.name; l.download = withoutBidiControls(file.name);
l.click(); l.click();
}; };
try { try {
@@ -226,7 +227,7 @@ export function FilePreviewDialog({
<Dialog <Dialog
open={Boolean(file)} open={Boolean(file)}
onClose={requestClose} onClose={requestClose}
title={file?.name ?? t("Preview")} title={file ? withoutBidiControls(file.name) : t("Preview")}
size="xl" size="xl"
closeOnBackdrop={!editing} closeOnBackdrop={!editing}
footer={ 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 { EMAIL_BASE_CSS, TEXT_EMAIL_CSS, hasHtmlAlternative, htmlDeclaresColors, markKeptSurfaces, sanitizeEmailHtml } from "@/lib/text/html";
import { openableInTab, previewKind } from "@/lib/preview"; import { openableInTab, previewKind } from "@/lib/preview";
import { FilePreviewDialog } from "@/ui/filepreview"; 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 { canShare, canShareFiles, shareFile, shareText } from "@/lib/share";
import { Avatar } from "@/ui/misc"; import { Avatar } from "@/ui/misc";
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover"; 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 blob = await client.fetchBlob(accountId, part.blobId, part.type);
const found = parseTnef(await blob.arrayBuffer()); const found = parseTnef(await blob.arrayBuffer());
setFiles(found); 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"); setState("done");
} catch { } catch {
setState("error"); setState("error");
@@ -859,7 +865,7 @@ function AttachmentList({ attachments, accountId, email }: { attachments: EmailB
*/ */
const shareAttachment = async (a: EmailBodyPart) => { const shareAttachment = async (a: EmailBodyPart) => {
if (!a.blobId) return; if (!a.blobId) return;
const name = a.name ?? "attachment"; const name = withoutBidiControls(a.name ?? "") || "attachment";
const download = () => { const download = () => {
const l = document.createElement("a"); const l = document.createElement("a");
l.href = client.downloadUrl(accountId, a.blobId!, name, a.type); l.href = client.downloadUrl(accountId, a.blobId!, name, a.type);
@@ -885,16 +891,17 @@ function AttachmentList({ attachments, accountId, email }: { attachments: EmailB
))} ))}
<div className="attachments"> <div className="attachments">
{attachments.map((a, i) => { {attachments.map((a, i) => {
const url = a.blobId ? client.downloadUrl(accountId, a.blobId, a.name ?? "attachment", a.type) : "#"; const name = a.name ? withoutBidiControls(a.name) : null;
const inlineUrl = a.blobId ? client.downloadUrl(accountId, a.blobId, a.name ?? "attachment", a.type, true) : "#"; 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 ( 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-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-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-size">{formatSize(a.size)}</span>
<span className="att-actions"> <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>} {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>} {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> </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> }) { function AppPasswords({ state, reload }: { state: SecurityState | null; reload: () => Promise<void> }) {
const [name, setName] = useState(""); const [name, setName] = useState("");
const [current, setCurrent] = useState("");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [issued, setIssued] = useState<{ description: string; secret: string } | null>(null); const [issued, setIssued] = useState<{ description: string; secret: string } | null>(null);
@@ -242,10 +243,11 @@ function AppPasswords({ state, reload }: { state: SecurityState | null; reload:
try { try {
const res = await apiFetch<{ id: string; secret: string }>("/api/account/app-passwords", { const res = await apiFetch<{ id: string; secret: string }>("/api/account/app-passwords", {
method: "POST", method: "POST",
body: JSON.stringify({ description: name }), body: JSON.stringify({ description: name, current }),
}); });
setIssued({ description: name, secret: res.secret }); setIssued({ description: name, secret: res.secret });
setName(""); setName("");
setCurrent("");
await reload(); await reload();
} catch (err) { } catch (err) {
toast.error((err as Error).message); 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> <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 /> <input id="ap-name" value={name} onChange={(e) => setName(e.target.value)} placeholder={t("Thunderbird on my laptop")} required />
</div> </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> </form>
<Dialog open={Boolean(issued)} onClose={() => setIssued(null)} title={t("Your new app password")} size="sm" <Dialog open={Boolean(issued)} onClose={() => setIssued(null)} title={t("Your new app password")} size="sm"