Files
ihasmail/server/src/app.test.ts
T
jcoffey-dev 607afeb4ad Do not spend login attempts on an outage nobody caused
ihasmail runs in its own container, usually on its own host, so Stalwart being
briefly unreachable is an ordinary Tuesday. Sign-in handled it almost right:
a 401 is invalid_credentials, a timeout is 504 and anything else is 502, none
of which reads as a rejected password.

What it got wrong was the counting. RateLimiter.check() consumes an attempt
when it is called, and it is called before the upstream is contacted; reset()
only runs on success. So every try against an unreachable server burned a
credential attempt, and after ten of them the person was locked out for the
rest of the fifteen-minute window -- including after the server came back. A
thirty-second blip became a quarter-hour lockout, and the second failure was
entirely ihasmail's own doing.

A 401 is a judgement about the password and stays counted. A 502 or 504 is the
upstream failing to answer, says nothing about the credentials, and is now
refunded -- one attempt back, not the key cleared, so a run of real failures
with an outage in the middle still adds up. The old-server refusal refunds too:
those credentials were accepted.

Both guessing keys are refunded, not just the username one. Refunding only
that would not have fixed it -- ten retries still spend the per-address budget,
and behind one office NAT that budget belongs to the whole building, so a
company-wide outage would lock out the company.

Which needs a backstop, because "not counted" must not mean "unlimited": each
attempt still costs an outbound connection that may sit there until
UPSTREAM_TIMEOUT, and an outage is the one moment the endpoint is cheapest to
abuse. So there is a second ceiling per address, twenty times looser and never
refunded. A person retrying will not come near it; something hammering will.

Both messages now say the quiet part -- "This is not a problem with your
password" -- for somebody already worried they have forgotten it.

Closes #239.
2026-09-02 14:08:23 -07:00

147 lines
6.7 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");
});
/*
* #239: retrying through an outage must not lock somebody out of the recovery.
*
* STALWART_URL at the top of this file is 127.0.0.1:1 — nothing listens there,
* so every sign-in here is the outage case. Before the fix, the eleventh of
* these came back 429 and stayed 429 for fifteen minutes, outliving whatever
* had actually been wrong.
*/
test("an unreachable upstream does not spend login attempts", async () => {
const app = createApp();
const login = () =>
app.request("/api/auth/login", {
method: "POST",
headers: { "content-type": "application/json", "x-requested-with": "ihasmail" },
body: JSON.stringify({ username: "[email protected]", password: "hunter2" }),
});
// Comfortably past LOGIN_RATE_LIMIT, which defaults to 10.
for (let i = 0; i < 25; i++) {
const res = await login();
assert.notEqual(res.status, 429, `attempt ${i + 1} was rate limited`);
assert.ok(res.status === 502 || res.status === 504, `attempt ${i + 1} said ${res.status}`);
}
});
test("an unreachable upstream says it is not the password", async () => {
const app = createApp();
const res = await app.request("/api/auth/login", {
method: "POST",
headers: { "content-type": "application/json", "x-requested-with": "ihasmail" },
body: JSON.stringify({ username: "[email protected]", password: "hunter2" }),
});
const body = (await res.json()) as { error: string; message: string };
assert.notEqual(body.error, "invalid_credentials");
assert.match(body.message, /not a problem with your password/i);
});