mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 16:06:32 +00:00
fix: never patch or judge another client's push subscription that reuses this device's subscription id
This commit is contained in:
@@ -59,7 +59,13 @@ const { DETACHED, SYNC_SESSION } = vi.hoisted(() => {
|
|||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
SYNC_SESSION,
|
SYNC_SESSION,
|
||||||
DETACHED: { loaded: true, types: ['EmailDelivery'] as string[], log: [] as unknown[], session: SYNC_SESSION },
|
DETACHED: {
|
||||||
|
loaded: true,
|
||||||
|
types: ['EmailDelivery'] as string[],
|
||||||
|
deviceClientId: null as string | null,
|
||||||
|
log: [] as unknown[],
|
||||||
|
session: SYNC_SESSION,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -82,7 +88,8 @@ vi.mock('../../api/jmap-client', () => ({
|
|||||||
const [name, args, id] = calls[0];
|
const [name, args, id] = calls[0];
|
||||||
DETACHED.log.push([name, args]);
|
DETACHED.log.push([name, args]);
|
||||||
if (name === 'PushSubscription/get') {
|
if (name === 'PushSubscription/get') {
|
||||||
return { methodResponses: [[name, { list: [{ id: args.ids[0], types: DETACHED.types }] }, id]] };
|
const listed = { id: args.ids[0], types: DETACHED.types, ...(DETACHED.deviceClientId ? { deviceClientId: DETACHED.deviceClientId } : {}) };
|
||||||
|
return { methodResponses: [[name, { list: [listed] }, id]] };
|
||||||
}
|
}
|
||||||
return { methodResponses: [[name, { updated: { [Object.keys(args.update)[0]]: null } }, id]] };
|
return { methodResponses: [[name, { updated: { [Object.keys(args.update)[0]]: null } }, id]] };
|
||||||
}
|
}
|
||||||
@@ -666,7 +673,7 @@ describe('push types for device sync (#34)', () => {
|
|||||||
await refreshPushSubscriptionTypes(OTHER);
|
await refreshPushSubscriptionTypes(OTHER);
|
||||||
expect(DETACHED.log).toEqual([
|
expect(DETACHED.log).toEqual([
|
||||||
['load', OTHER],
|
['load', OTHER],
|
||||||
['PushSubscription/get', { ids: ['bob-sub'], properties: ['id', 'types'] }],
|
['PushSubscription/get', { ids: ['bob-sub'], properties: ['id', 'deviceClientId', 'types'] }],
|
||||||
['PushSubscription/set', { update: { 'bob-sub': { types: ['EmailDelivery', 'CalendarEvent', 'Calendar'] } } }],
|
['PushSubscription/set', { update: { 'bob-sub': { types: ['EmailDelivery', 'CalendarEvent', 'Calendar'] } } }],
|
||||||
]);
|
]);
|
||||||
// The singleton's subscriptions were not touched.
|
// The singleton's subscriptions were not touched.
|
||||||
@@ -679,6 +686,40 @@ describe('push types for device sync (#34)', () => {
|
|||||||
expect(DETACHED.log.map((entry) => (entry as unknown[])[0])).toEqual(['load', 'PushSubscription/get']);
|
expect(DETACHED.log.map((entry) => (entry as unknown[])[0])).toEqual(['load', 'PushSubscription/get']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("leaves alone another client's subscription that reuses the recorded id", async () => {
|
||||||
|
// Stalwart gives a destroyed subscription's id to the next one, such as
|
||||||
|
// the webmail's on the same account.
|
||||||
|
await AsyncStorage.setItem(SUB_KEY, 'existing');
|
||||||
|
listMock.mockResolvedValue([{ ...healthy(['EmailDelivery']), deviceClientId: 'webmailwebmailwebmailwebmailwebm' }]);
|
||||||
|
await syncing({ [CONTACTS]: true });
|
||||||
|
await refreshPushSubscriptionTypes(ACCOUNT_ID);
|
||||||
|
expect(updateMock).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// The same through another account's client.
|
||||||
|
const OTHER = 'bob@other.example.net';
|
||||||
|
await AsyncStorage.setItem(`push:subscriptionId:v2:${OTHER}`, 'b');
|
||||||
|
await AsyncStorage.setItem(deviceClientIdKey(OTHER), 'bobsphonebobsphonebobsphonebobsp');
|
||||||
|
await AsyncStorage.setItem(DEVICE_SYNC_KEY, JSON.stringify({
|
||||||
|
state: { accounts: { [OTHER]: { enabled: { [CALENDAR]: true } } } },
|
||||||
|
version: 0,
|
||||||
|
}));
|
||||||
|
DETACHED.log = [];
|
||||||
|
DETACHED.types = ['EmailDelivery'];
|
||||||
|
DETACHED.deviceClientId = 'webmailwebmailwebmailwebmailwebm';
|
||||||
|
try {
|
||||||
|
await refreshPushSubscriptionTypes(OTHER);
|
||||||
|
expect(DETACHED.log.map((entry) => (entry as unknown[])[0])).toEqual(['load', 'PushSubscription/get']);
|
||||||
|
|
||||||
|
// Its own subscription still gets the types.
|
||||||
|
DETACHED.log = [];
|
||||||
|
DETACHED.deviceClientId = 'bobsphonebobsphonebobsphonebobsp';
|
||||||
|
await refreshPushSubscriptionTypes(OTHER);
|
||||||
|
expect(DETACHED.log.map((entry) => (entry as unknown[])[0])).toEqual(['load', 'PushSubscription/get', 'PushSubscription/set']);
|
||||||
|
} finally {
|
||||||
|
DETACHED.deviceClientId = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('leaves the subscription of an account that turned push off alone', async () => {
|
it('leaves the subscription of an account that turned push off alone', async () => {
|
||||||
await AsyncStorage.setItem(SUB_KEY, 'existing');
|
await AsyncStorage.setItem(SUB_KEY, 'existing');
|
||||||
await AsyncStorage.setItem(`push:optedOut:v1:${ACCOUNT_ID}`, '1');
|
await AsyncStorage.setItem(`push:optedOut:v1:${ACCOUNT_ID}`, '1');
|
||||||
@@ -1497,6 +1538,24 @@ describe('resyncPushNotifications', () => {
|
|||||||
expect(await AsyncStorage.getItem(OPTED_OUT_KEY)).not.toBeNull();
|
expect(await AsyncStorage.getItem(OPTED_OUT_KEY)).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("takes a recorded subscription whose id now names another client's for revoked", async () => {
|
||||||
|
// Revoked here, then the webmail on the same account subscribed and got
|
||||||
|
// the freed id.
|
||||||
|
await AsyncStorage.setItem(SUB_KEY, 'b');
|
||||||
|
await AsyncStorage.setItem(EXPIRES_KEY, inDays(5));
|
||||||
|
await AsyncStorage.setItem('push:accountIds:v1', JSON.stringify([ACCOUNT_ID]));
|
||||||
|
listMock.mockResolvedValue([
|
||||||
|
{ id: 'b', deviceClientId: 'webmailwebmailwebmailwebmailwebm', expires: inDays(7), types: ['EmailDelivery'] },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await resyncPushNotifications({ relayBaseUrl: RELAY });
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
expect(createMock).not.toHaveBeenCalled();
|
||||||
|
expect(destroyMock.mock.calls.map((c) => c[0])).not.toContain('b');
|
||||||
|
expect(await AsyncStorage.getItem(OPTED_OUT_KEY)).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('re-creates the subscription once the server behind the account was replaced (0.16.23 to 1.0)', async () => {
|
it('re-creates the subscription once the server behind the account was replaced (0.16.23 to 1.0)', async () => {
|
||||||
// A server reinstalled for Stalwart 1.0 holds none of the subscriptions,
|
// A server reinstalled for Stalwart 1.0 holds none of the subscriptions,
|
||||||
// which looks exactly like a revocation from elsewhere; the fingerprint
|
// which looks exactly like a revocation from elsewhere; the fingerprint
|
||||||
@@ -1613,8 +1672,12 @@ describe('resyncPushNotifications', () => {
|
|||||||
expect(await AsyncStorage.getItem(OPTED_OUT_KEY)).toBeNull();
|
expect(await AsyncStorage.getItem(OPTED_OUT_KEY)).toBeNull();
|
||||||
expect(await AsyncStorage.getItem(EXPIRES_KEY)).not.toBeNull();
|
expect(await AsyncStorage.getItem(EXPIRES_KEY)).not.toBeNull();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
// Turning push off forgot the device client id, so the new subscription
|
||||||
|
// carries a new one.
|
||||||
|
const deviceClientId = await AsyncStorage.getItem(deviceClientIdKey(ACCOUNT_ID));
|
||||||
|
expect(deviceClientId).not.toBe(OUR_DCID);
|
||||||
listMock.mockResolvedValue([
|
listMock.mockResolvedValue([
|
||||||
{ id: 'new-server-id', deviceClientId: OUR_DCID, expires: inDays(7), types: ['EmailDelivery'] },
|
{ id: 'new-server-id', deviceClientId, expires: inDays(7), types: ['EmailDelivery'] },
|
||||||
]);
|
]);
|
||||||
expect((await resyncPushNotifications({ relayBaseUrl: RELAY }))?.subscriptionId).toBe('new-server-id');
|
expect((await resyncPushNotifications({ relayBaseUrl: RELAY }))?.subscriptionId).toBe('new-server-id');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1703,8 +1703,25 @@ async function wasRevokedOnServer(accountId: string): Promise<boolean> {
|
|||||||
const expires = Date.parse((await AsyncStorage.getItem(subscriptionExpiresKey(accountId))) ?? '');
|
const expires = Date.parse((await AsyncStorage.getItem(subscriptionExpiresKey(accountId))) ?? '');
|
||||||
if (!storedServerId || !Number.isFinite(expires)) return false;
|
if (!storedServerId || !Number.isFinite(expires)) return false;
|
||||||
if (expires - Date.now() < REVOKED_EXPIRY_MARGIN_MS) return false;
|
if (expires - Date.now() < REVOKED_EXPIRY_MARGIN_MS) return false;
|
||||||
|
const deviceClientId = await AsyncStorage.getItem(deviceClientIdKey(accountId));
|
||||||
const subs = await listPushSubscriptions();
|
const subs = await listPushSubscriptions();
|
||||||
return !subs.some((s) => s.id === storedServerId);
|
return !subs.some((s) => isOwnSubscription(s, storedServerId, deviceClientId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a subscription the server lists is the one this device recorded.
|
||||||
|
* Stalwart hands out short ids and gives a destroyed subscription's id to the
|
||||||
|
* next one created ("b" in every account), so the id alone can name another
|
||||||
|
* client's subscription on the same account, such as the webmail's. A
|
||||||
|
* subscription listed without a device client id, or one recorded before the
|
||||||
|
* device kept its own, is taken on the id.
|
||||||
|
*/
|
||||||
|
function isOwnSubscription(
|
||||||
|
s: { id: string; deviceClientId?: string | null },
|
||||||
|
subscriptionId: string,
|
||||||
|
deviceClientId: string | null,
|
||||||
|
): boolean {
|
||||||
|
return s.id === subscriptionId && (!s.deviceClientId || !deviceClientId || s.deviceClientId === deviceClientId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1781,10 +1798,11 @@ export async function refreshPushSubscriptionTypes(accountId: string): Promise<v
|
|||||||
const username = jmapClient.username;
|
const username = jmapClient.username;
|
||||||
const serverUrl = jmapClient.serverUrl;
|
const serverUrl = jmapClient.serverUrl;
|
||||||
const served = !!username && !!serverUrl && generateAccountId(username, serverUrl) === accountId;
|
const served = !!username && !!serverUrl && generateAccountId(username, serverUrl) === accountId;
|
||||||
|
const deviceClientId = await AsyncStorage.getItem(deviceClientIdKey(accountId));
|
||||||
try {
|
try {
|
||||||
if (served) {
|
if (served) {
|
||||||
const types = await pushTypesFor(accountId, jmapClient.currentSession);
|
const types = await pushTypesFor(accountId, jmapClient.currentSession);
|
||||||
const current = (await listPushSubscriptions()).find((s) => s.id === subscriptionId);
|
const current = (await listPushSubscriptions()).find((s) => isOwnSubscription(s, subscriptionId, deviceClientId));
|
||||||
if (!current || sameTypes(current.types, types)) return;
|
if (!current || sameTypes(current.types, types)) return;
|
||||||
await updatePushSubscription(subscriptionId, { types: [...types] });
|
await updatePushSubscription(subscriptionId, { types: [...types] });
|
||||||
return;
|
return;
|
||||||
@@ -1794,11 +1812,13 @@ export async function refreshPushSubscriptionTypes(accountId: string): Promise<v
|
|||||||
const types = await pushTypesFor(accountId, client.currentSession);
|
const types = await pushTypesFor(accountId, client.currentSession);
|
||||||
const using = [CAPABILITIES.CORE];
|
const using = [CAPABILITIES.CORE];
|
||||||
const res = await client.request(
|
const res = await client.request(
|
||||||
[['PushSubscription/get', { ids: [subscriptionId], properties: ['id', 'types'] }, '0']],
|
[['PushSubscription/get', { ids: [subscriptionId], properties: ['id', 'deviceClientId', 'types'] }, '0']],
|
||||||
using,
|
using,
|
||||||
);
|
);
|
||||||
const current = (requireMethodResult(res, '0', 'PushSubscription/get').list as Array<{ id: string; types?: string[] | null }> | undefined)
|
const list = requireMethodResult(res, '0', 'PushSubscription/get').list as
|
||||||
?.find((s) => s.id === subscriptionId);
|
| Array<{ id: string; deviceClientId?: string | null; types?: string[] | null }>
|
||||||
|
| undefined;
|
||||||
|
const current = list?.find((s) => isOwnSubscription(s, subscriptionId, deviceClientId));
|
||||||
if (!current || sameTypes(current.types, types)) return;
|
if (!current || sameTypes(current.types, types)) return;
|
||||||
await client.request(
|
await client.request(
|
||||||
[['PushSubscription/set', { update: { [subscriptionId]: { types: [...types] } } }, '0']],
|
[['PushSubscription/set', { update: { [subscriptionId]: { types: [...types] } } }, '0']],
|
||||||
|
|||||||
Reference in New Issue
Block a user