Let an operator turn administration off
ADMINISTRATION=0 at launch removes in-app administration for everyone. The account's permissions are no longer sent to the browser, so the menu never appears, and the JMAP proxy refuses Stalwart registry methods other than the account's own (settings, password, app passwords, API keys, public keys, masked addresses). Hiding the menu alone would have left an administrator's browser console able to make every call the menu made. With administration on, the request body streams through untouched as before; only an installation that turns it off reads and checks the body, forwarding the parsed form so the server receives exactly what was inspected.
This commit is contained in:
@@ -61,6 +61,12 @@ MAX_UPLOAD_BYTES=52428800
|
|||||||
# Remote-image privacy proxy (Gmail-style). Set to 0 to load remote images directly.
|
# Remote-image privacy proxy (Gmail-style). Set to 0 to load remote images directly.
|
||||||
IMAGE_PROXY=1
|
IMAGE_PROXY=1
|
||||||
|
|
||||||
|
# In-app administration, for accounts whose Stalwart role manages accounts and
|
||||||
|
# domains. 0 turns it off for everyone: no menu, and the JMAP proxy refuses
|
||||||
|
# Stalwart's registry methods beyond an account's own password, app passwords
|
||||||
|
# and settings. Stalwart's own admin interface is not affected.
|
||||||
|
ADMINISTRATION=1
|
||||||
|
|
||||||
# Branding
|
# Branding
|
||||||
APP_NAME=ihasmail
|
APP_NAME=ihasmail
|
||||||
|
|
||||||
|
|||||||
+11
@@ -1140,6 +1140,16 @@ in as it. ihasmail shows any account that outranks the viewer read-only, and
|
|||||||
counts a role it cannot read as outranking rather than not. Nobody can change
|
counts a role it cannot read as outranking rather than not. Nobody can change
|
||||||
their own role or delete the account they are signed in with.
|
their own role or delete the account they are signed in with.
|
||||||
|
|
||||||
|
## An operator can turn it off
|
||||||
|
|
||||||
|
`ADMINISTRATION=0` at launch removes it for everyone, and not only from the
|
||||||
|
menu. The permissions are no longer sent to the browser, and the JMAP proxy
|
||||||
|
refuses Stalwart registry methods except the ones about the signed-in account
|
||||||
|
itself — its password, app passwords, API keys, public keys, masked addresses
|
||||||
|
and account settings. Without that, hiding the menu would leave an
|
||||||
|
administrator's browser console able to make every call the menu made.
|
||||||
|
Stalwart's own interface is unaffected; this decides what ihasmail offers.
|
||||||
|
|
||||||
## Stateless, as everything else
|
## Stateless, as everything else
|
||||||
|
|
||||||
Nothing new is stored anywhere. There is no admin route on ihasmail's server,
|
Nothing new is stored anywhere. There is no admin route on ihasmail's server,
|
||||||
@@ -1521,6 +1531,7 @@ wizard, because either would be state.
|
|||||||
| `UPSTREAM_TIMEOUT` | `30000` | Milliseconds |
|
| `UPSTREAM_TIMEOUT` | `30000` | Milliseconds |
|
||||||
| `MAX_UPLOAD_BYTES` | `52428800` | 50 MB |
|
| `MAX_UPLOAD_BYTES` | `52428800` | 50 MB |
|
||||||
| `IMAGE_PROXY` | `1` | Privacy proxy for remote images |
|
| `IMAGE_PROXY` | `1` | Privacy proxy for remote images |
|
||||||
|
| `ADMINISTRATION` | `1` | Offer in-app administration to accounts whose Stalwart role allows it; `0` turns it off, in the proxy as well as the menu |
|
||||||
| `LOGIN_RATE_LIMIT` | `10` | Attempts per window |
|
| `LOGIN_RATE_LIMIT` | `10` | Attempts per window |
|
||||||
| `COOKIE_NAME` | `ihm_session` | |
|
| `COOKIE_NAME` | `ihm_session` | |
|
||||||
| `APP_NAME` | `ihasmail` | Branding |
|
| `APP_NAME` | `ihasmail` | Branding |
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { gateAdministration } from "./adminGate.js";
|
||||||
|
|
||||||
|
const req = (...methods: string[]) => JSON.stringify({ using: ["urn:ietf:params:jmap:core"], methodCalls: methods.map((m, i) => [m, {}, `c${i}`]) });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* With ADMINISTRATION=0 an administrator's browser must not be a way round the
|
||||||
|
* operator's decision. Hiding the menu would leave the proxy forwarding the
|
||||||
|
* very calls the menu made.
|
||||||
|
*/
|
||||||
|
test("mail, calendars and the rest pass untouched", () => {
|
||||||
|
const r = gateAdministration(req("Email/query", "Mailbox/get", "CalendarEvent/set", "FileNode/get", "Principal/getAvailability"));
|
||||||
|
assert.equal(r.ok, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the account's own registry objects pass", () => {
|
||||||
|
assert.equal(gateAdministration(req("x:AccountSettings/get", "x:AppPassword/set", "x:PublicKey/get", "x:MaskedEmail/set")).ok, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("directory and server objects are refused, and named", () => {
|
||||||
|
for (const m of ["x:Account/get", "x:Domain/set", "x:Role/query", "x:Tenant/get", "x:SystemSettings/set", "x:DkimSignature/get"]) {
|
||||||
|
assert.deepEqual(gateAdministration(req("Email/get", m)), { ok: false, method: m });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a body that cannot be read is refused rather than forwarded unchecked", () => {
|
||||||
|
assert.deepEqual(gateAdministration("{not json"), { ok: false, method: null });
|
||||||
|
assert.deepEqual(gateAdministration(JSON.stringify({ methodCalls: "x:Account/get" })), { ok: false, method: null });
|
||||||
|
assert.deepEqual(gateAdministration(JSON.stringify({ methodCalls: [[{}, {}, "c"]] })), { ok: false, method: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("what is forwarded is what was checked", () => {
|
||||||
|
// A duplicate key is read one way by JSON.parse; forwarding the parsed form
|
||||||
|
// means the server cannot read it the other way.
|
||||||
|
const raw = '{"methodCalls":[["x:Account/get",{},"a"]],"methodCalls":[["Email/get",{},"b"]]}';
|
||||||
|
const r = gateAdministration(raw);
|
||||||
|
assert.equal(r.ok, true);
|
||||||
|
if (r.ok) assert.equal(r.body, JSON.stringify({ methodCalls: [["Email/get", {}, "b"]] }));
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* What the JMAP proxy lets through when an operator has turned in-app
|
||||||
|
* administration off (`ADMINISTRATION=0`).
|
||||||
|
*
|
||||||
|
* Hiding the menu is not turning it off. `/api/jmap` forwards any method the
|
||||||
|
* browser sends, and Stalwart's registry answers whatever the credential's role
|
||||||
|
* allows -- so without this, an administrator could still manage accounts, or
|
||||||
|
* the whole server, from the browser console of an installation whose operator
|
||||||
|
* said no. With it off, the proxy refuses every `x:` method except the few that
|
||||||
|
* are about the signed-in account itself.
|
||||||
|
*
|
||||||
|
* An allowlist rather than a list of administrative objects, because the
|
||||||
|
* registry has dozens of them -- listeners, stores, tracers, system settings --
|
||||||
|
* and a new release adds more. An object not named here is refused, which errs
|
||||||
|
* towards the operator's decision.
|
||||||
|
*
|
||||||
|
* The standard JMAP methods (mail, calendars, contacts, files, sharing) are not
|
||||||
|
* touched: they act on what the account can already reach.
|
||||||
|
*/
|
||||||
|
const SELF_SERVICE = new Set(["AccountSettings", "AccountPassword", "AppPassword", "ApiKey", "PublicKey", "MaskedEmail"]);
|
||||||
|
|
||||||
|
export type GateResult = { ok: true; body: string } | { ok: false; method: string | null };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check a JMAP request body. On success, hands back the body to forward --
|
||||||
|
* serialised from what was inspected, so the server can never be sent
|
||||||
|
* something different from what was checked (a duplicate key, say, read one
|
||||||
|
* way here and another way there).
|
||||||
|
*/
|
||||||
|
export function gateAdministration(raw: string): GateResult {
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return { ok: false, method: null };
|
||||||
|
}
|
||||||
|
const calls = (parsed as { methodCalls?: unknown } | null)?.methodCalls;
|
||||||
|
if (!Array.isArray(calls)) return { ok: false, method: null };
|
||||||
|
for (const call of calls) {
|
||||||
|
const name = Array.isArray(call) ? call[0] : undefined;
|
||||||
|
if (typeof name !== "string") return { ok: false, method: null };
|
||||||
|
if (!name.startsWith("x:")) continue;
|
||||||
|
const object = name.slice(2).split("/")[0] ?? "";
|
||||||
|
if (!SELF_SERVICE.has(object)) return { ok: false, method: name };
|
||||||
|
}
|
||||||
|
return { ok: true, body: JSON.stringify(parsed) };
|
||||||
|
}
|
||||||
+34
-2
@@ -8,6 +8,7 @@ import { RESPONSE_ALREADY_SENT } from "@hono/node-server/utils/response";
|
|||||||
import { attach as pushAttach, attachRelay as pushAttachRelay, prepare as pushPrepare, receive as pushReceive, pushStatus } from "./push.js";
|
import { attach as pushAttach, attachRelay as pushAttachRelay, prepare as pushPrepare, receive as pushReceive, pushStatus } from "./push.js";
|
||||||
import { getConnInfo } from "@hono/node-server/conninfo";
|
import { getConnInfo } from "@hono/node-server/conninfo";
|
||||||
import { config } from "./config.js";
|
import { config } from "./config.js";
|
||||||
|
import { gateAdministration } from "./adminGate.js";
|
||||||
import { SessionStore, type SessionBackend, type LiveSession } from "./sessions.js";
|
import { SessionStore, type SessionBackend, type LiveSession } from "./sessions.js";
|
||||||
import { RateLimiter } from "./ratelimit.js";
|
import { RateLimiter } from "./ratelimit.js";
|
||||||
import { resolveClientIp } from "./clientip.js";
|
import { resolveClientIp } from "./clientip.js";
|
||||||
@@ -633,6 +634,28 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
|||||||
if (!ct.toLowerCase().startsWith("application/json")) {
|
if (!ct.toLowerCase().startsWith("application/json")) {
|
||||||
return c.json({ error: "unsupported_media_type" }, 415);
|
return c.json({ error: "unsupported_media_type" }, 415);
|
||||||
}
|
}
|
||||||
|
/*
|
||||||
|
* With administration switched off the body is read and checked before it
|
||||||
|
* goes anywhere; with it on, it streams straight through as it always has,
|
||||||
|
* so an installation that allows administration pays nothing for this.
|
||||||
|
*/
|
||||||
|
let body: ReadableStream<Uint8Array> | string | null = c.req.raw.body;
|
||||||
|
if (!config.administration) {
|
||||||
|
let raw: string;
|
||||||
|
try {
|
||||||
|
// Counted as it arrives: a chunked body carries no length to refuse up front.
|
||||||
|
raw = c.req.raw.body ? await new Response(c.req.raw.body.pipeThrough(byteCap(MAX_GATED_REQUEST))).text() : "";
|
||||||
|
} catch {
|
||||||
|
return c.json({ error: "too_large" }, 413);
|
||||||
|
}
|
||||||
|
const gate = gateAdministration(raw);
|
||||||
|
if (!gate.ok) {
|
||||||
|
return gate.method
|
||||||
|
? c.json({ error: "administration_disabled", message: `Administration is turned off on this installation (${gate.method}).` }, 403)
|
||||||
|
: c.json({ error: "bad_request", message: "Not a JMAP request." }, 400);
|
||||||
|
}
|
||||||
|
body = gate.body;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const upstream = await getUpstreamSession(session.id, session.authorization, upstreamFor(session.username));
|
const upstream = await getUpstreamSession(session.id, session.authorization, upstreamFor(session.username));
|
||||||
const res = await fetch(absoluteUpstream(upstream.apiUrl, upstream.baseUrl), {
|
const res = await fetch(absoluteUpstream(upstream.apiUrl, upstream.baseUrl), {
|
||||||
@@ -642,7 +665,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
|||||||
"content-type": "application/json",
|
"content-type": "application/json",
|
||||||
accept: "application/json",
|
accept: "application/json",
|
||||||
},
|
},
|
||||||
body: c.req.raw.body,
|
body,
|
||||||
duplex: "half",
|
duplex: "half",
|
||||||
signal: AbortSignal.timeout(config.upstreamTimeout),
|
signal: AbortSignal.timeout(config.upstreamTimeout),
|
||||||
});
|
});
|
||||||
@@ -838,11 +861,14 @@ function sessionExtras(session: LiveSession, info: AccountInfo = { locale: null,
|
|||||||
userLocale: info.locale,
|
userLocale: info.locale,
|
||||||
/** What the upstream server would tell us about itself. */
|
/** What the upstream server would tell us about itself. */
|
||||||
server: { edition: info.edition },
|
server: { edition: info.edition },
|
||||||
|
/** Whether this installation offers administration at all (ADMINISTRATION). */
|
||||||
|
administration: config.administration,
|
||||||
/**
|
/**
|
||||||
* The account's permissions on that server, so the client can offer
|
* The account's permissions on that server, so the client can offer
|
||||||
* administration to those who have it. Stalwart still decides every call.
|
* administration to those who have it. Stalwart still decides every call.
|
||||||
|
* Withheld when administration is off: nothing in the browser needs them.
|
||||||
*/
|
*/
|
||||||
permissions: info.permissions,
|
permissions: config.administration ? info.permissions : [],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -852,6 +878,12 @@ function sessionExtras(session: LiveSession, info: AccountInfo = { locale: null,
|
|||||||
* denylist: everything else it might set — cookies, auth challenges, CORS
|
* denylist: everything else it might set — cookies, auth challenges, CORS
|
||||||
* grants — would be landing on *our* origin, where it means something else.
|
* grants — would be landing on *our* origin, where it means something else.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* The largest JMAP request read into memory for the administration check.
|
||||||
|
* Stalwart's own default `maxSizeRequest` is 10 MB; uploads never come this way.
|
||||||
|
*/
|
||||||
|
const MAX_GATED_REQUEST = 16 * 1024 * 1024;
|
||||||
|
|
||||||
const PASSTHROUGH_HEADERS = new Set(["content-type", "content-disposition", "content-language", "etag", "last-modified", "retry-after"]);
|
const PASSTHROUGH_HEADERS = new Set(["content-type", "content-disposition", "content-language", "etag", "last-modified", "retry-after"]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -295,6 +295,13 @@ export const config = {
|
|||||||
upstreamTimeout: int("UPSTREAM_TIMEOUT", 30_000),
|
upstreamTimeout: int("UPSTREAM_TIMEOUT", 30_000),
|
||||||
maxUploadBytes: int("MAX_UPLOAD_BYTES", 50 * 1024 * 1024),
|
maxUploadBytes: int("MAX_UPLOAD_BYTES", 50 * 1024 * 1024),
|
||||||
imageProxy: bool("IMAGE_PROXY", true),
|
imageProxy: bool("IMAGE_PROXY", true),
|
||||||
|
/*
|
||||||
|
* Whether ihasmail offers administration to accounts whose Stalwart role
|
||||||
|
* allows it. Off means off: no menu, no permissions sent to the browser, and
|
||||||
|
* the JMAP proxy refuses registry methods beyond the account's own -- see
|
||||||
|
* adminGate.ts. Stalwart's own interface is unaffected either way.
|
||||||
|
*/
|
||||||
|
administration: bool("ADMINISTRATION", true),
|
||||||
cookieName: env("COOKIE_NAME", "ihm_session"),
|
cookieName: env("COOKIE_NAME", "ihm_session"),
|
||||||
staticDir: process.env.STATIC_DIR ?? fileURLToPath(new URL("../../web/dist", import.meta.url)),
|
staticDir: process.env.STATIC_DIR ?? fileURLToPath(new URL("../../web/dist", import.meta.url)),
|
||||||
loginRateLimit: int("LOGIN_RATE_LIMIT", 10),
|
loginRateLimit: int("LOGIN_RATE_LIMIT", 10),
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ export interface JmapSession {
|
|||||||
/** "oss" | "community" | "enterprise". Stalwart publishes no version. */
|
/** "oss" | "community" | "enterprise". Stalwart publishes no version. */
|
||||||
edition?: string | null;
|
edition?: string | null;
|
||||||
};
|
};
|
||||||
|
/** False when the operator has turned in-app administration off. */
|
||||||
|
administration?: boolean;
|
||||||
/**
|
/**
|
||||||
* The account's effective permissions on that server, as Stalwart reports
|
* The account's effective permissions on that server, as Stalwart reports
|
||||||
* them. What the client offers is shaped by these; what is allowed is
|
* them. What the client offers is shaped by these; what is allowed is
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { permissionSet, type Permissions } from "@/lib/adminAccess";
|
|||||||
* everything that depends on it, whose requests could bring another refresh.
|
* everything that depends on it, whose requests could bring another refresh.
|
||||||
*/
|
*/
|
||||||
export function usePermissions(): Permissions {
|
export function usePermissions(): Permissions {
|
||||||
const key = useSession((s) => (s.session?.ihasmail?.permissions ?? []).join(","));
|
// An installation with administration off sends none; this is belt and braces.
|
||||||
|
const key = useSession((s) => (s.session?.ihasmail?.administration === false ? "" : (s.session?.ihasmail?.permissions ?? []).join(",")));
|
||||||
return useMemo(() => permissionSet(key ? key.split(",") : []), [key]);
|
return useMemo(() => permissionSet(key ? key.split(",") : []), [key]);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user