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.
80 lines
3.8 KiB
TypeScript
80 lines
3.8 KiB
TypeScript
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { SessionStore, accountKey } from "./sessions.js";
|
|
import { normalizeLocale } from "./upstream.js";
|
|
import { deriveKey, open, seal, sha256 } from "./crypto.js";
|
|
import { RateLimiter } from "./ratelimit.js";
|
|
import { randomBytes } from "node:crypto";
|
|
|
|
test("seal/open round-trips and rejects wrong key", () => {
|
|
const salt = randomBytes(16);
|
|
const k1 = deriveKey("cookie-secret", "app-secret", salt);
|
|
const k2 = deriveKey("other", "app-secret", salt);
|
|
const ct = seal("hello", k1);
|
|
assert.equal(open(ct, k1), "hello");
|
|
assert.equal(open(ct, k2), null);
|
|
assert.equal(sha256("a"), sha256("a"));
|
|
});
|
|
|
|
test("session store creates, resolves, and refuses tampered cookies", () => {
|
|
const store = new SessionStore("");
|
|
const { cookie, session } = store.create({ username: "[email protected]", password: "p4ss", remember: false, userAgent: "ua", ip: "127.0.0.1" });
|
|
assert.equal(session.username, "[email protected]");
|
|
const live = store.resolve(cookie);
|
|
assert.ok(live);
|
|
assert.equal(live!.authorization, `Basic ${Buffer.from("[email protected]:p4ss").toString("base64")}`);
|
|
assert.equal(store.resolve(cookie + "x"), null);
|
|
assert.equal(store.resolve("nope"), null);
|
|
assert.equal(store.listForUser("[email protected]").length, 1);
|
|
store.destroy(live!.id);
|
|
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: "" });
|
|
const json = JSON.stringify(store.listForUser("u"));
|
|
assert.ok(!json.includes("super-secret-pw"));
|
|
});
|
|
|
|
test("rate limiter blocks after max hits in window", () => {
|
|
const rl = new RateLimiter(3, 60_000);
|
|
assert.equal(rl.check("k"), true);
|
|
assert.equal(rl.check("k"), true);
|
|
assert.equal(rl.check("k"), true);
|
|
assert.equal(rl.check("k"), false);
|
|
assert.ok(rl.retryAfterSeconds("k") > 0);
|
|
rl.reset("k");
|
|
assert.equal(rl.check("k"), true);
|
|
});
|
|
|
|
test("normalizes Stalwart account locales to BCP-47 tags", () => {
|
|
assert.equal(normalizeLocale("de_DE"), "de-DE");
|
|
assert.equal(normalizeLocale("de_DE.UTF-8"), "de-DE");
|
|
assert.equal(normalizeLocale("ca_ES@valencia"), "ca-ES");
|
|
assert.equal(normalizeLocale("sr_RS@latin"), "sr-Latn-RS");
|
|
assert.equal(normalizeLocale("uz_UZ@cyrillic"), "uz-Cyrl-UZ");
|
|
assert.equal(normalizeLocale("ru_RU@cyrillic"), "ru-RU");
|
|
assert.equal(normalizeLocale("en"), "en");
|
|
assert.equal(normalizeLocale("POSIX"), null);
|
|
assert.equal(normalizeLocale("C"), null);
|
|
assert.equal(normalizeLocale(""), null);
|
|
assert.equal(normalizeLocale(undefined), null);
|
|
assert.equal(normalizeLocale({ locale: "de_DE" }), null);
|
|
assert.equal(normalizeLocale("../etc/passwd"), null);
|
|
});
|