ihasmail spoke to two generations of Stalwart that are less alike than their version numbers suggest: 0.16 replaced the REST management API with JMAP registry objects, changed the shape of FileNode, split its rights up, and moved configuration into the store. Carrying both meant 34 branch points across nine files, a 92-line compatibility shim whose only job was telling them apart, a parallel REST implementation of every credential operation, and a mock that had to model both. The branches were not the real cost. The cost was that a wrong answer about which generation had answered always had somewhere to fall back to, so it failed quietly rather than loudly: one capability looked for in the wrong place downgraded every real 0.16 server onto the 0.15 path, which posted the current password to an endpoint 0.16 had removed, reported the wrong generation on About, and ran Files on the older code. It reached production and was recorded as verified when it was not. The mock mirrored the same wrong placement, which is why the tests agreed. Removed: the filenode compatibility shim, the dual "registry" | "legacy" backend in account.ts, the pre-0.16 generation in AccountInfo and everything that read it, the mock's LEGACY mode and dev:mock:legacy, and the three test files that existed only to pin 0.15 behaviour. Sign-in now refuses an older server by name, once, rather than letting Files, the account locale and credentials each fail in their own way with nothing connecting them. It says the credentials were fine -- someone hitting this has typed a correct password, and telling them otherwise sends them round in circles -- and names the tag to build from. Four tests cover it, including that no session cookie is minted and that bad credentials on such a server are still a plain 401. Two fallbacks went that were not strictly about 0.15, and both for the same reason the removal is happening. Files no longer answers a refused filter or sort by fetching every node in the account, which would hide a real fault behind a performance cliff nobody would notice. And the app folder lookups now filter on parentId/isTopLevel alone and match names client-side, since `name` is not a filter Stalwart is known to implement and one it does not know fails the whole query rather than being ignored. The last release that runs on 0.15 is tagged stalwart-0.15-support. Verified against the mock end to end: sign-in, the Files tree on the 0.16 path with the app folder hidden, and self-service credentials over the registry. 226 web + 75 server tests pass; typecheck and build clean.
657 lines
25 KiB
TypeScript
657 lines
25 KiB
TypeScript
import { Hono } from "hono";
|
|
import type { Context, MiddlewareHandler } from "hono";
|
|
import { getCookie, setCookie, deleteCookie } from "hono/cookie";
|
|
import { getConnInfo } from "@hono/node-server/conninfo";
|
|
import { config } from "./config.js";
|
|
import { SessionStore, type LiveSession } from "./sessions.js";
|
|
import { RateLimiter } from "./ratelimit.js";
|
|
import { resolveClientIp } from "./clientip.js";
|
|
import {
|
|
type AccountInfo,
|
|
UpstreamError,
|
|
absoluteUpstream,
|
|
expandTemplate,
|
|
fetchUpstreamSession,
|
|
hasStalwartRegistry,
|
|
forgetUpstreamSession,
|
|
getAccountInfo,
|
|
getUpstreamSession,
|
|
localizeSession,
|
|
} from "./upstream.js";
|
|
import {
|
|
AccountError,
|
|
assertEnrolmentCode,
|
|
beginOtpEnrolment,
|
|
changePassword,
|
|
createAppPassword,
|
|
disableOtp,
|
|
enableOtp,
|
|
getState,
|
|
revokeAppPassword,
|
|
} from "./account.js";
|
|
import { imageProxyHandler } from "./imageproxy.js";
|
|
import { staticHandler } from "./static.js";
|
|
|
|
type Env = { Variables: { session: LiveSession } };
|
|
|
|
export const sessions = new SessionStore(config.sessionFile);
|
|
const loginLimiter = new RateLimiter(config.loginRateLimit, 15 * 60_000);
|
|
/**
|
|
* Credential changes verify the current password upstream, and Stalwart's
|
|
* fail2ban counts those failures against the *caller's* IP — which for a proxy
|
|
* is shared by every user. Keep our own lid on it so one person guessing
|
|
* cannot get the whole deployment banned.
|
|
*/
|
|
const accountLimiter = new RateLimiter(10, 15 * 60_000);
|
|
|
|
const HOP_BY_HOP = new Set([
|
|
"connection",
|
|
"keep-alive",
|
|
"proxy-authenticate",
|
|
"proxy-authorization",
|
|
"te",
|
|
"trailer",
|
|
"transfer-encoding",
|
|
"upgrade",
|
|
"content-encoding",
|
|
"content-length",
|
|
]);
|
|
|
|
export function clientIp(c: Context): string {
|
|
let peer = "unknown";
|
|
try {
|
|
peer = getConnInfo(c).remote.address ?? "unknown";
|
|
} catch {
|
|
/* no socket information available */
|
|
}
|
|
return resolveClientIp(peer, { forwardedFor: c.req.header("x-forwarded-for"), realIp: c.req.header("x-real-ip") }, config);
|
|
}
|
|
|
|
function isSecureRequest(c: Context): boolean {
|
|
if (config.secureCookies === "1" || config.secureCookies === "true") return true;
|
|
if (config.secureCookies === "0" || config.secureCookies === "false") return false;
|
|
if (config.trustProxy) {
|
|
const proto = c.req.header("x-forwarded-proto");
|
|
if (proto) return proto.split(",")[0]!.trim() === "https";
|
|
}
|
|
return new URL(c.req.url).protocol === "https:";
|
|
}
|
|
|
|
/** Security headers for every response. */
|
|
const securityHeaders: MiddlewareHandler = async (c, next) => {
|
|
await next();
|
|
const h = c.res.headers;
|
|
h.set("X-Content-Type-Options", "nosniff");
|
|
h.set("X-Frame-Options", "DENY");
|
|
h.set("Referrer-Policy", "no-referrer");
|
|
h.set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=(), usb=()");
|
|
h.set("Cross-Origin-Opener-Policy", "same-origin");
|
|
if (!h.has("Cache-Control")) h.set("Cache-Control", "no-store");
|
|
if (isSecureRequest(c)) h.set("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
|
|
};
|
|
|
|
/** CSRF: require our custom header on all API calls; reject cross-site fetches. */
|
|
const csrfGuard: MiddlewareHandler = async (c, next) => {
|
|
const site = c.req.header("sec-fetch-site");
|
|
if (site && site !== "same-origin" && site !== "none") {
|
|
return c.json({ error: "cross_site_request" }, 403);
|
|
}
|
|
if (c.req.method !== "GET" && c.req.method !== "HEAD") {
|
|
if (c.req.header("x-requested-with") !== "ihasmail") {
|
|
return c.json({ error: "missing_csrf_header" }, 403);
|
|
}
|
|
}
|
|
await next();
|
|
};
|
|
|
|
const requireSession: MiddlewareHandler<Env> = async (c, next) => {
|
|
const cookie = getCookie(c, config.cookieName);
|
|
const session = sessions.resolve(cookie);
|
|
if (!session) {
|
|
return c.json({ error: "unauthenticated" }, 401);
|
|
}
|
|
c.set("session", session);
|
|
await next();
|
|
};
|
|
|
|
function setSessionCookie(c: Context, value: string, remember: boolean) {
|
|
setCookie(c, config.cookieName, value, {
|
|
httpOnly: true,
|
|
sameSite: "Lax",
|
|
secure: isSecureRequest(c),
|
|
path: "/",
|
|
...(remember ? { maxAge: config.sessionRememberTtl } : {}),
|
|
});
|
|
}
|
|
|
|
function upstreamFailure(c: Context, err: unknown) {
|
|
if (err instanceof UpstreamError) {
|
|
return c.json({ error: err.status === 401 ? "invalid_credentials" : "upstream_error", message: err.message }, err.status as 401 | 502);
|
|
}
|
|
const name = (err as Error)?.name ?? "";
|
|
if (name === "TimeoutError" || name === "AbortError") {
|
|
return c.json({ error: "upstream_timeout", message: "The mail server did not respond in time" }, 504);
|
|
}
|
|
console.error("[ihasmail] upstream failure:", err);
|
|
return c.json({ error: "upstream_error", message: "Could not reach the mail server" }, 502);
|
|
}
|
|
|
|
export function createApp(): Hono<Env> {
|
|
const app = new Hono<Env>();
|
|
app.use("*", securityHeaders);
|
|
|
|
const api = new Hono<Env>();
|
|
api.use("*", csrfGuard);
|
|
|
|
api.get("/health", (c) => c.json({ ok: true, name: config.appName, version: "2.0.0" }));
|
|
|
|
api.get("/config", (c) =>
|
|
c.json({
|
|
appName: config.appName,
|
|
sourceUrl: config.sourceUrl,
|
|
imageProxy: config.imageProxy,
|
|
maxUploadBytes: config.maxUploadBytes,
|
|
}),
|
|
);
|
|
|
|
// ---------- Auth ----------
|
|
api.post("/auth/login", async (c) => {
|
|
const ip = clientIp(c);
|
|
let body: { username?: string; password?: string; totp?: string; remember?: boolean };
|
|
try {
|
|
body = await c.req.json();
|
|
} catch {
|
|
return c.json({ error: "bad_request" }, 400);
|
|
}
|
|
const username = (body.username ?? "").trim();
|
|
const password = body.password ?? "";
|
|
const totp = (body.totp ?? "").trim();
|
|
if (!username || !password) return c.json({ error: "missing_credentials" }, 400);
|
|
if (username.length > 320 || password.length > 1024) return c.json({ error: "bad_request" }, 400);
|
|
|
|
const limitKey = `${ip}|${username.toLowerCase()}`;
|
|
if (!loginLimiter.check(limitKey) || !loginLimiter.check(ip)) {
|
|
c.header("Retry-After", String(loginLimiter.retryAfterSeconds(limitKey)));
|
|
return c.json({ error: "rate_limited", message: "Too many login attempts. Please wait and try again." }, 429);
|
|
}
|
|
|
|
// Stalwart accepts TOTP codes appended to the password as "password$123456".
|
|
const effectivePassword = totp ? `${password}$${totp}` : password;
|
|
const authorization = `Basic ${Buffer.from(`${username}:${effectivePassword}`, "utf8").toString("base64")}`;
|
|
try {
|
|
const upstream = await fetchUpstreamSession(authorization);
|
|
// ihasmail requires Stalwart 0.16 or newer. Refuse here, once and
|
|
// clearly, rather than signing someone in and letting Files, the account
|
|
// locale and self-service credentials each fail in their own way with
|
|
// nothing to connect them. The credentials were good, so say so.
|
|
if (!hasStalwartRegistry(upstream)) {
|
|
return c.json(
|
|
{
|
|
error: "unsupported_server",
|
|
message:
|
|
"Your credentials are fine, but this mail server is older than Stalwart 0.16, which ihasmail needs. Upgrade the server, or run the release tagged stalwart-0.15-support.",
|
|
},
|
|
501,
|
|
);
|
|
}
|
|
loginLimiter.reset(limitKey);
|
|
const { cookie, session } = sessions.create({
|
|
username,
|
|
password: effectivePassword,
|
|
remember: Boolean(body.remember),
|
|
userAgent: c.req.header("user-agent") ?? "",
|
|
ip,
|
|
});
|
|
setSessionCookie(c, cookie, session.remember);
|
|
const info = await getAccountInfo(session.id, session.authorization, upstream);
|
|
return c.json(localizeSession(upstream, sessionExtras(session, info)));
|
|
} catch (err) {
|
|
return upstreamFailure(c, err);
|
|
}
|
|
});
|
|
|
|
api.get("/auth/session", requireSession, async (c) => {
|
|
const session = c.get("session");
|
|
try {
|
|
const upstream = await getUpstreamSession(session.id, session.authorization, c.req.query("refresh") === "1");
|
|
const info = await getAccountInfo(session.id, session.authorization, upstream);
|
|
return c.json(localizeSession(upstream, sessionExtras(session, info)));
|
|
} catch (err) {
|
|
if (err instanceof UpstreamError && err.status === 401) {
|
|
sessions.destroy(session.id);
|
|
deleteCookie(c, config.cookieName, { path: "/" });
|
|
}
|
|
return upstreamFailure(c, err);
|
|
}
|
|
});
|
|
|
|
api.post("/auth/logout", async (c) => {
|
|
const cookie = getCookie(c, config.cookieName);
|
|
const session = sessions.resolve(cookie);
|
|
if (session) {
|
|
sessions.destroy(session.id);
|
|
forgetUpstreamSession(session.id);
|
|
}
|
|
deleteCookie(c, config.cookieName, { path: "/" });
|
|
return c.json({ ok: true });
|
|
});
|
|
|
|
api.get("/auth/sessions", requireSession, (c) => {
|
|
const session = c.get("session");
|
|
return c.json({ current: session.id, sessions: sessions.listForUser(session.username) });
|
|
});
|
|
|
|
api.post("/auth/sessions/revoke-others", requireSession, (c) => {
|
|
const session = c.get("session");
|
|
const n = sessions.destroyAllForUser(session.username, session.id);
|
|
return c.json({ revoked: n });
|
|
});
|
|
|
|
// ---------- Self-service credentials ----------
|
|
/**
|
|
* Password, app passwords and 2FA. These live on the server rather than in
|
|
* the browser because changing a credential means re-sealing the session
|
|
* cookie that holds it, and because the browser only ever sees /api/jmap.
|
|
*/
|
|
const accountCtx = async (c: Context<Env>) => {
|
|
const session = c.get("session");
|
|
const upstream = await getUpstreamSession(session.id, session.authorization);
|
|
return { authorization: session.authorization, session: upstream, username: session.username };
|
|
};
|
|
|
|
const accountFailure = (c: Context, err: unknown) => {
|
|
if (err instanceof AccountError) {
|
|
return c.json({ error: err.code, message: err.message }, err.status as 400);
|
|
}
|
|
return upstreamFailure(c, err);
|
|
};
|
|
|
|
/** 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()}`;
|
|
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);
|
|
};
|
|
|
|
api.get("/account/security", requireSession, async (c) => {
|
|
const session = c.get("session");
|
|
try {
|
|
return c.json(await getState(await accountCtx(c)));
|
|
} catch (err) {
|
|
return accountFailure(c, err);
|
|
}
|
|
});
|
|
|
|
api.post("/account/password", requireSession, async (c) => {
|
|
const limited = guarded(c);
|
|
if (limited) return limited;
|
|
const session = c.get("session");
|
|
const body = await readJson<{ current?: string; next?: string; otpCode?: string }>(c);
|
|
if (!body) return c.json({ error: "bad_request" }, 400);
|
|
const current = body.current ?? "";
|
|
const next = body.next ?? "";
|
|
if (!current || !next) return c.json({ error: "missing_fields", message: "Both passwords are required." }, 400);
|
|
if (next.length > 1024) return c.json({ error: "bad_request" }, 400);
|
|
if (next === current) {
|
|
return c.json({ error: "unchanged", message: "The new password matches the old one." }, 400);
|
|
}
|
|
try {
|
|
await changePassword(await accountCtx(c), { current, next, otpCode: body.otpCode?.trim() || undefined });
|
|
} catch (err) {
|
|
return accountFailure(c, err);
|
|
}
|
|
// The old password is now dead: re-seal this session with the new one and
|
|
// drop the others, whose sealed copies would fail on their next call.
|
|
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);
|
|
return c.json({ ok: true, revokedSessions: revoked });
|
|
});
|
|
|
|
api.get("/account/app-passwords", requireSession, async (c) => {
|
|
const session = c.get("session");
|
|
try {
|
|
const state = await getState(await accountCtx(c));
|
|
return c.json({ appPasswords: state.appPasswords });
|
|
} catch (err) {
|
|
return accountFailure(c, err);
|
|
}
|
|
});
|
|
|
|
api.post("/account/app-passwords", requireSession, async (c) => {
|
|
const session = c.get("session");
|
|
const body = await readJson<{ description?: 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);
|
|
try {
|
|
return c.json(await createAppPassword(await accountCtx(c), { description }));
|
|
} catch (err) {
|
|
return accountFailure(c, err);
|
|
}
|
|
});
|
|
|
|
api.post("/account/app-passwords/revoke", requireSession, async (c) => {
|
|
const session = c.get("session");
|
|
const body = await readJson<{ id?: string }>(c);
|
|
if (!body?.id) return c.json({ error: "bad_request" }, 400);
|
|
try {
|
|
await revokeAppPassword(await accountCtx(c), body.id);
|
|
return c.json({ ok: true });
|
|
} catch (err) {
|
|
return accountFailure(c, err);
|
|
}
|
|
});
|
|
|
|
api.post("/account/2fa/begin", requireSession, async (c) => {
|
|
try {
|
|
// Nothing is stored yet; the client hands the URL back to confirm.
|
|
return c.json(beginOtpEnrolment(await accountCtx(c)));
|
|
} catch (err) {
|
|
return accountFailure(c, err);
|
|
}
|
|
});
|
|
|
|
api.post("/account/2fa/enable", requireSession, async (c) => {
|
|
const limited = guarded(c);
|
|
if (limited) return limited;
|
|
const session = c.get("session");
|
|
const body = await readJson<{ url?: string; code?: string; current?: string }>(c);
|
|
if (!body?.url || !body.code || !body.current) return c.json({ error: "bad_request" }, 400);
|
|
const ctx = await accountCtx(c);
|
|
const code = body.code.trim();
|
|
/*
|
|
* Every proxied call re-authenticates with the stored password, and once
|
|
* 2FA is on the server wants a fresh TOTP code alongside it — which we
|
|
* cannot produce between requests. An app password authenticates without
|
|
* one, so the session moves onto a dedicated app password rather than
|
|
* being signed out the moment 2FA is switched on.
|
|
*
|
|
* Order matters: mint it while the current credential still works, since
|
|
* the moment 2FA is enabled this session can no longer authenticate at all.
|
|
*/
|
|
try {
|
|
assertEnrolmentCode(body.url, code);
|
|
} catch (err) {
|
|
return accountFailure(c, err);
|
|
}
|
|
let app: { id: string; secret: string } | null = null;
|
|
try {
|
|
app = await createAppPassword(ctx, { description: appPasswordName(c) });
|
|
} catch (err) {
|
|
// Out of app-password quota, say. 2FA is still worth having; the user
|
|
// just has to sign in again afterwards.
|
|
console.warn("[ihasmail] could not mint a session app password:", (err as Error).message);
|
|
}
|
|
try {
|
|
await enableOtp(ctx, { url: body.url, code, current: body.current });
|
|
} catch (err) {
|
|
if (app) {
|
|
// Don't leave a credential behind for a change that never happened.
|
|
await revokeAppPassword(ctx, app.id).catch(() => {});
|
|
}
|
|
return accountFailure(c, err);
|
|
}
|
|
let sessionKept = false;
|
|
if (app) {
|
|
sessionKept = sessions.reseal(getCookie(c, config.cookieName), app.secret);
|
|
if (sessionKept) forgetUpstreamSession(session.id);
|
|
}
|
|
// Other sessions still hold the bare password and will be refused.
|
|
const revoked = sessions.destroyAllForUser(session.username, session.id);
|
|
return c.json({ ok: true, sessionKept, revokedSessions: revoked });
|
|
});
|
|
|
|
api.post("/account/2fa/disable", requireSession, async (c) => {
|
|
const limited = guarded(c);
|
|
if (limited) return limited;
|
|
const session = c.get("session");
|
|
const body = await readJson<{ current?: string; code?: string }>(c);
|
|
if (!body?.current || !body.code) return c.json({ error: "bad_request" }, 400);
|
|
try {
|
|
await disableOtp(await accountCtx(c), { current: body.current, code: body.code.trim() });
|
|
} catch (err) {
|
|
return accountFailure(c, err);
|
|
}
|
|
// This session may be running on the app password minted when 2FA went on;
|
|
// the plain password works again now, so put it back.
|
|
sessions.reseal(getCookie(c, config.cookieName), body.current);
|
|
forgetUpstreamSession(session.id);
|
|
return c.json({ ok: true });
|
|
});
|
|
|
|
// ---------- JMAP API proxy ----------
|
|
api.post("/jmap", requireSession, async (c) => {
|
|
const session = c.get("session");
|
|
const ct = c.req.header("content-type") ?? "";
|
|
if (!ct.toLowerCase().startsWith("application/json")) {
|
|
return c.json({ error: "unsupported_media_type" }, 415);
|
|
}
|
|
try {
|
|
const upstream = await getUpstreamSession(session.id, session.authorization);
|
|
const res = await fetch(absoluteUpstream(upstream.apiUrl), {
|
|
method: "POST",
|
|
headers: {
|
|
authorization: session.authorization,
|
|
"content-type": "application/json",
|
|
accept: "application/json",
|
|
},
|
|
body: c.req.raw.body,
|
|
duplex: "half",
|
|
signal: AbortSignal.timeout(config.upstreamTimeout),
|
|
});
|
|
if (res.status === 401) {
|
|
sessions.destroy(session.id);
|
|
forgetUpstreamSession(session.id);
|
|
deleteCookie(c, config.cookieName, { path: "/" });
|
|
return c.json({ error: "unauthenticated" }, 401);
|
|
}
|
|
return passthrough(res);
|
|
} catch (err) {
|
|
return upstreamFailure(c, err);
|
|
}
|
|
});
|
|
|
|
// ---------- Blob upload ----------
|
|
api.post("/upload/:accountId", requireSession, async (c) => {
|
|
const session = c.get("session");
|
|
const accountId = c.req.param("accountId");
|
|
const len = Number(c.req.header("content-length") ?? "0");
|
|
if (len > config.maxUploadBytes) return c.json({ error: "too_large" }, 413);
|
|
// content-length is absent on a chunked request, so the header alone is a
|
|
// suggestion; count the bytes as they go past.
|
|
const body = c.req.raw.body ? c.req.raw.body.pipeThrough(byteCap(config.maxUploadBytes)) : null;
|
|
try {
|
|
const upstream = await getUpstreamSession(session.id, session.authorization);
|
|
const url = absoluteUpstream(expandTemplate(upstream.uploadUrl, { accountId }));
|
|
const res = await fetch(url, {
|
|
method: "POST",
|
|
headers: {
|
|
authorization: session.authorization,
|
|
"content-type": c.req.header("content-type") ?? "application/octet-stream",
|
|
accept: "application/json",
|
|
},
|
|
body,
|
|
duplex: "half",
|
|
signal: AbortSignal.timeout(Math.max(config.upstreamTimeout, 5 * 60_000)),
|
|
});
|
|
return passthrough(res);
|
|
} catch (err) {
|
|
return upstreamFailure(c, err);
|
|
}
|
|
});
|
|
|
|
// ---------- Blob download ----------
|
|
api.get("/blob/:accountId/:blobId/:name", requireSession, async (c) => {
|
|
const session = c.get("session");
|
|
const { accountId, blobId, name } = c.req.param();
|
|
const accept = c.req.query("accept") ?? "application/octet-stream";
|
|
const inline = c.req.query("inline") === "1";
|
|
try {
|
|
const upstream = await getUpstreamSession(session.id, session.authorization);
|
|
const url = absoluteUpstream(expandTemplate(upstream.downloadUrl, { accountId, blobId, name, type: accept }));
|
|
const res = await fetch(url, {
|
|
headers: { authorization: session.authorization },
|
|
signal: AbortSignal.timeout(Math.max(config.upstreamTimeout, 5 * 60_000)),
|
|
});
|
|
if (!res.ok) return c.json({ error: "not_found" }, res.status === 404 ? 404 : 502);
|
|
const headers = new Headers();
|
|
const type = sanitizeContentType(res.headers.get("content-type") ?? accept);
|
|
headers.set("Content-Type", type);
|
|
const cl = res.headers.get("content-length");
|
|
if (cl) headers.set("Content-Length", cl);
|
|
const safeInline = inline && isInlineSafe(type);
|
|
headers.set(
|
|
"Content-Disposition",
|
|
`${safeInline ? "inline" : "attachment"}; filename*=UTF-8''${encodeURIComponent(name)}`,
|
|
);
|
|
headers.set("X-Content-Type-Options", "nosniff");
|
|
// Sandbox everything except the browser's built-in PDF viewer (which needs scripts to render).
|
|
if (!(safeInline && type === "application/pdf")) {
|
|
headers.set("Content-Security-Policy", "sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src data:");
|
|
}
|
|
headers.set("Cache-Control", "private, max-age=3600");
|
|
return new Response(res.body, { status: 200, headers });
|
|
} catch (err) {
|
|
return upstreamFailure(c, err);
|
|
}
|
|
});
|
|
|
|
// ---------- Push (Server-Sent Events) ----------
|
|
api.get("/events", requireSession, async (c) => {
|
|
const session = c.get("session");
|
|
const types = c.req.query("types") ?? "*";
|
|
const closeafter = c.req.query("closeafter") ?? "no";
|
|
const ping = c.req.query("ping") ?? "30";
|
|
try {
|
|
const upstream = await getUpstreamSession(session.id, session.authorization);
|
|
const url = absoluteUpstream(expandTemplate(upstream.eventSourceUrl, { types, closeafter, ping }));
|
|
const controller = new AbortController();
|
|
c.req.raw.signal.addEventListener("abort", () => controller.abort());
|
|
const res = await fetch(url, {
|
|
headers: { authorization: session.authorization, accept: "text/event-stream" },
|
|
signal: controller.signal,
|
|
});
|
|
if (!res.ok || !res.body) return c.json({ error: "upstream_error" }, 502);
|
|
const headers = new Headers({
|
|
"Content-Type": "text/event-stream",
|
|
"Cache-Control": "no-cache, no-transform",
|
|
Connection: "keep-alive",
|
|
"X-Accel-Buffering": "no",
|
|
});
|
|
return new Response(res.body, { status: 200, headers });
|
|
} catch (err) {
|
|
return upstreamFailure(c, err);
|
|
}
|
|
});
|
|
|
|
// ---------- Remote image privacy proxy ----------
|
|
api.get("/image", requireSession, imageProxyHandler);
|
|
|
|
api.notFound((c) => c.json({ error: "not_found" }, 404));
|
|
api.onError((err, c) => {
|
|
console.error("[ihasmail] api error:", err);
|
|
return c.json({ error: "internal_error" }, 500);
|
|
});
|
|
|
|
app.route("/api", api);
|
|
|
|
// ---------- Static SPA ----------
|
|
app.get("*", staticHandler(config.staticDir));
|
|
return app;
|
|
}
|
|
|
|
/** Fail a stream that runs past `max` bytes, whatever its headers claimed. */
|
|
function byteCap(max: number): TransformStream<Uint8Array, Uint8Array> {
|
|
let total = 0;
|
|
return new TransformStream<Uint8Array, Uint8Array>({
|
|
transform(chunk, controller) {
|
|
total += chunk.byteLength;
|
|
if (total > max) controller.error(new Error("upload too large"));
|
|
else controller.enqueue(chunk);
|
|
},
|
|
});
|
|
}
|
|
|
|
async function readJson<T>(c: Context): Promise<T | null> {
|
|
try {
|
|
return (await c.req.json()) as T;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Name the app password after the browser it will live in. */
|
|
function appPasswordName(c: Context): string {
|
|
const ua = c.req.header("user-agent") ?? "";
|
|
const browser = /Firefox\//.test(ua) ? "Firefox" : /Edg\//.test(ua) ? "Edge" : /Chrome\//.test(ua) ? "Chrome" : /Safari\//.test(ua) ? "Safari" : "browser";
|
|
return `${config.appName} (${browser})`;
|
|
}
|
|
|
|
function sessionExtras(session: LiveSession, info: AccountInfo = { locale: null, edition: null }) {
|
|
return {
|
|
ihasmail: {
|
|
appName: config.appName,
|
|
sourceUrl: config.sourceUrl,
|
|
imageProxy: config.imageProxy,
|
|
maxUploadBytes: config.maxUploadBytes,
|
|
sessionId: session.id,
|
|
loginName: session.username,
|
|
remember: session.remember,
|
|
/** Locale configured for the account in Stalwart's directory, if readable. */
|
|
userLocale: info.locale,
|
|
/** What the upstream server would tell us about itself. */
|
|
server: { edition: info.edition },
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Headers worth relaying from the mail server. An allowlist rather than a
|
|
* denylist: everything else it might set — cookies, auth challenges, CORS
|
|
* grants — would be landing on *our* origin, where it means something else.
|
|
*/
|
|
const PASSTHROUGH_HEADERS = new Set(["content-type", "content-disposition", "content-language", "etag", "last-modified", "retry-after"]);
|
|
|
|
function passthrough(res: Response): Response {
|
|
const headers = new Headers();
|
|
res.headers.forEach((v, k) => {
|
|
if (PASSTHROUGH_HEADERS.has(k.toLowerCase())) headers.set(k, v);
|
|
});
|
|
if (!headers.has("content-type")) headers.set("content-type", "application/json");
|
|
headers.set("Cache-Control", "no-store");
|
|
return new Response(res.body, { status: res.status, headers });
|
|
}
|
|
|
|
function sanitizeContentType(ct: string): string {
|
|
const lower = ct.split(";")[0]!.trim().toLowerCase();
|
|
// Never let the browser render HTML/SVG/XML/JS served from the blob endpoint.
|
|
if (
|
|
lower === "text/html" ||
|
|
lower === "application/xhtml+xml" ||
|
|
lower === "image/svg+xml" ||
|
|
lower.includes("javascript") ||
|
|
lower === "text/xml" ||
|
|
lower === "application/xml"
|
|
) {
|
|
return "application/octet-stream";
|
|
}
|
|
if (lower.startsWith("text/")) return `${lower}; charset=utf-8`;
|
|
return lower || "application/octet-stream";
|
|
}
|
|
|
|
function isInlineSafe(type: string): boolean {
|
|
const t = type.split(";")[0]!.trim();
|
|
return (
|
|
(t.startsWith("image/") && t !== "image/svg+xml") ||
|
|
t.startsWith("video/") ||
|
|
t.startsWith("audio/") ||
|
|
t === "application/pdf" ||
|
|
t === "text/plain" ||
|
|
t === "text/calendar" ||
|
|
t === "text/vcard"
|
|
);
|
|
}
|