mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-12 23:31:02 +00:00
30158cddc3
The apps subscribe to Web Push like a browser, with their own key pair, but with an endpoint on liketrek's relay: only liketrek holds the apps' APNs and FCM keys. The server encrypts exactly as for a browser (RFC 8291), so the relay hands on ciphertext it cannot read and the device decrypts it. Lookalike hosts stay refused.
279 lines
12 KiB
TypeScript
279 lines
12 KiB
TypeScript
/**
|
|
* Web Push subscriptions: what the browser may hand over (PUSHSUB-CHK-*) and
|
|
* the table behind it (PUSHSUB-*), on the migrated snapshot including legacy step 245.
|
|
*/
|
|
import { db as testDb } from '../../../../src/db/database';
|
|
import { PushSubscriptions } from '../../../../src/db/entities/PushSubscriptions.entity';
|
|
import { Users } from '../../../../src/db/entities/Users.entity';
|
|
import {
|
|
PUSH_SUBSCRIPTION_ERRORS,
|
|
checkPushEndpoint,
|
|
checkPushSubscription,
|
|
isPushServiceEndpoint,
|
|
type CheckedPushSubscription,
|
|
} from '../../../../src/nest/notifications/push/push-subscription.helpers';
|
|
import {
|
|
MAX_PUSH_DEVICES_PER_USER,
|
|
PushSubscriptionsService,
|
|
} from '../../../../src/nest/notifications/push/push-subscriptions.service';
|
|
import { createUser } from '../../../helpers/factories';
|
|
import { countRows, deleteRows, updateRows } from '../../../helpers/factories/rows';
|
|
import { makePushSubscriptionsService } from '../../../helpers/notifications';
|
|
import { resetTestDb } from '../../../helpers/test-db';
|
|
import { sharedTestOrm } from '../../../helpers/test-uow';
|
|
import { MAX_PUSH_ENDPOINT_LENGTH, pushUnsubscribeRequestSchema } from '@trek/shared';
|
|
|
|
import { createECDH } from 'node:crypto';
|
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
|
|
|
// One snapshot connection per file, created inside the factory so nothing has
|
|
// to be hoisted above the imports; the tests reach it through the mocked module.
|
|
vi.mock('../../../../src/db/database', async () => {
|
|
const { createSnapshotTestDb } = await import('../../../helpers/db-mock');
|
|
const db = createSnapshotTestDb();
|
|
return {
|
|
db,
|
|
closeDb: () => {},
|
|
reinitialize: () => {},
|
|
getPlaceWithTags: () => null,
|
|
canAccessTrip: () => null,
|
|
isOwner: () => false,
|
|
};
|
|
});
|
|
|
|
let subs: PushSubscriptionsService;
|
|
|
|
function browserKeys() {
|
|
const ua = createECDH('prime256v1');
|
|
ua.generateKeys();
|
|
return { p256dh: ua.getPublicKey('base64url'), auth: Buffer.from('0123456789abcdef').toString('base64url') };
|
|
}
|
|
|
|
function checked(endpoint: string): CheckedPushSubscription {
|
|
const result = checkPushSubscription({ endpoint, keys: browserKeys() });
|
|
if ('error' in result) throw new Error(result.error);
|
|
return result.value;
|
|
}
|
|
|
|
const FCM = 'https://fcm.googleapis.com/fcm/send/device-1';
|
|
|
|
beforeAll(async () => {
|
|
subs = await makePushSubscriptionsService(testDb);
|
|
});
|
|
|
|
beforeEach(() => {
|
|
resetTestDb(testDb);
|
|
});
|
|
|
|
afterAll(() => {
|
|
testDb.close();
|
|
});
|
|
|
|
describe('checkPushEndpoint / checkPushSubscription', () => {
|
|
it('PUSHSUB-CHK-001: accepts the push services browsers use', () => {
|
|
for (const endpoint of [
|
|
FCM,
|
|
'https://android.googleapis.com/gcm/send/x',
|
|
'https://jmt17.google.com/fcm/send/x',
|
|
'https://updates.push.services.mozilla.com/wpush/v2/gAAAA',
|
|
'https://push.services.mozilla.com/wpush/v1/x',
|
|
'https://web.push.apple.com/QGx9',
|
|
'https://api.push.apple.com/3/device/x',
|
|
'https://wns2-par02p.notify.windows.com/w/?token=BQYAAA',
|
|
// The TREK apps' relay, which hands the ciphertext on to Apple or Google.
|
|
'https://push.liketrek.com/v1/apns/production/abc123',
|
|
]) {
|
|
expect(checkPushEndpoint(endpoint), endpoint).toBe('ok');
|
|
expect(isPushServiceEndpoint(endpoint)).toBe(true);
|
|
}
|
|
});
|
|
|
|
it('PUSHSUB-CHK-002: refuses anything that is not a plain https URL', () => {
|
|
for (const endpoint of [
|
|
'not a url',
|
|
'http://fcm.googleapis.com/fcm/send/x',
|
|
'https://user:pw@fcm.googleapis.com/fcm/send/x',
|
|
'https://fcm.googleapis.com:8443/fcm/send/x',
|
|
`https://fcm.googleapis.com/${'a'.repeat(MAX_PUSH_ENDPOINT_LENGTH)}`,
|
|
]) {
|
|
expect(checkPushEndpoint(endpoint), endpoint).toBe('endpoint');
|
|
}
|
|
});
|
|
|
|
it('PUSHSUB-CHK-002b: takes an endpoint exactly as long as the contract allows, and not one character more', () => {
|
|
const base = 'https://fcm.googleapis.com/fcm/send/';
|
|
const longest = `${base}${'a'.repeat(MAX_PUSH_ENDPOINT_LENGTH - base.length)}`;
|
|
// The contract and the check share one limit, so a device that could register can also be sent to and removed.
|
|
expect(pushUnsubscribeRequestSchema.safeParse({ endpoint: longest }).success).toBe(true);
|
|
expect(checkPushEndpoint(longest)).toBe('ok');
|
|
expect(checkPushEndpoint(`${longest}a`)).toBe('endpoint');
|
|
});
|
|
|
|
it('PUSHSUB-CHK-003: refuses hosts that are not push services, including lookalikes', () => {
|
|
for (const endpoint of [
|
|
'https://example.com/push',
|
|
'https://127.0.0.1/push',
|
|
'https://fcm.googleapis.com.evil.test/x',
|
|
'https://jmt17.google.com.evil.test/x',
|
|
'https://google.com/fcm/send/x',
|
|
'https://evilnotify.windows.com/x',
|
|
'https://.notify.windows.com/x',
|
|
'https://push.apple.com/x',
|
|
'https://push.liketrek.com.evil.test/v1/apns/x',
|
|
'https://evilpush.liketrek.com/v1/apns/x',
|
|
]) {
|
|
expect(checkPushEndpoint(endpoint), endpoint).not.toBe('ok');
|
|
}
|
|
expect(checkPushSubscription({ endpoint: 'https://example.com/push', keys: browserKeys() })).toEqual({
|
|
ok: false,
|
|
error: PUSH_SUBSCRIPTION_ERRORS.service,
|
|
});
|
|
expect(checkPushSubscription({ endpoint: 'http://fcm.googleapis.com/x', keys: browserKeys() })).toEqual({
|
|
ok: false,
|
|
error: PUSH_SUBSCRIPTION_ERRORS.endpoint,
|
|
});
|
|
});
|
|
|
|
it('PUSHSUB-CHK-004: wants a p256dh on the curve and a 16-byte auth secret', () => {
|
|
const keys = browserKeys();
|
|
const offCurve = Buffer.from(keys.p256dh, 'base64url');
|
|
offCurve[64] ^= 0x01;
|
|
for (const p256dh of [
|
|
'!!!',
|
|
Buffer.alloc(65, 4).toString('base64url'),
|
|
offCurve.toString('base64url'),
|
|
keys.auth,
|
|
]) {
|
|
expect(checkPushSubscription({ endpoint: FCM, keys: { ...keys, p256dh } })).toEqual({
|
|
ok: false,
|
|
error: PUSH_SUBSCRIPTION_ERRORS.p256dh,
|
|
});
|
|
}
|
|
for (const auth of ['!!!', Buffer.alloc(8).toString('base64url')]) {
|
|
expect(checkPushSubscription({ endpoint: FCM, keys: { ...keys, auth } })).toEqual({
|
|
ok: false,
|
|
error: PUSH_SUBSCRIPTION_ERRORS.auth,
|
|
});
|
|
}
|
|
});
|
|
|
|
it('PUSHSUB-CHK-005: hands the keys back canonical, without padding', () => {
|
|
const keys = browserKeys();
|
|
const result = checkPushSubscription({
|
|
endpoint: FCM,
|
|
keys: { p256dh: `${keys.p256dh}=`, auth: `${keys.auth}==` },
|
|
});
|
|
expect(result).toEqual({ ok: true, value: { endpoint: FCM, p256dh: keys.p256dh, auth: keys.auth } });
|
|
});
|
|
});
|
|
|
|
describe('PushSubscriptionsService', () => {
|
|
it('PUSHSUB-001: stores a subscription with the key it was made for and counts devices', async () => {
|
|
const { user } = createUser(testDb);
|
|
const sub = checked(FCM);
|
|
expect(await subs.hasAny(user.id)).toBe(false);
|
|
expect(await subs.upsert(user.id, sub, 'server-key', 'Mozilla/5.0')).toBe(1);
|
|
const [row] = await subs.listForUser(user.id);
|
|
expect(row).toMatchObject({
|
|
user_id: user.id,
|
|
endpoint: FCM,
|
|
p256dh: sub.p256dh,
|
|
auth: sub.auth,
|
|
vapid_public_key: 'server-key',
|
|
user_agent: 'Mozilla/5.0',
|
|
failure_count: 0,
|
|
last_success_at: null,
|
|
});
|
|
expect(await subs.hasAny(user.id)).toBe(true);
|
|
expect(await subs.countForUser(user.id)).toBe(1);
|
|
});
|
|
|
|
it('PUSHSUB-002: subscribing again refreshes the same row instead of adding one', async () => {
|
|
const { user } = createUser(testDb);
|
|
await subs.upsert(user.id, checked(FCM), 'old-key');
|
|
const [before] = await subs.listForUser(user.id);
|
|
await subs.recordFailure(before.id);
|
|
const fresh = checked(FCM);
|
|
expect(await subs.upsert(user.id, fresh, 'new-key', null)).toBe(1);
|
|
const [after] = await subs.listForUser(user.id);
|
|
expect(after.id).toBe(before.id);
|
|
expect(after).toMatchObject({
|
|
p256dh: fresh.p256dh,
|
|
vapid_public_key: 'new-key',
|
|
failure_count: 0,
|
|
user_agent: null,
|
|
});
|
|
});
|
|
|
|
it('PUSHSUB-003: a browser shared by two accounts belongs to whoever subscribed on it last', async () => {
|
|
const { user: first } = createUser(testDb);
|
|
const { user: second } = createUser(testDb);
|
|
await subs.upsert(first.id, checked(FCM), 'k');
|
|
expect(await subs.upsert(second.id, checked(FCM), 'k')).toBe(1);
|
|
expect(await subs.countForUser(first.id)).toBe(0);
|
|
expect((await subs.listForUser(second.id)).map((r) => r.endpoint)).toEqual([FCM]);
|
|
});
|
|
|
|
it('PUSHSUB-004: caps the devices per user and drops the one registered longest ago', async () => {
|
|
const { user } = createUser(testDb);
|
|
for (let i = 0; i < MAX_PUSH_DEVICES_PER_USER; i++) {
|
|
await subs.upsert(user.id, checked(`https://fcm.googleapis.com/fcm/send/d${i}`), 'k');
|
|
}
|
|
// Same second for every row; the id breaks the tie, so d0 is the oldest.
|
|
// d0 subscribing again renews it, which leaves d1 as the oldest.
|
|
await updateRows(await sharedTestOrm(testDb), PushSubscriptions, {}, { created_at: '2026-01-01 00:00:00' });
|
|
await subs.upsert(user.id, checked('https://fcm.googleapis.com/fcm/send/d0'), 'k');
|
|
expect(await subs.upsert(user.id, checked('https://fcm.googleapis.com/fcm/send/new'), 'k')).toBe(
|
|
MAX_PUSH_DEVICES_PER_USER,
|
|
);
|
|
const endpoints = (await subs.listForUser(user.id)).map((r) => r.endpoint);
|
|
expect(endpoints).toContain('https://fcm.googleapis.com/fcm/send/d0');
|
|
expect(endpoints).toContain('https://fcm.googleapis.com/fcm/send/new');
|
|
expect(endpoints).not.toContain('https://fcm.googleapis.com/fcm/send/d1');
|
|
});
|
|
|
|
it('PUSHSUB-005: a user can only remove their own endpoint', async () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const { user: other } = createUser(testDb);
|
|
await subs.upsert(owner.id, checked(FCM), 'k');
|
|
expect(await subs.removeForUser(other.id, FCM)).toBe(false);
|
|
expect(await subs.countForUser(owner.id)).toBe(1);
|
|
expect(await subs.removeForUser(owner.id, FCM)).toBe(true);
|
|
expect(await subs.countForUser(owner.id)).toBe(0);
|
|
});
|
|
|
|
it('PUSHSUB-006: success resets the failure count, failures add up, deleteById removes the row', async () => {
|
|
const { user } = createUser(testDb);
|
|
await subs.upsert(user.id, checked(FCM), 'k');
|
|
const [row] = await subs.listForUser(user.id);
|
|
expect(await subs.recordFailure(row.id)).toBe(1);
|
|
expect(await subs.recordFailure(row.id)).toBe(2);
|
|
expect((await subs.listForUser(user.id))[0].failure_count).toBe(2);
|
|
await subs.recordSuccess(row.id);
|
|
const [after] = await subs.listForUser(user.id);
|
|
expect(after.failure_count).toBe(0);
|
|
expect(after.last_success_at).not.toBeNull();
|
|
expect(await subs.recordFailure(row.id)).toBe(1);
|
|
await subs.deleteById(row.id);
|
|
expect(await subs.hasAny(user.id)).toBe(false);
|
|
// A failure recorded for a row another send already removed changes nothing.
|
|
expect(await subs.recordFailure(row.id)).toBe(0);
|
|
});
|
|
|
|
it('PUSHSUB-007: cuts a long user agent and the rows go with the user', async () => {
|
|
const { user } = createUser(testDb);
|
|
await subs.upsert(user.id, checked(FCM), 'k', 'x'.repeat(1000));
|
|
expect((await subs.listForUser(user.id))[0].user_agent).toHaveLength(256);
|
|
await deleteRows(await sharedTestOrm(testDb), Users, { id: user.id });
|
|
expect(await countRows(await sharedTestOrm(testDb), PushSubscriptions)).toBe(0);
|
|
});
|
|
|
|
it('PUSHSUB-008: migration 245 created the table with its user index', async () => {
|
|
const index = testDb
|
|
// test-sql-allow: the index list comes from sqlite_master, which no entity or repository maps.
|
|
.prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'push_subscriptions'")
|
|
.all() as { name: string }[];
|
|
expect(index.map((i) => i.name)).toContain('idx_push_subscriptions_user');
|
|
});
|
|
});
|