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:
@@ -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 () => {
|
||||
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.match(created.body.secret, /^\$app\$/, "the server's generated secret is returned");
|
||||
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, []);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
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.body.error, "missing_fields");
|
||||
});
|
||||
|
||||
@@ -14,8 +14,18 @@ test("mail, calendars and the rest pass untouched", () => {
|
||||
assert.equal(r.ok, true);
|
||||
});
|
||||
|
||||
test("the account's own registry objects pass", () => {
|
||||
assert.equal(gateAdministration(req("x:AccountSettings/get", "x:AppPassword/set", "x:PublicKey/get", "x:MaskedEmail/set")).ok, true);
|
||||
test("the account's own registry objects can be read", () => {
|
||||
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", () => {
|
||||
|
||||
+10
-3
@@ -18,7 +18,7 @@
|
||||
* The standard JMAP methods (mail, calendars, contacts, files, sharing) are not
|
||||
* 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 };
|
||||
|
||||
@@ -83,8 +83,15 @@ export function gateAdministration(raw: string): GateResult {
|
||||
const name = Array.isArray(call) ? call[0] : undefined;
|
||||
if (typeof name !== "string") return { ok: false, method: null };
|
||||
if (!name.startsWith("x:")) continue;
|
||||
const object = name.slice(2).split("/")[0] ?? "";
|
||||
if (!SELF_SERVICE.has(object)) return { ok: false, method: name };
|
||||
const [object = "", op = ""] = name.slice(2).split("/");
|
||||
/*
|
||||
* 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) };
|
||||
}
|
||||
|
||||
+65
-10
@@ -11,9 +11,10 @@ import { getConnInfo } from "@hono/node-server/conninfo";
|
||||
import { config } from "./config.js";
|
||||
import { fetchPermissions } from "./permissionSchema.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 { rateLimitKey, resolveClientIp } from "./clientip.js";
|
||||
import { safeEqual } from "./crypto.js";
|
||||
import {
|
||||
type AccountInfo,
|
||||
UpstreamError,
|
||||
@@ -385,6 +386,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
loginLimiter.reset(limitKey);
|
||||
const { cookie, session } = sessions.create({
|
||||
username,
|
||||
account: accountKey(upstreamFor(username), upstream.username || username),
|
||||
password: effectivePassword,
|
||||
remember: Boolean(body.remember),
|
||||
userAgent: c.req.header("user-agent") ?? "",
|
||||
@@ -466,12 +468,12 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
|
||||
api.get("/auth/sessions", requireSession, (c) => {
|
||||
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) => {
|
||||
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 });
|
||||
});
|
||||
|
||||
@@ -499,8 +501,8 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
};
|
||||
|
||||
/** Guard the endpoints that check a password against brute-forcing. */
|
||||
const guarded = (c: Context<Env>): Response | null => {
|
||||
const key = `account|${c.get("session").username.toLowerCase()}`;
|
||||
const guarded = (c: Context<Env>, scope = "account"): Response | null => {
|
||||
const key = `${scope}|${c.get("session").username.toLowerCase()}`;
|
||||
if (accountLimiter.check(key)) return null;
|
||||
c.header("Retry-After", String(accountLimiter.retryAfterSeconds(key)));
|
||||
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();
|
||||
sessions.reseal(getCookie(c, config.cookieName), otpCode ? `${next}$${otpCode}` : next);
|
||||
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 });
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
// 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 body = await readJson<{ description?: string }>(c);
|
||||
const body = await readJson<{ description?: string; current?: string }>(c);
|
||||
if (!body) return c.json({ error: "bad_request" }, 400);
|
||||
const description = (body.description ?? "").trim().slice(0, 120);
|
||||
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 {
|
||||
return c.json(await createAppPassword(await accountCtx(c), { description }));
|
||||
} catch (err) {
|
||||
@@ -632,7 +648,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
if (sessionKept) forgetUpstreamSession(session.id);
|
||||
}
|
||||
// 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 });
|
||||
});
|
||||
|
||||
@@ -803,7 +819,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
const safeInline = inline && isInlineSafe(type);
|
||||
headers.set(
|
||||
"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");
|
||||
// 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 {
|
||||
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 });
|
||||
} catch (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. */
|
||||
function appPasswordName(c: Context): string {
|
||||
const ua = c.req.header("user-agent") ?? "";
|
||||
|
||||
@@ -217,7 +217,8 @@ export async function imageProxyHandler(c: Context) {
|
||||
res.on("close", done);
|
||||
const headers = new Headers({
|
||||
"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",
|
||||
"Content-Security-Policy": "sandbox; default-src 'none'",
|
||||
"Cross-Origin-Resource-Policy": "same-origin",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { SessionStore } from "./sessions.js";
|
||||
import { SessionStore, accountKey } from "./sessions.js";
|
||||
import { normalizeLocale } from "./upstream.js";
|
||||
import { deriveKey, open, seal, sha256 } from "./crypto.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);
|
||||
});
|
||||
|
||||
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", () => {
|
||||
const store = new SessionStore("");
|
||||
store.create({ username: "u", password: "super-secret-pw", remember: true, userAgent: "", ip: "" });
|
||||
|
||||
+33
-7
@@ -13,6 +13,8 @@ export interface StoredSession {
|
||||
/** sealed JSON {username, password} */
|
||||
sealedCredentials: string;
|
||||
username: string;
|
||||
/** Which account this is; see `accountKey`. Absent on sessions saved before it existed. */
|
||||
account?: string;
|
||||
createdAt: number;
|
||||
lastSeenAt: number;
|
||||
expiresAt: number;
|
||||
@@ -24,6 +26,8 @@ export interface StoredSession {
|
||||
export interface LiveSession {
|
||||
id: string;
|
||||
username: string;
|
||||
/** See `accountKey`. */
|
||||
account: string;
|
||||
/** Basic Authorization header value for upstream calls. */
|
||||
authorization: string;
|
||||
remember: boolean;
|
||||
@@ -46,8 +50,27 @@ export interface SessionSummary {
|
||||
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 {
|
||||
username: string;
|
||||
/** From `accountKey`; defaults to the lower-cased username. */
|
||||
account?: string;
|
||||
password: string;
|
||||
remember: boolean;
|
||||
userAgent: string;
|
||||
@@ -85,8 +108,9 @@ export interface SessionBackend {
|
||||
resolve(cookie: string | undefined): LiveSession | null;
|
||||
reseal(cookie: string | undefined, password: string): boolean;
|
||||
destroy(id: string): void;
|
||||
destroyAllForUser(username: string, exceptId?: string): number;
|
||||
listForUser(username: string): SessionSummary[];
|
||||
/** `account` is an `accountKey`, as carried on `LiveSession.account`. */
|
||||
destroyAllForUser(account: string, exceptId?: string): number;
|
||||
listForUser(account: string): SessionSummary[];
|
||||
}
|
||||
|
||||
const COOKIE_SEP = ".";
|
||||
@@ -172,6 +196,7 @@ export class SessionStore implements SessionBackend {
|
||||
salt: salt.toString("base64"),
|
||||
sealedCredentials: seal(JSON.stringify({ u: params.username, p: params.password }), key),
|
||||
username: params.username,
|
||||
account: params.account ?? params.username.trim().toLowerCase(),
|
||||
createdAt: now,
|
||||
lastSeenAt: now,
|
||||
expiresAt: now + ttl,
|
||||
@@ -248,10 +273,10 @@ export class SessionStore implements SessionBackend {
|
||||
if (this.sessions.delete(id)) this.scheduleSave();
|
||||
}
|
||||
|
||||
destroyAllForUser(username: string, exceptId?: string): number {
|
||||
destroyAllForUser(account: string, exceptId?: string): number {
|
||||
let n = 0;
|
||||
for (const [id, s] of this.sessions) {
|
||||
if (s.username === username && id !== exceptId) {
|
||||
if (accountOf(s) === account && id !== exceptId) {
|
||||
this.sessions.delete(id);
|
||||
n++;
|
||||
}
|
||||
@@ -260,11 +285,11 @@ export class SessionStore implements SessionBackend {
|
||||
return n;
|
||||
}
|
||||
|
||||
listForUser(username: string): SessionSummary[] {
|
||||
listForUser(account: string): SessionSummary[] {
|
||||
const out = [];
|
||||
for (const s of this.sessions.values()) {
|
||||
if (s.username !== username) continue;
|
||||
const { secretHash: _h, salt: _s, sealedCredentials: _c, ...rest } = s;
|
||||
if (accountOf(s) !== account) continue;
|
||||
const { secretHash: _h, salt: _s, sealedCredentials: _c, account: _a, ...rest } = s;
|
||||
out.push(rest);
|
||||
}
|
||||
return out;
|
||||
@@ -274,6 +299,7 @@ export class SessionStore implements SessionBackend {
|
||||
return {
|
||||
id: s.id,
|
||||
username,
|
||||
account: accountOf(s),
|
||||
authorization: `Basic ${Buffer.from(`${username}:${password}`, "utf8").toString("base64")}`,
|
||||
remember: s.remember,
|
||||
createdAt: s.createdAt,
|
||||
|
||||
Reference in New Issue
Block a user