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.
184 lines
7.8 KiB
TypeScript
184 lines
7.8 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { isTnef, parseTnef } from "@/lib/tnef";
|
|
|
|
/**
|
|
* The blobs below are **built from the format description, not captured from
|
|
* Outlook**. That is worth saying plainly: they prove the parser reads what the
|
|
* spec says, and they cannot prove it reads what Outlook actually emits. The
|
|
* cases most likely to differ in the wild are the MAPI property stream, where
|
|
* real messages carry many more properties than these do, and named properties
|
|
* (id >= 0x8000), which the parser stops at rather than guessing past.
|
|
*/
|
|
|
|
const SIGNATURE = 0x223e9f78;
|
|
|
|
const ATT = {
|
|
attachRenddata: 0x00069002,
|
|
attachTitle: 0x00018010,
|
|
attachData: 0x0006800f,
|
|
attachment: 0x00069005,
|
|
tnefVersion: 0x00089006,
|
|
} as const;
|
|
|
|
const sum16 = (b: number[]) => b.reduce((a, x) => (a + x) & 0xffff, 0);
|
|
const u16 = (v: number) => [v & 0xff, (v >> 8) & 0xff];
|
|
const u32 = (v: number) => [v & 0xff, (v >> 8) & 0xff, (v >> 16) & 0xff, (v >>> 24) & 0xff];
|
|
const ascii = (s: string) => [...s].map((c) => c.charCodeAt(0));
|
|
const utf16 = (s: string) => [...s].flatMap((c) => u16(c.charCodeAt(0)));
|
|
|
|
interface Attr {
|
|
level?: number;
|
|
id: number;
|
|
data: number[];
|
|
/** Deliberately wrong, for the desync case. */
|
|
badChecksum?: boolean;
|
|
}
|
|
|
|
function tnef(attrs: Attr[], opts: { signature?: number } = {}): Uint8Array {
|
|
const out: number[] = [...u32(opts.signature ?? SIGNATURE), ...u16(0x1234)];
|
|
for (const a of attrs) {
|
|
out.push(a.level ?? 2, ...u32(a.id), ...u32(a.data.length), ...a.data, ...u16(a.badChecksum ? (sum16(a.data) + 1) & 0xffff : sum16(a.data)));
|
|
}
|
|
return new Uint8Array(out);
|
|
}
|
|
|
|
/** A MAPI property stream carrying the given string properties. */
|
|
function mapi(props: Array<{ id: number; type: number; value: string }>): number[] {
|
|
const out: number[] = [...u32(props.length)];
|
|
for (const p of props) {
|
|
out.push(...u32(((p.id & 0xffff) << 16) | (p.type & 0xffff)));
|
|
const bytes = p.type === 0x001f ? [...utf16(p.value), 0, 0] : [...ascii(p.value), 0];
|
|
out.push(...u32(bytes.length), ...bytes);
|
|
const pad = (4 - (bytes.length % 4)) % 4;
|
|
for (let i = 0; i < pad; i++) out.push(0);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
const file = (name: string, body: string, extra: Attr[] = []): Attr[] => [
|
|
{ id: ATT.attachRenddata, data: new Array(14).fill(0) },
|
|
{ id: ATT.attachTitle, data: [...ascii(name), 0] },
|
|
...extra,
|
|
{ id: ATT.attachData, data: ascii(body) },
|
|
];
|
|
|
|
const text = (a: Uint8Array) => new TextDecoder().decode(a);
|
|
|
|
describe("isTnef", () => {
|
|
it("recognizes the types and the filename", () => {
|
|
expect(isTnef("application/ms-tnef", null)).toBe(true);
|
|
expect(isTnef("application/vnd.ms-tnef; name=winmail.dat", null)).toBe(true);
|
|
expect(isTnef("application/octet-stream", "winmail.dat")).toBe(true);
|
|
expect(isTnef("application/octet-stream", "WINMAIL.DAT")).toBe(true);
|
|
});
|
|
|
|
it("leaves everything else alone", () => {
|
|
expect(isTnef("application/pdf", "report.pdf")).toBe(false);
|
|
expect(isTnef(null, null)).toBe(false);
|
|
});
|
|
});
|
|
|
|
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);
|
|
expect(out[0]!.name).toBe("report.pdf");
|
|
expect(text(out[0]!.data)).toBe("hello");
|
|
expect(out[0]!.size).toBe(5);
|
|
// Guessed from the extension, since this blob names no type of its own.
|
|
expect(out[0]!.type).toBe("application/pdf");
|
|
});
|
|
|
|
it("pulls several out, in order", () => {
|
|
const out = parseTnef(tnef([...file("a.txt", "one"), ...file("b.png", "two"), ...file("c.zip", "three")]));
|
|
expect(out.map((a) => a.name)).toEqual(["a.txt", "b.png", "c.zip"]);
|
|
expect(out.map((a) => text(a.data))).toEqual(["one", "two", "three"]);
|
|
expect(out.map((a) => a.type)).toEqual(["text/plain", "image/png", "application/zip"]);
|
|
});
|
|
|
|
it("prefers the long filename over the 8.3 one", () => {
|
|
// The whole reason for reading the MAPI stream at all.
|
|
const attrs = file("QUARTE~1.DOC", "body", [
|
|
{ id: ATT.attachment, data: mapi([{ id: 0x3707, type: 0x001e, value: "Quarterly Report Final.docx" }]) },
|
|
]);
|
|
expect(parseTnef(tnef(attrs))[0]!.name).toBe("Quarterly Report Final.docx");
|
|
});
|
|
|
|
it("reads a unicode long filename", () => {
|
|
const attrs = file("SHORT~1.DOC", "body", [
|
|
{ id: ATT.attachment, data: mapi([{ id: 0x3707, type: 0x001f, value: "四半期報告.docx" }]) },
|
|
]);
|
|
expect(parseTnef(tnef(attrs))[0]!.name).toBe("四半期報告.docx");
|
|
});
|
|
|
|
it("takes the MIME type the blob states over one guessed from the name", () => {
|
|
const attrs = file("data.bin", "body", [
|
|
{ id: ATT.attachment, data: mapi([{ id: 0x370e, type: 0x001e, value: "image/webp" }]) },
|
|
]);
|
|
expect(parseTnef(tnef(attrs))[0]!.type).toBe("image/webp");
|
|
});
|
|
|
|
it("falls back to octet-stream for a name that says nothing", () => {
|
|
expect(parseTnef(tnef(file("mystery", "x")))[0]!.type).toBe("application/octet-stream");
|
|
});
|
|
|
|
it("names an attachment that carries no title at all", () => {
|
|
const out = parseTnef(tnef([{ id: ATT.attachRenddata, data: new Array(14).fill(0) }, { id: ATT.attachData, data: ascii("x") }]));
|
|
expect(out[0]!.name).toBe("attachment");
|
|
});
|
|
|
|
it("ignores attributes it has no use for", () => {
|
|
const out = parseTnef(tnef([{ level: 1, id: ATT.tnefVersion, data: u32(0x00010000) }, ...file("a.txt", "one")]));
|
|
expect(out.map((a) => a.name)).toEqual(["a.txt"]);
|
|
});
|
|
|
|
it("is not TNEF, and says so quietly", () => {
|
|
// The caller gets here by guessing from a filename, so this is an ordinary
|
|
// answer rather than an error worth showing anybody.
|
|
expect(parseTnef(new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]))).toEqual([]);
|
|
expect(parseTnef(tnef(file("a.txt", "x"), { signature: 0xdeadbeef }))).toEqual([]);
|
|
expect(parseTnef(new Uint8Array([]))).toEqual([]);
|
|
});
|
|
|
|
it("keeps what it read when the stream goes out of step", () => {
|
|
// Half the attachments beats none: the alternative is a reader who can see
|
|
// the file is there and cannot have it.
|
|
const bad = tnef([...file("good.txt", "kept"), { id: ATT.attachRenddata, data: new Array(14).fill(0), badChecksum: true }, ...file("lost.txt", "gone")]);
|
|
const out = parseTnef(bad);
|
|
expect(out.map((a) => a.name)).toEqual(["good.txt"]);
|
|
});
|
|
|
|
it("keeps what it read when the blob is truncated mid-attribute", () => {
|
|
const full = tnef([...file("good.txt", "kept"), ...file("cut.txt", "partial")]);
|
|
const out = parseTnef(full.slice(0, full.length - 12));
|
|
expect(out.map((a) => a.name)).toEqual(["good.txt"]);
|
|
});
|
|
|
|
it("stops at a named property rather than guessing past it", () => {
|
|
// A named property carries a GUID before its value; the stream cannot be
|
|
// trusted to stay aligned past one, so the long name is simply not found.
|
|
const attrs = file("SHORT~1.DOC", "body", [
|
|
{ id: ATT.attachment, data: mapi([{ id: 0x8001, type: 0x001e, value: "whatever" }, { id: 0x3707, type: 0x001e, value: "Long Name.docx" }]) },
|
|
]);
|
|
expect(parseTnef(tnef(attrs))[0]!.name).toBe("SHORT~1.DOC");
|
|
});
|
|
|
|
it("survives a MAPI stream that is nonsense, keeping the attachment", () => {
|
|
const attrs = file("keep.txt", "body", [{ id: ATT.attachment, data: [...u32(0xffff), 1, 2, 3] }]);
|
|
const out = parseTnef(tnef(attrs));
|
|
expect(out).toHaveLength(1);
|
|
expect(out[0]!.name).toBe("keep.txt");
|
|
});
|
|
|
|
it("drops an attachment that has a name but no data", () => {
|
|
const out = parseTnef(tnef([{ id: ATT.attachRenddata, data: new Array(14).fill(0) }, { id: ATT.attachTitle, data: [...ascii("empty.txt"), 0] }]));
|
|
expect(out).toEqual([]);
|
|
});
|
|
});
|