Merge pull request #94 from LINUXexpert-org/fix-account-routing
Keep your own settings out of someone else's account
This commit is contained in:
@@ -0,0 +1,85 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { accountForCapability, ownAccountForCapability, type SessionLike } from "@/lib/accountRouting";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Found by sharing a folder between two real accounts.
|
||||||
|
*
|
||||||
|
* Switching to the account somebody shared pointed everything at it, because
|
||||||
|
* the rule was "use the selected account if it can do this" and a shared file
|
||||||
|
* account can, by definition, do files. ihasmail keeps its own settings in the
|
||||||
|
* account's Files, so changing any setting while looking at somebody's shared
|
||||||
|
* folder wrote `settings.json` into *their* storage, creating the `ihasmail`
|
||||||
|
* folder there to do it. Reading someone else's data by mistake is bad; writing
|
||||||
|
* yours into it is worse, and it was the same one-line rule doing both.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const CAL = "urn:ietf:params:jmap:calendars";
|
||||||
|
const FILES = "urn:ietf:params:jmap:filenode";
|
||||||
|
const MAIL = "urn:ietf:params:jmap:mail";
|
||||||
|
|
||||||
|
/** Mine does everything; theirs is a shared account with only files on it. */
|
||||||
|
const shared = (): SessionLike => ({
|
||||||
|
accounts: {
|
||||||
|
mine: { isPersonal: true, accountCapabilities: { [MAIL]: {}, [FILES]: {}, [CAL]: {} } },
|
||||||
|
theirs: { isPersonal: false, accountCapabilities: { [FILES]: {} } },
|
||||||
|
},
|
||||||
|
primaryAccounts: { [MAIL]: "mine", [FILES]: "mine", [CAL]: "mine" },
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("what the reader is looking at", () => {
|
||||||
|
it("follows the switch into a shared account for what was shared", () => {
|
||||||
|
expect(accountForCapability(shared(), "theirs", FILES)).toBe("theirs");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves everything else on the reader's own account", () => {
|
||||||
|
expect(accountForCapability(shared(), "theirs", MAIL)).toBe("mine");
|
||||||
|
expect(accountForCapability(shared(), "theirs", CAL)).toBe("mine");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still follows a switch between the reader's own accounts", () => {
|
||||||
|
const s = shared();
|
||||||
|
s.accounts.second = { isPersonal: true, accountCapabilities: { [MAIL]: {} } };
|
||||||
|
expect(accountForCapability(s, "second", MAIL)).toBe("second");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives up rather than aim at a shared account for something unshared", () => {
|
||||||
|
// No primary for calendars, and theirs does not offer them. The old rule
|
||||||
|
// fell back to the selection, which is somebody else's account.
|
||||||
|
const s = shared();
|
||||||
|
delete s.primaryAccounts[CAL];
|
||||||
|
expect(accountForCapability(s, "theirs", CAL)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets one of the reader's own accounts stand in when there is no primary", () => {
|
||||||
|
const s = shared();
|
||||||
|
delete s.primaryAccounts[CAL];
|
||||||
|
expect(accountForCapability(s, "mine", CAL)).toBe("mine");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("what belongs to the reader", () => {
|
||||||
|
it("stays on their own account while they look at a shared one", () => {
|
||||||
|
// The one that matters: settings are written through this.
|
||||||
|
expect(ownAccountForCapability(shared(), FILES)).toBe("mine");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores a primary account the server says is not the reader's", () => {
|
||||||
|
const s = shared();
|
||||||
|
s.primaryAccounts[FILES] = "theirs";
|
||||||
|
expect(ownAccountForCapability(s, FILES)).toBe("mine");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finds a personal account when no primary is named", () => {
|
||||||
|
const s = shared();
|
||||||
|
delete s.primaryAccounts[FILES];
|
||||||
|
expect(ownAccountForCapability(s, FILES)).toBe("mine");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("answers nothing rather than a shared account", () => {
|
||||||
|
const s: SessionLike = {
|
||||||
|
accounts: { theirs: { isPersonal: false, accountCapabilities: { [FILES]: {} } } },
|
||||||
|
primaryAccounts: {},
|
||||||
|
};
|
||||||
|
expect(ownAccountForCapability(s, FILES)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
* Which account a request goes to.
|
||||||
|
*
|
||||||
|
* A JMAP session lists more than one account whenever anything is shared with
|
||||||
|
* you: the sharer's account appears alongside your own, carrying whichever
|
||||||
|
* capabilities they shared. Switching to one is how you read their files, so
|
||||||
|
* some requests have to follow that selection.
|
||||||
|
*
|
||||||
|
* Others must never follow it, and telling the two apart is the whole point of
|
||||||
|
* this file. ihasmail keeps its own settings in the account's Files — that is
|
||||||
|
* what makes them travel between devices — and a shared file account advertises
|
||||||
|
* the file capability by definition. So the obvious rule, "use whichever
|
||||||
|
* account is selected if it can do this", writes your settings into the other
|
||||||
|
* person's storage the moment you change one while looking at their folder. It
|
||||||
|
* would create the `ihasmail` folder there to do it.
|
||||||
|
*
|
||||||
|
* Two questions, then, and they have different answers:
|
||||||
|
*
|
||||||
|
* - what am I *looking at* -> `accountForCapability`, follows the selection
|
||||||
|
* - what is *mine* -> `ownAccountForCapability`, never does
|
||||||
|
*
|
||||||
|
* There is a third rule hiding in the first. A capability the selected account
|
||||||
|
* does not advertise used to fall back to that account anyway, so a session
|
||||||
|
* with no primary account for something would aim it at whoever was selected —
|
||||||
|
* someone else. Falling back to nothing is the honest answer: the feature is
|
||||||
|
* unavailable, which is true, rather than pointed at a stranger's data.
|
||||||
|
*/
|
||||||
|
import type { Id } from "@/jmap/types";
|
||||||
|
|
||||||
|
export interface AccountLike {
|
||||||
|
/** JMAP: true when the account belongs to the authenticated user. */
|
||||||
|
isPersonal: boolean;
|
||||||
|
accountCapabilities?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionLike {
|
||||||
|
accounts: Record<Id, AccountLike>;
|
||||||
|
primaryAccounts: Record<string, Id>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const advertises = (account: AccountLike | undefined, cap: string): boolean =>
|
||||||
|
Boolean(account && cap in (account.accountCapabilities ?? {}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The account to read and write for this capability, honouring the switcher.
|
||||||
|
*
|
||||||
|
* Use for anything the reader is looking at: their mail, a shared calendar,
|
||||||
|
* somebody's files. Not for anything of the reader's own — see below.
|
||||||
|
*/
|
||||||
|
export function accountForCapability(session: SessionLike | null, selectedId: Id | null, cap: string): Id | null {
|
||||||
|
if (!session) return null;
|
||||||
|
const selected = selectedId ? session.accounts[selectedId] : undefined;
|
||||||
|
if (selected && advertises(selected, cap)) return selectedId;
|
||||||
|
const primary = session.primaryAccounts[cap];
|
||||||
|
if (primary) return primary;
|
||||||
|
// No primary, and the selection cannot serve this. Falling back to the
|
||||||
|
// selection would aim the request at a shared account for something nobody
|
||||||
|
// shared; only one of the reader's own accounts may stand in.
|
||||||
|
if (selected && selected.isPersonal) return selectedId;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The reader's own account for this capability, whatever they are looking at.
|
||||||
|
*
|
||||||
|
* Use for the reader's own state -- synced settings, signature images, push
|
||||||
|
* registration. These belong to them and follow them, and must not land in an
|
||||||
|
* account somebody shared just because it happens to be on screen.
|
||||||
|
*/
|
||||||
|
export function ownAccountForCapability(session: SessionLike | null, cap: string): Id | null {
|
||||||
|
if (!session) return null;
|
||||||
|
const primary = session.primaryAccounts[cap];
|
||||||
|
// A primary account is the reader's own by definition, but check rather than
|
||||||
|
// assume: a server that named a shared one here would otherwise be trusted.
|
||||||
|
if (primary && session.accounts[primary]?.isPersonal !== false) return primary;
|
||||||
|
const own = Object.entries(session.accounts).find(([, a]) => a.isPersonal && advertises(a, cap));
|
||||||
|
return own?.[0] ?? null;
|
||||||
|
}
|
||||||
@@ -36,7 +36,7 @@ let armed = false;
|
|||||||
let listenersBound = false;
|
let listenersBound = false;
|
||||||
|
|
||||||
export function settingsSyncAvailable(): boolean {
|
export function settingsSyncAvailable(): boolean {
|
||||||
return client.hasCapability(CAP.filenode) && Boolean(useSession.getState().accountFor(CAP.filenode));
|
return client.hasCapability(CAP.filenode) && Boolean(useSession.getState().ownAccountFor(CAP.filenode));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -46,7 +46,7 @@ export function settingsSyncAvailable(): boolean {
|
|||||||
*/
|
*/
|
||||||
export async function loadRemoteSettings(): Promise<Record<string, unknown> | null> {
|
export async function loadRemoteSettings(): Promise<Record<string, unknown> | null> {
|
||||||
if (!settingsSyncAvailable()) return null;
|
if (!settingsSyncAvailable()) return null;
|
||||||
const accountId = useSession.getState().accountFor(CAP.filenode)!;
|
const accountId = useSession.getState().ownAccountFor(CAP.filenode)!;
|
||||||
try {
|
try {
|
||||||
const folderId = await ensureFolder(accountId);
|
const folderId = await ensureFolder(accountId);
|
||||||
const node = await findInFolder(accountId, folderId, FILE);
|
const node = await findInFolder(accountId, folderId, FILE);
|
||||||
@@ -109,7 +109,7 @@ export async function flushSettingsPush(): Promise<void> {
|
|||||||
|
|
||||||
async function writeSettings(body: Record<string, unknown>): Promise<void> {
|
async function writeSettings(body: Record<string, unknown>): Promise<void> {
|
||||||
if (!settingsSyncAvailable()) return;
|
if (!settingsSyncAvailable()) return;
|
||||||
const accountId = useSession.getState().accountFor(CAP.filenode)!;
|
const accountId = useSession.getState().ownAccountFor(CAP.filenode)!;
|
||||||
const json = JSON.stringify(body, null, 2);
|
const json = JSON.stringify(body, null, 2);
|
||||||
// Byte length, not character count: a template or a signature with any
|
// Byte length, not character count: a template or a signature with any
|
||||||
// non-ASCII in it would otherwise be reported shorter than it is.
|
// non-ASCII in it would otherwise be reported shorter than it is.
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { toast } from "@/ui/toast";
|
|||||||
|
|
||||||
/** Upload an image for use in a signature; returns a same-origin blob URL. */
|
/** Upload an image for use in a signature; returns a same-origin blob URL. */
|
||||||
export async function uploadSignatureImage(file: File): Promise<string> {
|
export async function uploadSignatureImage(file: File): Promise<string> {
|
||||||
const accountId = useSession.getState().accountFor(CAP.filenode);
|
const accountId = useSession.getState().ownAccountFor(CAP.filenode);
|
||||||
if (!accountId || !client.hasCapability(CAP.filenode)) {
|
if (!accountId || !client.hasCapability(CAP.filenode)) {
|
||||||
toast.error("Images in signatures need the Files feature, which this account doesn't have.");
|
toast.error("Images in signatures need the Files feature, which this account doesn't have.");
|
||||||
throw new Error("filenode unavailable");
|
throw new Error("filenode unavailable");
|
||||||
@@ -42,7 +42,7 @@ export async function uploadSignatureImage(file: File): Promise<string> {
|
|||||||
|
|
||||||
/** Store the full HTML of an over-sized signature in Files; returns the blob id. */
|
/** Store the full HTML of an over-sized signature in Files; returns the blob id. */
|
||||||
export async function storeSignatureHtml(html: string): Promise<string> {
|
export async function storeSignatureHtml(html: string): Promise<string> {
|
||||||
const accountId = useSession.getState().accountFor(CAP.filenode);
|
const accountId = useSession.getState().ownAccountFor(CAP.filenode);
|
||||||
if (!accountId || !client.hasCapability(CAP.filenode)) throw new Error("This signature is too long for the server and the Files feature (needed to store long signatures) is not available.");
|
if (!accountId || !client.hasCapability(CAP.filenode)) throw new Error("This signature is too long for the server and the Files feature (needed to store long signatures) is not available.");
|
||||||
const up = await client.upload(accountId, new Blob([html], { type: "text/html" }), { type: "text/html" });
|
const up = await client.upload(accountId, new Blob([html], { type: "text/html" }), { type: "text/html" });
|
||||||
const folderId = await ensureFolder(accountId);
|
const folderId = await ensureFolder(accountId);
|
||||||
@@ -77,7 +77,9 @@ export async function externalizeDataImages(html: string): Promise<string> {
|
|||||||
|
|
||||||
/** Load the full HTML of a marker signature. */
|
/** Load the full HTML of a marker signature. */
|
||||||
export async function loadStoredSignature(blobId: string, type = "text/html"): Promise<string> {
|
export async function loadStoredSignature(blobId: string, type = "text/html"): Promise<string> {
|
||||||
const accountId = useSession.getState().accountFor(CAP.filenode) ?? useSession.getState().accountId;
|
// No `?? accountId` fallback: a signature is the reader's own, and the
|
||||||
|
// selected account may be somebody else's shared one.
|
||||||
|
const accountId = useSession.getState().ownAccountFor(CAP.filenode);
|
||||||
if (!accountId) throw new Error("no account");
|
if (!accountId) throw new Error("no account");
|
||||||
return client.fetchBlobText(accountId, blobId, type);
|
return client.fetchBlobText(accountId, blobId, type);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export async function enableWebPush(): Promise<{ ok: true } | { ok: false; reaso
|
|||||||
userVisibleOnly: true,
|
userVisibleOnly: true,
|
||||||
applicationServerKey: decodeApplicationServerKey(key),
|
applicationServerKey: decodeApplicationServerKey(key),
|
||||||
}));
|
}));
|
||||||
const accountId = useSession.getState().accountFor(CAP.mail);
|
const accountId = useSession.getState().ownAccountFor(CAP.mail);
|
||||||
const inboxId = useMail.getState().roleId("inbox");
|
const inboxId = useMail.getState().roleId("inbox");
|
||||||
await createSubscription(subscriptionPayload(sub, accountId, inboxId));
|
await createSubscription(subscriptionPayload(sub, accountId, inboxId));
|
||||||
listenForVerification();
|
listenForVerification();
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { create } from "zustand";
|
|||||||
import { apiFetch, ApiError, CAP, client } from "@/jmap/client";
|
import { apiFetch, ApiError, CAP, client } from "@/jmap/client";
|
||||||
import type { Id, JmapSession } from "@/jmap/types";
|
import type { Id, JmapSession } from "@/jmap/types";
|
||||||
import { push, type PushState } from "@/jmap/push";
|
import { push, type PushState } from "@/jmap/push";
|
||||||
|
import { accountForCapability, ownAccountForCapability } from "@/lib/accountRouting";
|
||||||
import { setServerLocale } from "@/lib/datetime";
|
import { setServerLocale } from "@/lib/datetime";
|
||||||
import { flushSettingsPush, stopSettingsSync } from "@/lib/settingsSync";
|
import { flushSettingsPush, stopSettingsSync } from "@/lib/settingsSync";
|
||||||
import { unsubscribeThisDevice } from "@/lib/webpush";
|
import { unsubscribeThisDevice } from "@/lib/webpush";
|
||||||
@@ -22,8 +23,10 @@ interface SessionState {
|
|||||||
logout(): Promise<void>;
|
logout(): Promise<void>;
|
||||||
refresh(): Promise<void>;
|
refresh(): Promise<void>;
|
||||||
setAccount(id: Id): void;
|
setAccount(id: Id): void;
|
||||||
/** Returns the accountId for a capability (primary), falling back to the selected mail account. */
|
/** The account to read and write for a capability, honouring the account switcher. */
|
||||||
accountFor(cap: string): Id | null;
|
accountFor(cap: string): Id | null;
|
||||||
|
/** The user's own account for a capability, whatever they are looking at. */
|
||||||
|
ownAccountFor(cap: string): Id | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useSession = create<SessionState>((set, get) => ({
|
export const useSession = create<SessionState>((set, get) => ({
|
||||||
@@ -97,11 +100,11 @@ export const useSession = create<SessionState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
accountFor(cap) {
|
accountFor(cap) {
|
||||||
const s = get().session;
|
return accountForCapability(get().session, get().accountId, cap);
|
||||||
if (!s) return null;
|
},
|
||||||
const selected = get().accountId;
|
|
||||||
if (selected && s.accounts[selected] && cap in (s.accounts[selected]?.accountCapabilities ?? {})) return selected;
|
ownAccountFor(cap) {
|
||||||
return s.primaryAccounts[cap] ?? selected ?? null;
|
return ownAccountForCapability(get().session, cap);
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user