diff --git a/server/src/accounts.test.ts b/server/src/accounts.test.ts index c7519a2..e54428a 100644 --- a/server/src/accounts.test.ts +++ b/server/src/accounts.test.ts @@ -17,6 +17,8 @@ process.env.MOCK_SECOND_USER = "second@example.com"; process.env.MOCK_SECOND_PASS = "second-password"; process.env.MAIL_SERVER_URL = `http://127.0.0.1:${PORT}`; process.env.APP_SECRET = "test-secret-for-accounts"; +// Every test here signs in several times from one address +process.env.LOGIN_RATE_LIMIT = "100"; const mock = await import("./mock/index.js"); const { createApp } = await import("./app.js"); @@ -154,3 +156,37 @@ test("inbuxa MA-8: the accounts not in front report their Inbox unread count", a assert.equal(other.id, listed.id); assert.equal(typeof other.unread, "number", JSON.stringify(res.body)); }); + +test("inbuxa MA-8: only push, mailboxes and marking mail reach an account not in front", async () => { + const { otherAccountCallAllowed } = await import("./app.js"); + assert.equal(otherAccountCallAllowed(["PushSubscription/get", { ids: null }, "0"]), true); + assert.equal(otherAccountCallAllowed(["Mailbox/get", { accountId: "a" }, "0"]), true); + assert.equal(otherAccountCallAllowed(["Email/set", { accountId: "a", update: { m1: { "keywords/$seen": true } } }, "0"]), true); + assert.equal(otherAccountCallAllowed(["Email/set", { accountId: "a", update: { m1: { mailboxIds: { arch: true } } } }, "0"]), true); + // Anything else is refused + assert.equal(otherAccountCallAllowed(["Email/get", { accountId: "a" }, "0"]), false); + assert.equal(otherAccountCallAllowed(["Email/set", { accountId: "a", destroy: ["m1"] }, "0"]), false); + assert.equal(otherAccountCallAllowed(["Email/set", { accountId: "a", create: { x: {} } }, "0"]), false); + assert.equal(otherAccountCallAllowed(["Email/set", { accountId: "a", update: { m1: { subject: "x" } } }, "0"]), false); + assert.equal(otherAccountCallAllowed(["EmailSubmission/set", {}, "0"]), false); + + const b = new Browser(); + await b.signIn("first@example.com", "first-password"); + const added = await b.signIn("second@example.com", "second-password", true); + assert.equal(added.status, 200, JSON.stringify(added.body)); + const other = (await b.accounts()).find((a) => !a.front)!; + const ok = await b.call(`/api/auth/accounts/${other.id}/jmap`, { + method: "POST", + body: { using: ["urn:ietf:params:jmap:core"], methodCalls: [["PushSubscription/get", { ids: null }, "0"]] }, + }); + assert.equal(ok.status, 200, JSON.stringify(ok.body)); + assert.equal(ok.body.methodResponses[0][0], "PushSubscription/get"); + const refused = await b.call(`/api/auth/accounts/${other.id}/jmap`, { + method: "POST", + body: { using: [], methodCalls: [["Email/get", { accountId: "x", ids: null }, "0"]] }, + }); + assert.equal(refused.status, 403); + // The account in front isn't reached this way, nor a session not held here + const front = (await b.accounts()).find((a) => a.front)!; + assert.equal((await b.call(`/api/auth/accounts/${front.id}/jmap`, { method: "POST", body: { methodCalls: [] } })).status, 404); +}); diff --git a/server/src/app.ts b/server/src/app.ts index 790e98f..2335c52 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -363,6 +363,35 @@ function liveOthers(c: Context): { cookie: string; session: LiveSession }[] { return out; } +/* + * inbuxa MA-8: what may be asked of a signed-in account that isn't in front. + * + * Its push subscription has to be registered, verified and renewed through + * its own session -- a JMAP push subscription belongs to whoever signs the + * request -- and a notification's Archive and Mark read act on its mail. Those + * four methods, and for Email/set only changes to keywords and mailboxes: the + * browser already holds the session, so this reaches nothing new, but it is + * kept to what the notifications need. + */ +const OTHER_ACCOUNT_METHODS = new Set(["PushSubscription/get", "PushSubscription/set", "Mailbox/get", "Email/set"]); + +export function otherAccountCallAllowed(call: unknown): boolean { + if (!Array.isArray(call) || call.length !== 3) return false; + const [method, args] = call as [unknown, unknown, unknown]; + if (typeof method !== "string" || !OTHER_ACCOUNT_METHODS.has(method)) return false; + if (typeof args !== "object" || args === null) return false; + if (method !== "Email/set") return true; + const set = args as { create?: unknown; destroy?: unknown; update?: unknown }; + if (set.create !== undefined || set.destroy !== undefined) return false; + if (typeof set.update !== "object" || set.update === null) return false; + return Object.values(set.update as Record).every( + (patch) => + typeof patch === "object" && + patch !== null && + Object.keys(patch).every((k) => k === "keywords" || k === "mailboxIds" || k.startsWith("keywords/") || k.startsWith("mailboxIds/")), + ); +} + /** inbuxa MA-8: Inbox unread counts of accounts not in front, briefly kept. */ const UNREAD_CACHE_MS = 60_000; const unreadCache = new Map(); @@ -806,6 +835,39 @@ export function createApp(basePath = config.basePath): Hono { return c.json({ accounts: answers }); }); + /* inbuxa MA-8: a narrow JMAP route to a signed-in account not in front; see OTHER_ACCOUNT_METHODS. */ + api.post("/auth/accounts/:id/jmap", requireSession, async (c) => { + const other = liveOthers(c).find((o) => o.session.id === c.req.param("id")); + if (!other) return c.json({ error: "not_found" }, 404); + let body: { using?: unknown; methodCalls?: unknown }; + try { + body = await c.req.json(); + } catch { + return c.json({ error: "bad_request" }, 400); + } + const calls = body.methodCalls; + if (!Array.isArray(calls) || calls.length === 0 || calls.length > 16 || !calls.every(otherAccountCallAllowed)) { + return c.json({ error: "forbidden", message: "Only push subscriptions, mailboxes and marking mail can be reached in another account." }, 403); + } + const using = Array.isArray(body.using) ? body.using.filter((u): u is string => typeof u === "string") : []; + let session: LiveSession | null = other.session; + if (session.tokens && needsRefresh(session.tokens)) session = await refreshSession(other.cookie, session); + if (!session) return c.json({ error: "unauthenticated" }, 401); + try { + const upstream = await getUpstreamSession(session.id, session.authorization, upstreamFor(session.username)); + const res = await fetch(absoluteUpstream(upstream.apiUrl, upstream.baseUrl), { + method: "POST", + headers: { authorization: session.authorization, "content-type": "application/json", accept: "application/json" }, + body: JSON.stringify({ using, methodCalls: calls }), + signal: AbortSignal.timeout(config.upstreamTimeout), + }); + if (res.status === 401 || res.status === 403) return c.json({ error: "unauthenticated" }, 401); + return c.json(await res.json(), res.ok ? 200 : 502); + } catch (err) { + return upstreamFailure(c, err); + } + }); + /* inbuxa MA-B: bring another signed-in account to the front. */ api.post("/auth/accounts/:id/front", requireSession, async (c) => { const frontCookie = getCookie(c, config.cookieName)!; diff --git a/web/src/lib/notify/__tests__/other-account-call.test.ts b/web/src/lib/notify/__tests__/other-account-call.test.ts new file mode 100644 index 0000000..6950366 --- /dev/null +++ b/web/src/lib/notify/__tests__/other-account-call.test.ts @@ -0,0 +1,35 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { otherAccountCall } from "@/lib/notify/otherAccount"; +import { listSubscriptions } from "@/lib/notify/webpush"; + +/** inbuxa MA-8: push calls made as an account that isn't in front. */ + +afterEach(() => vi.unstubAllGlobals()); + +function stub(response: unknown) { + const seen: { url: string; body: any }[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (url: string, init?: RequestInit) => { + seen.push({ url: String(url), body: JSON.parse(String(init?.body ?? "{}")) }); + return { ok: true, status: 200, json: async () => response, text: async () => JSON.stringify(response) } as Response; + }), + ); + return seen; +} + +describe("otherAccountCall", () => { + it("goes through that account's route and answers the method's result", async () => { + const seen = stub({ methodResponses: [["PushSubscription/get", { list: [{ id: "p1", deviceClientId: "d" }] }, "0"]] }); + const subs = await listSubscriptions(otherAccountCall("sess-2")); + expect(subs.map((s) => s.id)).toEqual(["p1"]); + expect(seen[0]!.url).toContain("/api/auth/accounts/sess-2/jmap"); + expect(seen[0]!.body.methodCalls[0][0]).toBe("PushSubscription/get"); + expect(seen[0]!.body.using).toContain("urn:ietf:params:jmap:core"); + }); + + it("turns a JMAP error into a thrown one", async () => { + stub({ methodResponses: [["error", { type: "forbidden" }, "0"]] }); + await expect(listSubscriptions(otherAccountCall("sess-2"))).rejects.toThrow("forbidden"); + }); +}); diff --git a/web/src/lib/notify/otherAccount.ts b/web/src/lib/notify/otherAccount.ts new file mode 100644 index 0000000..2e2c10d --- /dev/null +++ b/web/src/lib/notify/otherAccount.ts @@ -0,0 +1,22 @@ +/** + * inbuxa MA-8: JMAP calls made as a signed-in account that isn't in front, + * through the webmail server's narrow route for it + * (POST /api/auth/accounts//jmap). The server allows only what + * notifications need: push subscriptions, mailboxes, and marking or filing + * mail. + */ +import { apiFetch, CAP } from "@/jmap/client"; +import type { JmapCall } from "@/lib/notify/webpush"; + +export function otherAccountCall(sessionId: string): JmapCall { + return async (method: string, args: Record, using: string[]): Promise => { + const res = await apiFetch<{ methodResponses?: [string, unknown, string][] }>( + `/api/auth/accounts/${encodeURIComponent(sessionId)}/jmap`, + { method: "POST", body: JSON.stringify({ using: [...new Set([CAP.core, ...using])], methodCalls: [[method, args, "0"]] }) }, + ); + const [name, out] = res.methodResponses?.[0] ?? []; + if (!name) throw new Error("The mail server sent no response."); + if (name === "error") throw new Error(String((out as { type?: string } | undefined)?.type ?? "error")); + return out as T; + }; +} diff --git a/web/src/lib/notify/webpush.ts b/web/src/lib/notify/webpush.ts index 5c6970a..8fab8d1 100644 --- a/web/src/lib/notify/webpush.ts +++ b/web/src/lib/notify/webpush.ts @@ -22,6 +22,15 @@ import type { GetResponse, Id, SetResponse } from "@/jmap/types"; import { isDeviceTrusted } from "@/lib/storage"; export const VAPID_CAP = "urn:ietf:params:jmap:webpush-vapid"; + +/** + * Who a push call is made as (inbuxa MA-8). A JMAP push subscription belongs + * to whoever signs the request, so registering one for an account that isn't + * in front goes through that account's own session (lib/notify/otherAccount). + * Everything here defaults to the account in front. + */ +export type JmapCall = (method: string, args: Record, using: string[]) => Promise; +export const frontCall: JmapCall = (method, args, using) => client.call(method, args, using); export const EMAILPUSH_CAP = "urn:ietf:params:jmap:emailpush"; /** @@ -285,13 +294,13 @@ export function needsRenewal(subs: JmapPushSubscription[], deviceId: string, now return at - now <= RENEW_WITHIN_MS; } -export async function listSubscriptions(): Promise { - const res = await client.call>("PushSubscription/get", { ids: null }, [CAP.core, VAPID_CAP]); +export async function listSubscriptions(call: JmapCall = frontCall): Promise { + const res = await call>("PushSubscription/get", { ids: null }, [CAP.core, VAPID_CAP]); return res.list; } -export async function createSubscription(body: Record): Promise { - const res = await client.call>( +export async function createSubscription(body: Record, call: JmapCall = frontCall): Promise { + const res = await call>( "PushSubscription/set", { create: { s: body } }, [CAP.core, VAPID_CAP, EMAILPUSH_CAP], @@ -307,16 +316,16 @@ export async function createSubscription(body: Record): Promise * Seven days is JMAP's ceiling and what Stalwart grants a new one; the server * may shorten what is asked for, and whatever it keeps is what counts. */ -export async function extendSubscription(id: Id, now: number = Date.now()): Promise { +export async function extendSubscription(id: Id, now: number = Date.now(), call: JmapCall = frontCall): Promise { const expires = new Date(now + 7 * 24 * 60 * 60 * 1000).toISOString().replace(/\.\d+Z$/, "Z"); - const res = await client.call>("PushSubscription/set", { update: { [id]: { expires } } }, [CAP.core, VAPID_CAP]); + const res = await call>("PushSubscription/set", { update: { [id]: { expires } } }, [CAP.core, VAPID_CAP]); const err = res.notUpdated?.[id]; if (err) throw new PushSetError(String(err.type), String(err.description ?? err.type)); } -export async function destroySubscriptions(ids: Id[]): Promise { +export async function destroySubscriptions(ids: Id[], call: JmapCall = frontCall): Promise { if (!ids.length) return; - await client.call>("PushSubscription/set", { destroy: ids }, [CAP.core, VAPID_CAP]); + await call>("PushSubscription/set", { destroy: ids }, [CAP.core, VAPID_CAP]); } /** @@ -353,8 +362,8 @@ export function rememberEndpoint(endpoint: string | null): void { * the client echoes it. A subscription left unverified looks registered and is * silent, which is the confusing failure worth being explicit about. */ -export async function verifySubscription(id: Id, verificationCode: string): Promise { - const res = await client.call>( +export async function verifySubscription(id: Id, verificationCode: string, call: JmapCall = frontCall): Promise { + const res = await call>( "PushSubscription/set", { update: { [id]: { verificationCode } } }, [CAP.core, VAPID_CAP],