A .md previewed as its own source, which is reading the punctuation rather than the notes. It now opens rendered, with Rendered | Source in the dialog footer for anyone who wants what the file actually says. Markdown only; a .txt has nothing to toggle between. Rendering is `marked`, sanitised by DOMPurify -- the one the app already carries for mail. Markdown is not a safe subset of anything: raw HTML passes through it by design, so a <script> in a file somebody uploaded or shared into the account is a script tag unless something takes it out. Images become links rather than pictures. An image in a Markdown file is either a relative path, which has no base to resolve against here, or a URL somewhere else, which fetches on open and tells that server the file was read -- the tracking pixel this app blocks in mail. The link keeps the alt text and the address, so nothing vanishes silently. Fixes the PDF preview while here, which never worked: securityHeaders put X-Frame-Options: DENY on every response including the blob route, so the iframe showed Chrome's "refused to connect" where the file should have been -- in Files today and in mail attachments long before that. The middleware now leaves a header the route has set, and a PDF served inline says SAMEORIGIN. Nothing else on the server is framable.
110 lines
5.2 KiB
TypeScript
110 lines
5.2 KiB
TypeScript
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
process.env.STALWART_URL = "http://127.0.0.1:1";
|
|
const { createApp } = await import("./app.js");
|
|
|
|
test("CSRF guard rejects API POSTs without the custom header", async () => {
|
|
const app = createApp();
|
|
const res = await app.request("/api/auth/login", { method: "POST", headers: { "content-type": "application/json" }, body: "{}" });
|
|
assert.equal(res.status, 403);
|
|
});
|
|
|
|
test("unauthenticated JMAP calls are rejected", async () => {
|
|
const app = createApp();
|
|
const res = await app.request("/api/jmap", { method: "POST", headers: { "content-type": "application/json", "x-requested-with": "ihasmail" }, body: "{}" });
|
|
assert.equal(res.status, 401);
|
|
});
|
|
|
|
test("cross-site fetches are rejected", async () => {
|
|
const app = createApp();
|
|
const res = await app.request("/api/health", { headers: { "sec-fetch-site": "cross-site" } });
|
|
assert.equal(res.status, 403);
|
|
});
|
|
|
|
test("health and security headers", async () => {
|
|
const app = createApp();
|
|
const res = await app.request("/api/health");
|
|
assert.equal(res.status, 200);
|
|
assert.equal(res.headers.get("x-content-type-options"), "nosniff");
|
|
assert.equal(res.headers.get("x-frame-options"), "DENY");
|
|
});
|
|
|
|
test("image proxy refuses private targets", async () => {
|
|
const app = createApp();
|
|
// no session -> 401 first; so exercise the handler directly via a logged-in-less path is not possible; check the URL validation ordering instead
|
|
const res = await app.request("/api/image?url=http://127.0.0.1/x");
|
|
assert.equal(res.status, 401);
|
|
});
|
|
|
|
test("a compressed upstream blob is not forwarded with the compressed length", async () => {
|
|
const { forwardedContentLength } = await import("./app.js");
|
|
// gzip: the body we forward has already been decompressed, so the length on
|
|
// the wire describes different bytes and must not be copied (issue #76).
|
|
const gz = new Headers({ "content-encoding": "gzip", "content-length": "384" });
|
|
assert.equal(forwardedContentLength(gz), null);
|
|
// identity, spelled out or absent: the length describes the body we send.
|
|
assert.equal(forwardedContentLength(new Headers({ "content-encoding": "identity", "content-length": "1157" })), "1157");
|
|
assert.equal(forwardedContentLength(new Headers({ "content-length": "1157" })), "1157");
|
|
assert.equal(forwardedContentLength(new Headers({ "content-encoding": "BR", "content-length": "384" })), null);
|
|
// Nothing to forward is not an error.
|
|
assert.equal(forwardedContentLength(new Headers()), null);
|
|
});
|
|
|
|
test("a Sieve script larger than a compressing hop's threshold survives the proxy", async () => {
|
|
const http = await import("node:http");
|
|
const zlib = await import("node:zlib");
|
|
const { forwardedContentLength } = await import("./app.js");
|
|
|
|
const script =
|
|
"# ihasmail filters v1 - edit with care; rules are stored in the `# rule:` comments\nrequire [\"fileinto\"];\n\n" +
|
|
["a", "b", "c"]
|
|
.map(
|
|
(k) =>
|
|
`# rule:{"id":"r${k}","name":"From ${k}@example.com","enabled":true,"join":"allof","tests":[{"type":"header","header":"from","op":"contains","value":"${k}@example.com"}],"actions":[{"type":"fileinto","mailbox":"INBOX/${k}"}]}\n` +
|
|
`if header :contains "from" "${k}@example.com"\n{\n fileinto "INBOX/${k}";\n}\n\n`,
|
|
)
|
|
.join("");
|
|
const gz = zlib.gzipSync(Buffer.from(script));
|
|
assert.ok(gz.length < Buffer.byteLength(script), "the script has to compress for this test to mean anything");
|
|
|
|
// A hop that compresses regardless of what we asked for.
|
|
const origin = http.createServer((_req, res) => {
|
|
res.writeHead(200, { "content-type": "application/sieve", "content-encoding": "gzip", "content-length": String(gz.length) });
|
|
res.end(gz);
|
|
});
|
|
await new Promise<void>((r) => origin.listen(0, () => r()));
|
|
const port = (origin.address() as { port: number }).port;
|
|
|
|
try {
|
|
const up = await fetch(`http://127.0.0.1:${port}/`);
|
|
// What the blob route forwards.
|
|
const headers = new Headers({ "content-type": "application/sieve; charset=utf-8" });
|
|
const cl = forwardedContentLength(up.headers);
|
|
if (cl) headers.set("Content-Length", cl);
|
|
const out = new Response(await up.arrayBuffer(), { status: 200, headers });
|
|
assert.equal(out.headers.get("content-length"), null);
|
|
assert.equal(await out.text(), script);
|
|
} finally {
|
|
origin.close();
|
|
}
|
|
});
|
|
|
|
test("only a PDF blob may be framed, and only by us", async () => {
|
|
/*
|
|
* The PDF preview is an iframe, and the blanket X-Frame-Options: DENY on
|
|
* every response blocked it -- the dialog showed Chrome's "refused to
|
|
* connect" where the file should have been. The middleware now leaves a
|
|
* header a route has already set, so this pins both halves: the exception
|
|
* exists, and it did not become the rule.
|
|
*/
|
|
const app = createApp();
|
|
const health = await app.request("/api/health");
|
|
assert.equal(health.headers.get("x-frame-options"), "DENY");
|
|
|
|
const { securityHeadersFor } = await import("./app.js");
|
|
assert.equal(securityHeadersFor("application/pdf", true), "SAMEORIGIN");
|
|
assert.equal(securityHeadersFor("application/pdf", false), "DENY");
|
|
assert.equal(securityHeadersFor("image/png", true), "DENY");
|
|
assert.equal(securityHeadersFor("text/html", true), "DENY");
|
|
});
|