Merge pull request #338 from Coffey-Labs/feat/admin-trusted-device-only
Offer administration only on a device marked as your own
This commit is contained in:
+15
@@ -1166,6 +1166,20 @@ For a role that can read domains (`sysDomainQuery`, `sysDomainGet`):
|
||||
Switching DNS, DKIM or certificate management between automatic and manual,
|
||||
and choosing a DNS or ACME provider, stay in Stalwart's own interface for now.
|
||||
|
||||
## Only on your own device
|
||||
|
||||
Administration is available only to a session signed in with **"This is my own
|
||||
device"** ticked. A borrowed laptop or a shared machine is exactly where nobody
|
||||
should be able to reset a password or remove a domain, and that tickbox is the
|
||||
one question the sign-in page already asks about where it is being used.
|
||||
|
||||
It is enforced the same way as the switch below: an untrusted session is sent
|
||||
no permissions, and the JMAP proxy refuses registry methods beyond the account's
|
||||
own. The menu still shows **Administration** to an administrator in that
|
||||
session, greyed out, with the reason and what to do about it — signing in again
|
||||
with the box ticked — rather than losing the entry without a word. All the
|
||||
server tells that session is that the account administers, never what it may do.
|
||||
|
||||
## An operator can turn it off
|
||||
|
||||
`ADMINISTRATION=0` at launch removes it for everyone, and not only from the
|
||||
@@ -1373,6 +1387,7 @@ costs something to get wrong is the one that assumes the machine is yours.
|
||||
| Idle sign-out | after 5 minutes | none |
|
||||
| Kept on the computer | nothing | settings cache, recent addresses, username |
|
||||
| Background notifications | refused | available |
|
||||
| Administration | unavailable | available, if the role allows it |
|
||||
|
||||
Local storage is gated on that answer for **reads** as well as writes — a
|
||||
machine trusted once still has residue, and honouring it would let a previous
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { gateAdministration } from "./adminGate.js";
|
||||
import { administrationAllowed, gateAdministration, grantsAdministration, mayNameRegistryMethod } from "./adminGate.js";
|
||||
|
||||
const req = (...methods: string[]) => JSON.stringify({ using: ["urn:ietf:params:jmap:core"], methodCalls: methods.map((m, i) => [m, {}, `c${i}`]) });
|
||||
|
||||
@@ -24,10 +24,41 @@ test("directory and server objects are refused, and named", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("a body that cannot be read is refused rather than forwarded unchecked", () => {
|
||||
assert.deepEqual(gateAdministration("{not json"), { ok: false, method: null });
|
||||
test("a body that could name a registry method and cannot be read is refused rather than forwarded", () => {
|
||||
assert.deepEqual(gateAdministration('{"methodCalls": [["x:Account/get"'), { 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 });
|
||||
assert.deepEqual(gateAdministration(JSON.stringify({ methodCalls: [[{}, {}, "c"]], note: "x:" })), { ok: false, method: null });
|
||||
});
|
||||
|
||||
test("a body that cannot name a registry method is forwarded exactly as it came", () => {
|
||||
// Most traffic from a session that may not administer: no parse, no rewrite.
|
||||
const raw = '{"using":["urn:ietf:params:jmap:core"],"methodCalls":[["Email/get",{"ids":["a"]},"c"]]}';
|
||||
assert.equal(mayNameRegistryMethod(raw), false);
|
||||
assert.deepEqual(gateAdministration(raw), { ok: true, body: raw });
|
||||
});
|
||||
|
||||
test("a method name hidden behind a unicode escape is still found", () => {
|
||||
// JSON.parse and the server both read \u0078 as "x"; a substring check alone would not.
|
||||
const raw = '{"methodCalls":[["\\u0078:Account/get",{},"c"]]}';
|
||||
assert.equal(mayNameRegistryMethod(raw), true);
|
||||
assert.deepEqual(gateAdministration(raw), { ok: false, method: "x:Account/get" });
|
||||
});
|
||||
|
||||
/**
|
||||
* The operator's rule: administration only from a session signed in with
|
||||
* "This is my own device" ticked, and never when the installation turned it off.
|
||||
*/
|
||||
test("administration needs both the installation and a device marked as the person's own", () => {
|
||||
assert.equal(administrationAllowed(true, true), true);
|
||||
assert.equal(administrationAllowed(true, false), false);
|
||||
assert.equal(administrationAllowed(false, true), false);
|
||||
});
|
||||
|
||||
test("an account counts as an administrator by the same test the menu makes", () => {
|
||||
assert.equal(grantsAdministration(["sysAccountQuery", "sysAccountGet"]), true);
|
||||
assert.equal(grantsAdministration(["sysDomainQuery", "sysDomainGet"]), true);
|
||||
assert.equal(grantsAdministration(["sysAccountQuery", "sysDomainGet"]), false);
|
||||
assert.equal(grantsAdministration(["jmapEmailGet", "sysAccountSettingsGet"]), false);
|
||||
});
|
||||
|
||||
test("what is forwarded is what was checked", () => {
|
||||
|
||||
+40
-2
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* What the JMAP proxy lets through when an operator has turned in-app
|
||||
* administration off (`ADMINISTRATION=0`).
|
||||
* What the JMAP proxy lets through for a session that may not administer:
|
||||
* the operator turned it off (`ADMINISTRATION=0`), or the session was signed
|
||||
* in without "This is my own device".
|
||||
*
|
||||
* 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
|
||||
@@ -21,6 +22,42 @@ const SELF_SERVICE = new Set(["AccountSettings", "AccountPassword", "AppPassword
|
||||
|
||||
export type GateResult = { ok: true; body: string } | { ok: false; method: string | null };
|
||||
|
||||
/**
|
||||
* Whether a session may administer at all: the installation allows it, and
|
||||
* the person signing in said the device is their own.
|
||||
*
|
||||
* The second half is the operator's rule, not Stalwart's. A borrowed laptop or
|
||||
* a library machine is exactly where a session should not be able to reset a
|
||||
* password or remove a domain, and "This is my own device" is the one thing
|
||||
* the sign-in form already asks that says where it is being used. An untrusted
|
||||
* session is also signed out when idle and wipes its local data, so nothing
|
||||
* about it suits an administrator's work.
|
||||
*/
|
||||
export function administrationAllowed(enabled: boolean, remember: boolean): boolean {
|
||||
return enabled && remember;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an account's permissions would put Administration in its menu --
|
||||
* the same test the client makes, so the server can say why it is missing
|
||||
* without handing over the permissions themselves.
|
||||
*/
|
||||
export function grantsAdministration(permissions: readonly string[]): boolean {
|
||||
const has = new Set(permissions);
|
||||
return (has.has("sysAccountQuery") && has.has("sysAccountGet")) || (has.has("sysDomainQuery") && has.has("sysDomainGet"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a body could hold a registry method name at all, so the common case
|
||||
* -- mail, calendars, contacts from a session that may not administer -- skips
|
||||
* the parse. A method name is a JSON string starting `x:`, which appears in the
|
||||
* text as `"x:` unless written with a `\u` escape; a body with neither cannot
|
||||
* contain one, and is forwarded exactly as it came.
|
||||
*/
|
||||
export function mayNameRegistryMethod(raw: string): boolean {
|
||||
return raw.includes('"x:') || raw.includes("\\u");
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -28,6 +65,7 @@ export type GateResult = { ok: true; body: string } | { ok: false; method: strin
|
||||
* way here and another way there).
|
||||
*/
|
||||
export function gateAdministration(raw: string): GateResult {
|
||||
if (!mayNameRegistryMethod(raw)) return { ok: true, body: raw };
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
|
||||
+24
-12
@@ -8,7 +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 { getConnInfo } from "@hono/node-server/conninfo";
|
||||
import { config } from "./config.js";
|
||||
import { gateAdministration } from "./adminGate.js";
|
||||
import { administrationAllowed, gateAdministration, grantsAdministration } from "./adminGate.js";
|
||||
import { SessionStore, type SessionBackend, type LiveSession } from "./sessions.js";
|
||||
import { RateLimiter } from "./ratelimit.js";
|
||||
import { resolveClientIp } from "./clientip.js";
|
||||
@@ -639,12 +639,13 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
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.
|
||||
* For a session that may not administer -- administration switched off, or
|
||||
* a device not marked as the person's own -- the body is read and checked
|
||||
* before it goes anywhere. A session that may streams straight through as
|
||||
* it always has, and pays nothing for this.
|
||||
*/
|
||||
let body: ReadableStream<Uint8Array> | string | null = c.req.raw.body;
|
||||
if (!config.administration) {
|
||||
if (!administrationAllowed(config.administration, session.remember)) {
|
||||
let raw: string;
|
||||
try {
|
||||
// Counted as it arrives: a chunked body carries no length to refuse up front.
|
||||
@@ -654,9 +655,10 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
}
|
||||
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);
|
||||
if (!gate.method) return c.json({ error: "bad_request", message: "Not a JMAP request." }, 400);
|
||||
return config.administration
|
||||
? c.json({ error: "administration_needs_own_device", message: `Administration is only available when signed in on a device marked as your own (${gate.method}).` }, 403)
|
||||
: c.json({ error: "administration_disabled", message: `Administration is turned off on this installation (${gate.method}).` }, 403);
|
||||
}
|
||||
body = gate.body;
|
||||
}
|
||||
@@ -865,14 +867,24 @@ function sessionExtras(session: LiveSession, info: AccountInfo = { locale: null,
|
||||
userLocale: info.locale,
|
||||
/** What the upstream server would tell us about itself. */
|
||||
server: { edition: info.edition },
|
||||
/** Whether this installation offers administration at all (ADMINISTRATION). */
|
||||
administration: config.administration,
|
||||
/**
|
||||
* Whether this session may administer: the installation offers it
|
||||
* (ADMINISTRATION) and the person signed in on a device marked as their own.
|
||||
*/
|
||||
administration: administrationAllowed(config.administration, session.remember),
|
||||
/**
|
||||
* An administrator signed in on a device not marked as their own, so the
|
||||
* menu can say why Administration is unavailable rather than lose it
|
||||
* without a word. Says only that the account administers, never what it
|
||||
* may do.
|
||||
*/
|
||||
administrationNeedsOwnDevice: config.administration && !session.remember && grantsAdministration(info.permissions),
|
||||
/**
|
||||
* The account's permissions on that server, so the client can offer
|
||||
* administration to those who have it. Stalwart still decides every call.
|
||||
* Withheld when administration is off: nothing in the browser needs them.
|
||||
* Withheld from a session that may not administer: nothing in it needs them.
|
||||
*/
|
||||
permissions: config.administration ? info.permissions : [],
|
||||
permissions: administrationAllowed(config.administration, session.remember) ? info.permissions : [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -39,8 +39,13 @@ export interface JmapSession {
|
||||
/** "oss" | "community" | "enterprise". Stalwart publishes no version. */
|
||||
edition?: string | null;
|
||||
};
|
||||
/** False when the operator has turned in-app administration off. */
|
||||
/**
|
||||
* False when this session may not administer: the operator turned it off,
|
||||
* or the session was signed in without "This is my own device".
|
||||
*/
|
||||
administration?: boolean;
|
||||
/** An administrator on a device not marked as their own; the menu says so. */
|
||||
administrationNeedsOwnDevice?: boolean;
|
||||
/**
|
||||
* The account's effective permissions on that server, as Stalwart reports
|
||||
* them. What the client offers is shaped by these; what is allowed is
|
||||
|
||||
@@ -113,6 +113,7 @@ export const catalog: Catalog = {
|
||||
"also {names}": "auch {names}",
|
||||
"The server did not say whether the domain was created.": "Der Server hat nicht mitgeteilt, ob die Domain angelegt wurde.",
|
||||
// ── Administration: accounts ───────────────────────────────────
|
||||
"Only on a device you've marked as your own. Sign in again with \u201cThis is my own device\u201d ticked.": "Nur auf einem Gerät, das Sie als Ihr eigenes markiert haben. Melden Sie sich erneut an und setzen Sie das Häkchen bei „Das ist mein eigenes Gerät“.",
|
||||
"Change your own password in {settings}.": "Ihr eigenes Passwort ändern Sie unter {settings}.",
|
||||
"Administration": "Verwaltung",
|
||||
"Directory": "Verzeichnis",
|
||||
|
||||
@@ -105,6 +105,7 @@ export const catalog: Catalog = {
|
||||
"also {names}": "también {names}",
|
||||
"The server did not say whether the domain was created.": "El servidor no indicó si el dominio se creó.",
|
||||
// ── Administration: accounts ───────────────────────────────────
|
||||
"Only on a device you've marked as your own. Sign in again with \u201cThis is my own device\u201d ticked.": "Solo en un dispositivo que haya marcado como suyo. Vuelva a iniciar sesión con «Este es mi propio dispositivo» marcado.",
|
||||
"Change your own password in {settings}.": "Cambie su propia contraseña en {settings}.",
|
||||
"Administration": "Administración",
|
||||
"Directory": "Directorio",
|
||||
|
||||
@@ -110,6 +110,7 @@ export const catalog: Catalog = {
|
||||
"also {names}": "aussi {names}",
|
||||
"The server did not say whether the domain was created.": "Le serveur n’a pas indiqué si le domaine a été créé.",
|
||||
// ── Administration: accounts ───────────────────────────────────
|
||||
"Only on a device you've marked as your own. Sign in again with \u201cThis is my own device\u201d ticked.": "Uniquement sur un appareil que vous avez indiqué comme le vôtre. Reconnectez-vous en cochant « Cet appareil est le mien ».",
|
||||
"Change your own password in {settings}.": "Modifiez votre propre mot de passe dans {settings}.",
|
||||
"Administration": "Administration",
|
||||
"Directory": "Annuaire",
|
||||
|
||||
@@ -104,6 +104,7 @@ export const catalog: Catalog = {
|
||||
"also {names}": "別名: {names}",
|
||||
"The server did not say whether the domain was created.": "ドメインが作成されたかどうか、サーバーから応答がありませんでした。",
|
||||
// ── Administration: accounts ───────────────────────────────────
|
||||
"Only on a device you've marked as your own. Sign in again with \u201cThis is my own device\u201d ticked.": "自分のデバイスとして指定した端末でのみ使えます。「これは自分のデバイスです」にチェックを入れて、もう一度サインインしてください。",
|
||||
"Change your own password in {settings}.": "ご自身のパスワードは{settings}で変更してください。",
|
||||
"Administration": "管理",
|
||||
"Directory": "ディレクトリ",
|
||||
|
||||
@@ -101,6 +101,7 @@ export const catalog: Catalog = {
|
||||
"also {names}": "ook {names}",
|
||||
"The server did not say whether the domain was created.": "De server heeft niet gemeld of het domein is aangemaakt.",
|
||||
// ── Administration: accounts ───────────────────────────────────
|
||||
"Only on a device you've marked as your own. Sign in again with \u201cThis is my own device\u201d ticked.": "Alleen op een apparaat dat u als uw eigen apparaat hebt aangemerkt. Log opnieuw in met ‘Dit is mijn eigen apparaat’ aangevinkt.",
|
||||
"Change your own password in {settings}.": "Wijzig uw eigen wachtwoord bij {settings}.",
|
||||
"Administration": "Beheer",
|
||||
"Directory": "Adreslijst",
|
||||
|
||||
@@ -108,6 +108,7 @@ export const catalog: Catalog = {
|
||||
"also {names}": "também {names}",
|
||||
"The server did not say whether the domain was created.": "O servidor não informou se o domínio foi criado.",
|
||||
// ── Administration: accounts ───────────────────────────────────
|
||||
"Only on a device you've marked as your own. Sign in again with \u201cThis is my own device\u201d ticked.": "Só em um dispositivo que você marcou como seu. Entre novamente com “Este dispositivo é meu” marcado.",
|
||||
"Change your own password in {settings}.": "Altere sua própria senha em {settings}.",
|
||||
"Administration": "Administração",
|
||||
"Directory": "Diretório",
|
||||
|
||||
@@ -107,6 +107,7 @@ export const catalog: Catalog = {
|
||||
"also {names}": "также {names}",
|
||||
"The server did not say whether the domain was created.": "Сервер не сообщил, создан ли домен.",
|
||||
// ── Administration: accounts ───────────────────────────────────
|
||||
"Only on a device you've marked as your own. Sign in again with \u201cThis is my own device\u201d ticked.": "Только на устройстве, отмеченном как ваше. Войдите снова, отметив «Это моё личное устройство».",
|
||||
"Change your own password in {settings}.": "Свой пароль можно изменить в разделе {settings}.",
|
||||
"Administration": "Администрирование",
|
||||
"Directory": "Каталог",
|
||||
|
||||
@@ -101,6 +101,7 @@ export const catalog: Catalog = {
|
||||
"also {names}": "також {names}",
|
||||
"The server did not say whether the domain was created.": "Сервер не повідомив, чи створено домен.",
|
||||
// ── Administration: accounts ───────────────────────────────────
|
||||
"Only on a device you've marked as your own. Sign in again with \u201cThis is my own device\u201d ticked.": "Лише на пристрої, позначеному як ваш. Увійдіть знову, позначивши «Це мій власний пристрій».",
|
||||
"Change your own password in {settings}.": "Власний пароль можна змінити в розділі {settings}.",
|
||||
"Administration": "Адміністрування",
|
||||
"Directory": "Каталог",
|
||||
|
||||
@@ -103,6 +103,7 @@ export const catalog: Catalog = {
|
||||
"also {names}": "别名:{names}",
|
||||
"The server did not say whether the domain was created.": "服务器没有说明域名是否已创建。",
|
||||
// ── Administration: accounts ───────────────────────────────────
|
||||
"Only on a device you've marked as your own. Sign in again with \u201cThis is my own device\u201d ticked.": "仅限在您标记为自己设备的设备上使用。请勾选「这是我自己的设备」后重新登录。",
|
||||
"Change your own password in {settings}.": "请在{settings}中更改您自己的密码。",
|
||||
"Administration": "管理",
|
||||
"Directory": "目录",
|
||||
|
||||
@@ -46,6 +46,7 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
const appName = useSession((s) => s.session?.ihasmail?.appName) || DEFAULT_APP_NAME;
|
||||
const acctMenu = useMenu();
|
||||
const administers = hasAdministration(usePermissions());
|
||||
const needsOwnDevice = useSession((s) => Boolean(s.session?.ihasmail?.administrationNeedsOwnDevice));
|
||||
/*
|
||||
* "Go to folder" (#233), hosted here rather than in the mail view because
|
||||
* the `g` shortcuts are global: pressing it from the calendar should still
|
||||
@@ -163,6 +164,21 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
{/* Only for an account whose Stalwart role manages other accounts.
|
||||
Nobody else is shown an entry that would open onto refusals. */}
|
||||
{administers && <MenuItem icon={<ShieldCheck size={16} />} label={t("Administration")} active={section === "admin"} onClick={() => navigate("/admin")} />}
|
||||
{/* An administrator who signed in without "This is my own device". The
|
||||
server withholds administration from that session, so the entry is
|
||||
shown dead with the reason, rather than gone without one. */}
|
||||
{!administers && needsOwnDevice && (
|
||||
<MenuItem
|
||||
icon={<ShieldCheck size={16} />}
|
||||
disabled
|
||||
label={
|
||||
<>
|
||||
<span style={{ display: "block" }}>{t("Administration")}</span>
|
||||
<span className="hint" style={{ display: "block", whiteSpace: "normal" }}>{t("Only on a device you've marked as your own. Sign in again with “This is my own device” ticked.")}</span>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<MenuItem icon={<RefreshCw size={16} />} label={t("Refresh")} onClick={() => window.location.reload()} />
|
||||
<MenuItem icon={<LogOut size={16} />} label={t("Sign out")} onClick={() => void logout()} />
|
||||
</Popover>
|
||||
|
||||
Reference in New Issue
Block a user