import { WebPush } from '@teleporthq/teleport-shared' import { generateLazyPgClientCode } from '@teleporthq/teleport-plugin-next-workflows' import { toJsLiteral } from '../pwa/source-literals' export const WEB_PUSH_MODULE_PATH = ['utils', 'push'] export const WEB_PUSH_MODULE_NAME = 'web-push-server' /** * The push services the app sends to. A subscription whose endpoint points * anywhere else is refused when it is saved: the endpoint is a URL the server * POSTs to, so accepting any would let a visitor aim the app at an internal * address (server-side request forgery). */ const PUSH_SERVICE_HOSTS = [ 'fcm.googleapis.com', 'android.googleapis.com', 'updates.push.services.mozilla.com', 'web.push.apple.com', ] const PUSH_SERVICE_HOST_SUFFIXES = [ '.push.services.mozilla.com', '.push.apple.com', '.notify.windows.com', ] const KEYS_MISMATCH_MESSAGE = 'The push notification keys of this site do not belong together. Create new keys in Project settings > Push notifications and publish again.' /** * `utils/push/web-push-server.js`: the store of the browsers that subscribed * (`teleport_push_subscriptions`, in the project's TeleportHQ database) and the * sender. Required only by the two push routes. Every subscription is stored * with the public key it was made with, and only the ones made with the * CURRENT key are sent to — a regenerated key never produces a burst of * authentication failures. * * Authored as source text (no backslash, no template literal). */ export const buildWebPushServerModule = (): string => `/** * Web Push for this app — generated by TeleportHQ. Required by * pages/api/push/subscriptions.js and pages/api/push/send.js only. */ ${generateLazyPgClientCode()} var crypto = require('crypto'); var TABLE = ${toJsLiteral(WebPush.SUBSCRIPTIONS_TABLE)}; var PUBLIC_KEY_ENV = ${toJsLiteral(WebPush.PUBLIC_KEY_ENV)}; var PRIVATE_KEY_ENV = ${toJsLiteral(WebPush.PRIVATE_KEY_ENV)}; var SEND_TOKEN_PURPOSE = ${toJsLiteral(WebPush.SEND_TOKEN_PURPOSE)}; var NOT_CONFIGURED_MESSAGE = ${toJsLiteral(WebPush.NOT_CONFIGURED_MESSAGE)}; var KEYS_MISMATCH_MESSAGE = ${toJsLiteral(KEYS_MISMATCH_MESSAGE)}; var UNRESOLVED_SECRET_PREFIX = 'teleporthq.secrets.'; var PUSH_SERVICE_HOSTS = ${toJsLiteral(PUSH_SERVICE_HOSTS)}; var PUSH_SERVICE_HOST_SUFFIXES = ${toJsLiteral(PUSH_SERVICE_HOST_SUFFIXES)}; var BASE64URL_RE = /^[A-Za-z0-9_-]+={0,2}$/; var MAX_ENDPOINT_LENGTH = 2048; var MAX_KEY_LENGTH = 200; // Ids of every sign-in the app offers: a users-table UUID, or the provider's // own id for a Google or GitHub sign-in. var MAX_USER_ID_LENGTH = 255; var MAX_CURSOR_LENGTH = 100; var PAGE_SIZE = 500; var CONCURRENCY = 50; // A workflow waits for the send inside its own serverless call: one call stops // starting new sends after this long and hands the rest of the audience on. var TIME_BUDGET_MS = 5000; // A push service that stops answering is given up on after this long. var REQUEST_TIMEOUT_MS = 3000; var DEFAULT_TTL_SECONDS = 86400; var MAX_TTL_SECONDS = 2419200; var URGENCIES = ['very-low', 'low', 'normal', 'high']; var AUDIENCES = ['all', 'user', 'subscription']; var LIMITS = { title: 200, body: 1000, url: 2048, icon: 2048, image: 2048, tag: 100 }; // What fits in the single 4096-byte record every push service accepts, once // encrypted: aes128gcm adds an 86-byte header, a 16-byte tag and a delimiter. var MAX_PAYLOAD_BYTES = 3993; // Dropped, in this order, from a notification that does not fit; then the // text is shortened. var DROPPABLE_FIELDS = ['image', 'icon']; var ELLIPSIS = '…'; var __webPush = null; function loadWebPush() { if (!__webPush) { __webPush = require('web-push'); } return __webPush; } function env(name) { var value = typeof process.env[name] === 'string' ? process.env[name].trim() : ''; return value.indexOf(UNRESOLVED_SECRET_PREFIX) === 0 ? '' : value; } function hasDatabase() { return !!(env('TELEPORT_DB_CONNECTION_STRING') || env('TELEPORT_DB_HOST')) && !!__loadPg(); } function decodeBase64Url(value, maxLength) { if (typeof value !== 'string' || !value || value.length > maxLength || !BASE64URL_RE.test(value)) { return null; } return Buffer.from(value, 'base64'); } // The private key must produce the public key browsers subscribe with: a pair // that does not match signs notifications every push service refuses. var __keysProblem = null; function keysProblem() { if (__keysProblem === null) { var publicKey = decodeBase64Url(env(PUBLIC_KEY_ENV), MAX_KEY_LENGTH); var privateKey = decodeBase64Url(env(PRIVATE_KEY_ENV), MAX_KEY_LENGTH); __keysProblem = KEYS_MISMATCH_MESSAGE; if (publicKey && privateKey) { try { var ecdh = crypto.createECDH('prime256v1'); ecdh.setPrivateKey(privateKey); var derived = ecdh.getPublicKey(); if (derived.length === publicKey.length && crypto.timingSafeEqual(derived, publicKey)) { __keysProblem = ''; } } catch (error) { // Not a P-256 private key at all. } } } return __keysProblem; } // What stops push from working on this deployment, in words; empty when it works. function configurationProblem() { if (!env(PUBLIC_KEY_ENV) || !env(PRIVATE_KEY_ENV) || !hasDatabase()) { return NOT_CONFIGURED_MESSAGE; } return keysProblem(); } // A page built before the keys were regenerated subscribes with the old public // key; storing that subscription under the new one would make it unreachable. function isCurrentKey(value) { return typeof value === 'string' && value === env(PUBLIC_KEY_ENV); } function isPushServiceHost(host) { if (PUSH_SERVICE_HOSTS.indexOf(host) !== -1) { return true; } return PUSH_SERVICE_HOST_SUFFIXES.some(function (suffix) { return host.length > suffix.length && host.slice(-suffix.length) === suffix; }); } function normalizeEndpoint(value) { if (typeof value !== 'string' || !value || value.length > MAX_ENDPOINT_LENGTH) { return null; } var url; try { url = new URL(value); } catch (error) { return null; } if (url.protocol !== 'https:' || url.username || url.password || url.port) { return null; } return isPushServiceHost(url.hostname.toLowerCase()) ? url.href : null; } // A browser's key must be a point on the P-256 curve: the app could never // encrypt a notification for any other. var __curveProbe = null; function isCurvePoint(bytes) { try { if (!__curveProbe) { __curveProbe = crypto.createECDH('prime256v1'); __curveProbe.generateKeys(); } __curveProbe.computeSecret(bytes); return true; } catch (error) { return false; } } // p256dh is an uncompressed P-256 point (65 bytes, 0x04 first); auth is 16 bytes. function normalizeSubscription(input) { if (!input || typeof input !== 'object') { return null; } var endpoint = normalizeEndpoint(input.endpoint); var keys = input.keys && typeof input.keys === 'object' ? input.keys : {}; var p256dh = decodeBase64Url(keys.p256dh, MAX_KEY_LENGTH); var auth = decodeBase64Url(keys.auth, MAX_KEY_LENGTH); if (!endpoint || !p256dh || p256dh.length !== 65 || p256dh[0] !== 4 || !auth || auth.length !== 16) { return null; } if (!isCurvePoint(p256dh)) { return null; } return { endpoint: endpoint, p256dh: keys.p256dh, auth: keys.auth }; } function normalizeUserId(value) { var id = typeof value === 'number' && isFinite(value) ? String(value) : typeof value === 'string' ? value.trim() : ''; return id && id.length <= MAX_USER_ID_LENGTH ? id : null; } async function withClient(work) { var client = getClient(); await client.connect(); try { return await work(client); } finally { await client.end().catch(function () {}); } } // Upserted by endpoint, and only by the browser holding the subscription: a // stored one changes only when the request carries its auth secret, so knowing // an endpoint is not enough to relink or replace a visitor's browser. The user // is whoever is signed in NOW: a browser that re-saves after signing out stops // receiving that account's notifications. Resolves to false when the endpoint // is stored with another secret. async function saveSubscription(subscription, options) { var previousEndpoint = normalizeEndpoint(options.previousEndpoint); var previousAuth = typeof options.previousAuth === 'string' ? options.previousAuth : ''; return withClient(async function (client) { var result = await client.query( 'INSERT INTO ' + TABLE + ' (endpoint, p256dh, auth, vapid_public_key, user_id, updated_at)' + ' VALUES ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP)' + ' ON CONFLICT (endpoint) DO UPDATE SET p256dh = EXCLUDED.p256dh,' + ' vapid_public_key = EXCLUDED.vapid_public_key, user_id = EXCLUDED.user_id, updated_at = CURRENT_TIMESTAMP' + ' WHERE ' + TABLE + '.auth = EXCLUDED.auth', [subscription.endpoint, subscription.p256dh, subscription.auth, env(PUBLIC_KEY_ENV), normalizeUserId(options.userId)] ); if (!result.rowCount) { return false; } if (previousEndpoint && previousAuth && previousEndpoint !== subscription.endpoint) { await client.query('DELETE FROM ' + TABLE + ' WHERE endpoint = $1 AND auth = $2', [previousEndpoint, previousAuth]); } return true; }); } async function deleteSubscription(endpoint, auth) { var normalized = normalizeEndpoint(endpoint); if (!normalized || typeof auth !== 'string' || !auth) { return false; } return withClient(async function (client) { var result = await client.query('DELETE FROM ' + TABLE + ' WHERE endpoint = $1 AND auth = $2', [normalized, auth]); return (result.rowCount || 0) > 0; }); } function clip(value, max) { var text = typeof value === 'number' && isFinite(value) ? String(value) : typeof value === 'string' ? value : ''; return text.trim().slice(0, max); } function normalizeNotification(input) { var source = input && typeof input === 'object' ? input : {}; var title = clip(source.title, LIMITS.title); if (!title) { return null; } var notification = { title: title }; ['body', 'url', 'icon', 'image', 'tag'].forEach(function (field) { var value = clip(source[field], LIMITS[field]); if (value) { notification[field] = value; } }); if (source.requireInteraction === true) { notification.requireInteraction = true; } if (source.silent === true) { notification.silent = true; } return notification; } function payloadBytes(notification) { return Buffer.byteLength(JSON.stringify(notification), 'utf8'); } // The notification as the JSON payload, made to fit what a push service // accepts: the image and the icon go first, then the text is shortened. // Null when even the title, link and tag alone are too long. function fitPayload(notification) { var fitted = Object.assign({}, notification); DROPPABLE_FIELDS.forEach(function (field) { if (payloadBytes(fitted) > MAX_PAYLOAD_BYTES) { delete fitted[field]; } }); if (fitted.body && payloadBytes(fitted) > MAX_PAYLOAD_BYTES) { var characters = Array.from(fitted.body); var fits = 0; var tooMany = characters.length; while (tooMany - fits > 1) { var middle = Math.floor((fits + tooMany) / 2); var candidate = Object.assign({}, fitted, { body: characters.slice(0, middle).join('') + ELLIPSIS }); if (payloadBytes(candidate) <= MAX_PAYLOAD_BYTES) { fits = middle; } else { tooMany = middle; } } if (fits > 0) { fitted.body = characters.slice(0, fits).join('') + ELLIPSIS; } else { delete fitted.body; } } return payloadBytes(fitted) <= MAX_PAYLOAD_BYTES ? JSON.stringify(fitted) : null; } // Only a known audience is sent to: a mistyped one must never turn a message // meant for one person into a broadcast. function audienceFilter(request) { var audience = request.audience === undefined || request.audience === null || request.audience === '' ? 'all' : request.audience; if (AUDIENCES.indexOf(audience) === -1) { return { error: 'Unknown audience "' + String(audience).slice(0, 40) + '". Send to all, user or subscription.' }; } if (audience === 'user') { var userId = normalizeUserId(request.userId); if (!userId) { return { error: 'Notifying one user needs the id of a signed-in user.' }; } return { clause: ' AND user_id = $2', params: [userId] }; } if (audience === 'subscription') { var endpoint = normalizeEndpoint(request.endpoint); if (!endpoint) { return { error: 'Notifying one browser needs the endpoint of its push subscription.' }; } return { clause: ' AND endpoint = $2', params: [endpoint] }; } return { clause: '', params: [] }; } function timeToLive(value) { var parsed = parseInt(value, 10); if (isNaN(parsed)) { return DEFAULT_TTL_SECONDS; } return Math.min(Math.max(parsed, 0), MAX_TTL_SECONDS); } // Runs the worker over the items, a limited number at a time, taking them in // order and taking no more once shouldStop says so. Returns how many were // taken — always a prefix of the items, so the last one taken is where to resume. async function forEachConcurrently(items, limit, shouldStop, worker) { var next = 0; var runners = []; for (var runner = 0; runner < Math.min(limit, items.length); runner++) { runners.push( (async function () { while (next < items.length && !shouldStop()) { var item = items[next]; next += 1; await worker(item); } })() ); } await Promise.all(runners); return next; } // 404 and 410 mean the browser dropped the subscription: it is deleted. Any // other refusal is counted and kept — a misconfigured key must never wipe the // merchant's subscribers. A send that runs out of time reports complete: false // with the cursor a later call resumes after (as request.after). async function sendNotification(request, subject) { var notification = normalizeNotification(request.notification); if (!notification) { return { success: false, error: 'A push notification needs a title.' }; } var audience = audienceFilter(request); if (audience.error) { return { success: false, error: audience.error }; } var payload = fitPayload(notification); if (!payload) { return { success: false, error: 'This push notification is too long to send. Shorten its title, link or tag.' }; } var webPush = loadWebPush(); var options = { TTL: timeToLive(request.ttl), urgency: URGENCIES.indexOf(request.urgency) !== -1 ? request.urgency : 'normal', timeout: REQUEST_TIMEOUT_MS, vapidDetails: { subject: subject, publicKey: env(PUBLIC_KEY_ENV), privateKey: env(PRIVATE_KEY_ENV) }, }; var deadline = Date.now() + TIME_BUDGET_MS; var pastDeadline = function () { return Date.now() > deadline; }; var summary = { success: true, sent: 0, failed: 0, removed: 0, complete: true }; var gone = []; var cursor = typeof request.after === 'string' && request.after && request.after.length <= MAX_CURSOR_LENGTH ? request.after : null; await withClient(async function (client) { for (;;) { var params = [env(PUBLIC_KEY_ENV)].concat(audience.params); var after = ''; if (cursor) { params.push(cursor); after = ' AND id > $' + params.length; } var result = await client.query( 'SELECT id, endpoint, p256dh, auth FROM ' + TABLE + ' WHERE vapid_public_key = $1' + audience.clause + after + ' ORDER BY id LIMIT ' + PAGE_SIZE, params ); var rows = result.rows || []; var taken = await forEachConcurrently(rows, CONCURRENCY, pastDeadline, async function (row) { try { await webPush.sendNotification({ endpoint: row.endpoint, keys: { p256dh: row.p256dh, auth: row.auth } }, payload, options); summary.sent += 1; } catch (error) { summary.failed += 1; if (error && (error.statusCode === 404 || error.statusCode === 410)) { gone.push(row.endpoint); } } }); if (taken > 0) { cursor = String(rows[taken - 1].id); } var morePages = rows.length === PAGE_SIZE; if (taken < rows.length || (morePages && pastDeadline())) { summary.complete = false; summary.cursor = cursor; break; } if (!morePages) { break; } } if (gone.length > 0) { var removed = await client.query('DELETE FROM ' + TABLE + ' WHERE endpoint = ANY($1)', [gone]); summary.removed = removed.rowCount || 0; } }); return summary; } function sendToken() { return crypto.createHmac('sha256', env(PRIVATE_KEY_ENV)).update(SEND_TOKEN_PURPOSE).digest('hex'); } function verifySendToken(value) { if (typeof value !== 'string' || !env(PRIVATE_KEY_ENV)) { return false; } var expected = Buffer.from(sendToken(), 'utf8'); var given = Buffer.from(value, 'utf8'); return given.length === expected.length && crypto.timingSafeEqual(given, expected); } module.exports = { configurationProblem: configurationProblem, isCurrentKey: isCurrentKey, normalizeSubscription: normalizeSubscription, normalizeUserId: normalizeUserId, saveSubscription: saveSubscription, deleteSubscription: deleteSubscription, sendNotification: sendNotification, verifySendToken: verifySendToken, }; `