mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
650451bdf7
A fourth built-in channel next to email, webhook and ntfy. Browsers register per device, the server encrypts per RFC 8291 and signs per RFC 8292 with node:crypto and sends only to known push services through the SSRF guard. The event matrix decides per category what arrives by push; admins switch the channel on under Notifications. VAPID keys come from the environment or are generated once, only while neither stored row exists, and kept encrypted. A pair that does not decrypt or match is never overwritten and never swapped for another one: push stays off with an error in the log and every device stays registered until the key is fixed. The subject is VAPID_SUBJECT, then an https APP_URL, then the project URL. Only chat messages collapse into one notification per trip, and a replacement still alerts. A 403 removes a device only after three failures in a row; 404 and 410 remove it at once. Password change, reset and an admin setting a password drop the user's devices with the other credentials. The request log redacts subscriptions and endpoints, boot errors print *** for every secret variable, and logout forgets the device with a plain fetch so an expired session cannot redirect midway. Addresses discussion #894.
277 lines
11 KiB
TypeScript
277 lines
11 KiB
TypeScript
/**
|
|
* Web Push subscriptions: what the browser may hand over (PUSHSUB-CHK-*) and
|
|
* the table behind it (PUSHSUB-*), on the real schema including migration 244.
|
|
*/
|
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
|
import { createECDH } from 'node:crypto';
|
|
import { MAX_PUSH_ENDPOINT_LENGTH, pushUnsubscribeRequestSchema } from '@trek/shared';
|
|
|
|
// One :memory: 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 { default: Database } = await import('better-sqlite3');
|
|
const db = new Database(':memory:');
|
|
db.exec('PRAGMA journal_mode = WAL');
|
|
db.exec('PRAGMA foreign_keys = ON');
|
|
return {
|
|
db,
|
|
closeDb: () => {},
|
|
reinitialize: () => {},
|
|
getPlaceWithTags: () => null,
|
|
canAccessTrip: () => null,
|
|
isOwner: () => false,
|
|
};
|
|
});
|
|
vi.mock('../../../../src/config', () => ({
|
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
|
updateJwtSecret: () => {},
|
|
}));
|
|
|
|
import { db as testDb } from '../../../../src/db/database';
|
|
import { createTables } from '../../../../src/db/schema';
|
|
import { runMigrations } from '../../../../src/db/migrations';
|
|
import { resetTestDb } from '../../../helpers/test-db';
|
|
import { createUser } from '../../../helpers/factories';
|
|
import { DatabaseService } from '../../../../src/nest/database/database.service';
|
|
import {
|
|
MAX_PUSH_DEVICES_PER_USER,
|
|
PushSubscriptionsService,
|
|
} from '../../../../src/nest/notifications/push/push-subscriptions.service';
|
|
import {
|
|
PUSH_SUBSCRIPTION_ERRORS,
|
|
checkPushEndpoint,
|
|
checkPushSubscription,
|
|
isPushServiceEndpoint,
|
|
type CheckedPushSubscription,
|
|
} from '../../../../src/nest/notifications/push/push-subscription.helpers';
|
|
|
|
const subs = new PushSubscriptionsService(new DatabaseService(testDb));
|
|
|
|
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(() => {
|
|
createTables(testDb);
|
|
runMigrations(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://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',
|
|
]) {
|
|
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://evilnotify.windows.com/x',
|
|
'https://.notify.windows.com/x',
|
|
'https://push.apple.com/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', () => {
|
|
const { user } = createUser(testDb);
|
|
const sub = checked(FCM);
|
|
expect(subs.hasAny(user.id)).toBe(false);
|
|
expect(subs.upsert(user.id, sub, 'server-key', 'Mozilla/5.0')).toBe(1);
|
|
const [row] = 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(subs.hasAny(user.id)).toBe(true);
|
|
expect(subs.countForUser(user.id)).toBe(1);
|
|
});
|
|
|
|
it('PUSHSUB-002: subscribing again refreshes the same row instead of adding one', () => {
|
|
const { user } = createUser(testDb);
|
|
subs.upsert(user.id, checked(FCM), 'old-key');
|
|
const [before] = subs.listForUser(user.id);
|
|
subs.recordFailure(before.id);
|
|
const fresh = checked(FCM);
|
|
expect(subs.upsert(user.id, fresh, 'new-key', null)).toBe(1);
|
|
const [after] = 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', () => {
|
|
const { user: first } = createUser(testDb);
|
|
const { user: second } = createUser(testDb);
|
|
subs.upsert(first.id, checked(FCM), 'k');
|
|
expect(subs.upsert(second.id, checked(FCM), 'k')).toBe(1);
|
|
expect(subs.countForUser(first.id)).toBe(0);
|
|
expect(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', () => {
|
|
const { user } = createUser(testDb);
|
|
for (let i = 0; i < MAX_PUSH_DEVICES_PER_USER; i++) {
|
|
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.
|
|
testDb.prepare("UPDATE push_subscriptions SET created_at = '2026-01-01 00:00:00'").run();
|
|
subs.upsert(user.id, checked('https://fcm.googleapis.com/fcm/send/d0'), 'k');
|
|
expect(subs.upsert(user.id, checked('https://fcm.googleapis.com/fcm/send/new'), 'k')).toBe(
|
|
MAX_PUSH_DEVICES_PER_USER,
|
|
);
|
|
const endpoints = 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', () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const { user: other } = createUser(testDb);
|
|
subs.upsert(owner.id, checked(FCM), 'k');
|
|
expect(subs.removeForUser(other.id, FCM)).toBe(false);
|
|
expect(subs.countForUser(owner.id)).toBe(1);
|
|
expect(subs.removeForUser(owner.id, FCM)).toBe(true);
|
|
expect(subs.countForUser(owner.id)).toBe(0);
|
|
});
|
|
|
|
it('PUSHSUB-006: success resets the failure count, failures add up, deleteById removes the row', () => {
|
|
const { user } = createUser(testDb);
|
|
subs.upsert(user.id, checked(FCM), 'k');
|
|
const [row] = subs.listForUser(user.id);
|
|
expect(subs.recordFailure(row.id)).toBe(1);
|
|
expect(subs.recordFailure(row.id)).toBe(2);
|
|
expect(subs.listForUser(user.id)[0].failure_count).toBe(2);
|
|
subs.recordSuccess(row.id);
|
|
const [after] = subs.listForUser(user.id);
|
|
expect(after.failure_count).toBe(0);
|
|
expect(after.last_success_at).not.toBeNull();
|
|
expect(subs.recordFailure(row.id)).toBe(1);
|
|
subs.deleteById(row.id);
|
|
expect(subs.hasAny(user.id)).toBe(false);
|
|
// A failure recorded for a row another send already removed changes nothing.
|
|
expect(subs.recordFailure(row.id)).toBe(0);
|
|
});
|
|
|
|
it('PUSHSUB-007: cuts a long user agent and the rows go with the user', () => {
|
|
const { user } = createUser(testDb);
|
|
subs.upsert(user.id, checked(FCM), 'k', 'x'.repeat(1000));
|
|
expect(subs.listForUser(user.id)[0].user_agent).toHaveLength(256);
|
|
testDb.prepare('DELETE FROM users WHERE id = ?').run(user.id);
|
|
expect(testDb.prepare('SELECT COUNT(*) AS n FROM push_subscriptions').get()).toEqual({ n: 0 });
|
|
});
|
|
|
|
it('PUSHSUB-008: migration 244 created the table with its user index', () => {
|
|
const index = testDb
|
|
.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');
|
|
});
|
|
});
|