mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 21:46:49 +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.
170 lines
7.0 KiB
TypeScript
170 lines
7.0 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { redact, SENSITIVE_KEYS } from '../../../src/middleware/globalMiddleware';
|
|
|
|
describe('globalMiddleware request-log redaction', () => {
|
|
it('redacts secretAccessKey (any casing) — storage admin PUT bodies land in the same debug log line', () => {
|
|
expect(redact({ secretAccessKey: 'sk-super-secret' })).toEqual({ secretAccessKey: '[REDACTED]' });
|
|
expect(redact({ SecretAccessKey: 'sk-super-secret' })).toEqual({ SecretAccessKey: '[REDACTED]' });
|
|
expect(redact({ secretaccesskey: 'sk-super-secret' })).toEqual({ secretaccesskey: '[REDACTED]' });
|
|
});
|
|
|
|
it('lists secretaccesskey in the lowercase lookup set (lookup lowercases the key first)', () => {
|
|
expect(SENSITIVE_KEYS.has('secretaccesskey')).toBe(true);
|
|
});
|
|
|
|
it('still redacts every pre-existing sensitive key (no regression)', () => {
|
|
const input = {
|
|
password: 'p',
|
|
new_password: 'p',
|
|
current_password: 'p',
|
|
token: 't',
|
|
jwt: 'j',
|
|
authorization: 'a',
|
|
cookie: 'c',
|
|
client_secret: 's',
|
|
mfa_token: 'm',
|
|
code: '123456',
|
|
smtp_pass: 's',
|
|
};
|
|
const out = redact(input) as Record<string, unknown>;
|
|
for (const key of Object.keys(input)) {
|
|
expect(out[key]).toBe('[REDACTED]');
|
|
}
|
|
});
|
|
|
|
it('redacts nested objects and arrays, leaving non-sensitive fields untouched', () => {
|
|
const out = redact({
|
|
name: 'off-box',
|
|
options: { secretAccessKey: 'sk-1', accessKeyId: 'ak-1' },
|
|
backends: [{ secretAccessKey: 'sk-2' }, { accessKeyId: 'ak-2' }],
|
|
});
|
|
expect(out).toEqual({
|
|
name: 'off-box',
|
|
options: { secretAccessKey: '[REDACTED]', accessKeyId: 'ak-1' },
|
|
backends: [{ secretAccessKey: '[REDACTED]' }, { accessKeyId: 'ak-2' }],
|
|
});
|
|
});
|
|
|
|
it('redacts secret-shaped keys by suffix — the OAuth token body and the settings API keys', () => {
|
|
const out = redact({
|
|
refresh_token: 'rt-1',
|
|
code_verifier: 'cv-1',
|
|
llm_api_key: 'sk-1',
|
|
mapbox_access_token: 'pk-1',
|
|
grant_type: 'refresh_token',
|
|
}) as Record<string, unknown>;
|
|
expect(out.refresh_token).toBe('[REDACTED]');
|
|
expect(out.code_verifier).toBe('[REDACTED]');
|
|
expect(out.llm_api_key).toBe('[REDACTED]');
|
|
expect(out.mapbox_access_token).toBe('[REDACTED]');
|
|
expect(out.grant_type).toBe('refresh_token');
|
|
});
|
|
|
|
it('redacts the value of a {key, value} settings body when the key names a secret', () => {
|
|
expect(redact({ key: 'carto_api_key', value: 'carto-secret' })).toEqual({
|
|
key: 'carto_api_key',
|
|
value: '[REDACTED]',
|
|
});
|
|
expect(redact({ key: 'mapbox_access_token', value: 'pk-1' })).toEqual({
|
|
key: 'mapbox_access_token',
|
|
value: '[REDACTED]',
|
|
});
|
|
// A harmless setting keeps its value: the name is what decides.
|
|
expect(redact({ key: 'map_tile_url', value: 'https://tiles.example/{z}/{x}/{y}.png' })).toEqual({
|
|
key: 'map_tile_url',
|
|
value: 'https://tiles.example/{z}/{x}/{y}.png',
|
|
});
|
|
});
|
|
|
|
it('matches the suffix on the end of the key only, so identifiers stay readable', () => {
|
|
expect(redact({ accessKeyId: 'ak-1', tokenCount: 5 })).toEqual({ accessKeyId: 'ak-1', tokenCount: 5 });
|
|
});
|
|
|
|
it('hides the Web Push subscription body: the endpoint capability URL and both keys', () => {
|
|
// POST /api/notifications/push/subscriptions, as PushSubscription.toJSON() hands it over.
|
|
expect(
|
|
redact({
|
|
subscription: {
|
|
endpoint: 'https://fcm.googleapis.com/fcm/send/device-capability',
|
|
expirationTime: null,
|
|
keys: { p256dh: 'BNcRdreALRFXTkOOUHK1EtK2wtaz5Ry4YfYCA', auth: 'tBHItJI5svbpez7KI4CCXg' },
|
|
},
|
|
}),
|
|
).toEqual({
|
|
subscription: {
|
|
endpoint: '[REDACTED]',
|
|
expirationTime: null,
|
|
keys: { p256dh: '[REDACTED]', auth: '[REDACTED]' },
|
|
},
|
|
});
|
|
});
|
|
|
|
it('hides the subscription the service worker posts again after the browser renewed it', () => {
|
|
// sw-push.js on pushsubscriptionchange: the same route and shape, and a renewed
|
|
// subscription may carry a real expiry instead of null.
|
|
expect(
|
|
redact({
|
|
subscription: {
|
|
endpoint: 'https://updates.push.services.mozilla.com/wpush/v2/renewed-capability',
|
|
expirationTime: 1767225600000,
|
|
keys: { p256dh: 'BOr1renewedPublicKey', auth: 'renewedAuthSecret' },
|
|
},
|
|
}),
|
|
).toEqual({
|
|
subscription: {
|
|
endpoint: '[REDACTED]',
|
|
expirationTime: 1767225600000,
|
|
keys: { p256dh: '[REDACTED]', auth: '[REDACTED]' },
|
|
},
|
|
});
|
|
});
|
|
|
|
it('hides the endpoint of a subscription that arrives without its keys', () => {
|
|
// The route refuses this body with a 400, but the request is logged all the same.
|
|
expect(
|
|
redact({ subscription: { endpoint: 'https://fcm.googleapis.com/fcm/send/keyless', expirationTime: null } }),
|
|
).toEqual({ subscription: { endpoint: '[REDACTED]', expirationTime: null } });
|
|
expect(redact({ subscription: { endpoint: 'https://fcm.googleapis.com/fcm/send/lone' } })).toEqual({
|
|
subscription: { endpoint: '[REDACTED]' },
|
|
});
|
|
expect(
|
|
redact({ subscription: { endpoint: 'https://fcm.googleapis.com/fcm/send/odd-keys', keys: 'not-an-object' } }),
|
|
).toEqual({ subscription: { endpoint: '[REDACTED]', keys: 'not-an-object' } });
|
|
});
|
|
|
|
it('hides the endpoint of the body that forgets a push device', () => {
|
|
// DELETE /api/notifications/push/subscriptions (logout, switching push off, the worker's renewal).
|
|
expect(redact({ endpoint: 'https://web.push.apple.com/QGx-device' })).toEqual({ endpoint: '[REDACTED]' });
|
|
expect(redact({ Endpoint: 'https://web.push.apple.com/QGx-device' })).toEqual({ Endpoint: '[REDACTED]' });
|
|
});
|
|
|
|
it('keeps an endpoint that is an ordinary setting readable, like the S3 backend URL', () => {
|
|
expect(
|
|
redact({ endpoint: 'http://127.0.0.1:9000', bucket: 'trek', region: 'us-east-1', secretAccessKey: 'sk' }),
|
|
).toEqual({ endpoint: 'http://127.0.0.1:9000', bucket: 'trek', region: 'us-east-1', secretAccessKey: '[REDACTED]' });
|
|
expect(redact({ endpoint: 'https://s3.example.com', forcePathStyle: true })).toEqual({
|
|
endpoint: 'https://s3.example.com',
|
|
forcePathStyle: true,
|
|
});
|
|
});
|
|
|
|
it('decides p256dh and auth by name wherever they appear, and nothing near them by accident', () => {
|
|
// Only the push subscription sends either field today; an `auth` anywhere
|
|
// else would carry credentials too. The fields around them stay readable.
|
|
expect(redact({ p256dh: 'BPub', Auth: 'secret', auth_method: 'basic', author: 'Ada' })).toEqual({
|
|
p256dh: '[REDACTED]',
|
|
Auth: '[REDACTED]',
|
|
auth_method: 'basic',
|
|
author: 'Ada',
|
|
});
|
|
expect(redact({ auth: { user: 'mailer', pass: 'hunter2' } })).toEqual({ auth: '[REDACTED]' });
|
|
});
|
|
|
|
it('passes non-object values through untouched', () => {
|
|
expect(redact('plain string')).toBe('plain string');
|
|
expect(redact(42)).toBe(42);
|
|
expect(redact(null)).toBe(null);
|
|
expect(redact(undefined)).toBe(undefined);
|
|
});
|
|
});
|