push-sidecar/unifiedpush.js
Rama main · réplica de lectura del repositorio original.
const dns = require('dns');
const https = require('https');
const net = require('net');
const os = require('os');
const webpush = require('web-push');
// The relay forwards whatever endpoint a client registered, so without these
// limits the sidecar would POST to any URL a client names, including hosts
// inside this machine's network.
const MAX_TOKEN_LEN = 2048;
const MAX_ENDPOINT_LEN = 1024;
const ALLOW_PRIVATE = process.env.UNIFIEDPUSH_ALLOW_PRIVATE === '1';
// Matches the relay's offline buffer: a wake older than that finds nothing.
const TTL_SECS = 24 * 60 * 60;
const TIMEOUT_MS = 5000;
const vapidDetails =
process.env.VAPID_PUBLIC_KEY && process.env.VAPID_PRIVATE_KEY && process.env.VAPID_SUBJECT
? {
subject: process.env.VAPID_SUBJECT,
publicKey: process.env.VAPID_PUBLIC_KEY,
privateKey: process.env.VAPID_PRIVATE_KEY,
}
: null;
const blocked = new net.BlockList();
for (const [addr, prefix] of [
['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8],
['169.254.0.0', 16], ['172.16.0.0', 12], ['192.0.0.0', 24], ['192.0.2.0', 24],
['192.168.0.0', 16], ['198.18.0.0', 15], ['198.51.100.0', 24], ['203.0.113.0', 24],
['224.0.0.0', 4], ['240.0.0.0', 4],
]) blocked.addSubnet(addr, prefix, 'ipv4');
for (const [addr, prefix] of [
['::', 128], ['::1', 128], ['64:ff9b::', 96], ['100::', 64], ['2001:db8::', 32],
['fc00::', 7], ['fe80::', 10], ['ff00::', 8],
]) blocked.addSubnet(addr, prefix, 'ipv6');
// This machine's own public addresses reach its listeners from inside the firewall.
// Read once at start; without the list the ranges above still hold.
function blockOwnAddresses(interfaces) {
try {
for (const list of Object.values(interfaces || os.networkInterfaces())) {
for (const a of list || []) {
blocked.addAddress(a.address, a.family === 6 || a.family === 'IPv6' ? 'ipv6' : 'ipv4');
}
}
} catch (err) {
console.error(`[push-sidecar] own addresses not read: ${err.code || err.message}`);
}
}
blockOwnAddresses();
function isPublicAddress(address, family) {
if (family === 6 || family === 'IPv6') {
// An IPv4-mapped address is judged as the IPv4 address it carries.
const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(address);
if (mapped) return !blocked.check(mapped[1], 'ipv4');
return !blocked.check(address, 'ipv6');
}
return !blocked.check(address, 'ipv4');
}
// Filters inside the socket's own resolution, so the address that is checked
// is the address that is connected to.
function guardedLookup(hostname, options, callback) {
dns.lookup(hostname, { ...options, all: true }, (err, addresses) => {
if (err) return callback(err);
const allowed = ALLOW_PRIVATE
? addresses
: addresses.filter(a => isPublicAddress(a.address, a.family));
if (allowed.length === 0) {
const e = new Error('endpoint resolves to a non-public address');
e.code = 'ENDPOINT_NOT_PUBLIC';
return callback(e);
}
if (options.all) return callback(null, allowed);
callback(null, allowed[0].address, allowed[0].family);
});
}
const agent = new https.Agent({ lookup: guardedLookup, keepAlive: true, maxSockets: 16 });
const B64URL = /^[A-Za-z0-9_-]+={0,2}$/;
// Parses the app's registration, or null when anything is missing or off.
function parseToken(token) {
if (typeof token !== 'string' || token.length > MAX_TOKEN_LEN) return null;
let t;
try {
t = JSON.parse(token);
} catch (_) {
return null;
}
if (!t || t.v !== 1) return null;
const { endpoint, p256dh, auth } = t;
if (typeof endpoint !== 'string' || endpoint.length > MAX_ENDPOINT_LEN) return null;
if (typeof p256dh !== 'string' || !B64URL.test(p256dh) || p256dh.length > 128) return null;
if (typeof auth !== 'string' || !B64URL.test(auth) || auth.length > 64) return null;
let url;
try {
url = new URL(endpoint);
} catch (_) {
return null;
}
if (url.protocol !== 'https:' || url.username || url.password) return null;
// Sockets skip the lookup for an IP literal, so the guard runs here too.
const literal = url.hostname.replace(/^\[|\]$/g, '');
const family = net.isIP(literal);
if (family && !ALLOW_PRIVATE && !isPublicAddress(literal, family)) return null;
return { url, subscription: { endpoint, keys: { p256dh, auth } } };
}
// Encrypts [data] to the device's key set and posts it to its endpoint.
// Resolves to the HTTP status and body code the relay gets back.
async function sendUnifiedPush(token, data) {
const parsed = parseToken(token);
if (!parsed) return { status: 400, code: 'bad_unifiedpush_token' };
// Nothing from the endpoint is logged: its path is the device's private address
// on the push server, and a self-hosted server's host names its owner.
try {
await webpush.sendNotification(parsed.subscription, JSON.stringify(data), {
TTL: TTL_SECS,
urgency: 'high',
contentEncoding: 'aes128gcm',
vapidDetails,
agent,
timeout: TIMEOUT_MS,
});
return { status: 200, code: 'ok' };
} catch (err) {
if (err.statusCode === 404 || err.statusCode === 410) {
console.error(`[push-sidecar] endpoint gone platform=unifiedpush status=${err.statusCode}`);
return { status: 410, code: 'token_expired' };
}
if (err.code === 'ENDPOINT_NOT_PUBLIC') {
console.error('[push-sidecar] refused platform=unifiedpush (non-public address)');
return { status: 400, code: 'endpoint_not_public' };
}
console.error(
`[push-sidecar] UnifiedPush error status=${err.statusCode || '-'} code=${err.code || '-'}`
);
return { status: 502, code: 'error' };
}
}
module.exports = { sendUnifiedPush, parseToken, isPublicAddress, blockOwnAddresses };