import type { LookupFunction } from 'node:net'; import * as plugins from '../plugins.js'; export type TWebPushEndpointPolicyErrorCode = | 'ENDPOINT_URL_INVALID' | 'ENDPOINT_SCHEME_INVALID' | 'ENDPOINT_PORT_INVALID' | 'ENDPOINT_AUTHORITY_INVALID' | 'ENDPOINT_FRAGMENT_INVALID' | 'ENDPOINT_IP_LITERAL_FORBIDDEN' | 'ENDPOINT_DNS_EMPTY' | 'ENDPOINT_DNS_UNSAFE'; export class WebPushEndpointPolicyError extends Error { public constructor(public readonly code: TWebPushEndpointPolicyErrorCode) { super(code); this.name = 'WebPushEndpointPolicyError'; } } export interface IWebPushDnsAddress { address: string; family: number; } export type TWebPushDnsLookup = ( hostnameArg: string, ) => Promise; export interface IWebPushResolvedEndpoint { url: URL; hostname: string; address: string; family: 4 | 6; } const defaultDnsLookup: TWebPushDnsLookup = async (hostnameArg) => { return await plugins.dns.promises.lookup(hostnameArg, { all: true, verbatim: true, }); }; export function isGloballyRoutableWebPushAddress(addressArg: string): boolean { const family = plugins.net.isIP(addressArg); if (family !== 4 && family !== 6) return false; try { const parsed = plugins.ipaddr.parse(addressArg); if ( parsed.kind() === 'ipv6' && 'isIPv4MappedAddress' in parsed && parsed.isIPv4MappedAddress() ) return false; return parsed.range() === 'unicast'; } catch { return false; } } export function parseWebPushEndpoint(endpointArg: string): URL { if (typeof endpointArg !== 'string' || endpointArg.length === 0 || endpointArg.length > 4096) { throw new WebPushEndpointPolicyError('ENDPOINT_URL_INVALID'); } let endpoint: URL; try { endpoint = new URL(endpointArg); } catch { throw new WebPushEndpointPolicyError('ENDPOINT_URL_INVALID'); } if (endpoint.protocol !== 'https:') { throw new WebPushEndpointPolicyError('ENDPOINT_SCHEME_INVALID'); } if (endpoint.port && endpoint.port !== '443') { throw new WebPushEndpointPolicyError('ENDPOINT_PORT_INVALID'); } if (endpoint.username || endpoint.password || !endpoint.hostname) { throw new WebPushEndpointPolicyError('ENDPOINT_AUTHORITY_INVALID'); } if (endpoint.hash) { throw new WebPushEndpointPolicyError('ENDPOINT_FRAGMENT_INVALID'); } const hostname = endpoint.hostname.startsWith('[') && endpoint.hostname.endsWith(']') ? endpoint.hostname.slice(1, -1) : endpoint.hostname; if (plugins.net.isIP(hostname) !== 0) { throw new WebPushEndpointPolicyError('ENDPOINT_IP_LITERAL_FORBIDDEN'); } return endpoint; } export async function resolveWebPushEndpoint( endpointArg: string, lookupArg: TWebPushDnsLookup = defaultDnsLookup, ): Promise { const url = parseWebPushEndpoint(endpointArg); const hostname = url.hostname.toLowerCase(); const answers = await lookupArg(hostname); if (!Array.isArray(answers) || answers.length === 0) { throw new WebPushEndpointPolicyError('ENDPOINT_DNS_EMPTY'); } const normalized = answers.map((answerArg) => ({ address: String(answerArg.address || '').trim(), family: plugins.net.isIP(String(answerArg.address || '').trim()), })); if ( normalized.some((answerArg) => ( (answerArg.family !== 4 && answerArg.family !== 6) || !isGloballyRoutableWebPushAddress(answerArg.address) )) ) { throw new WebPushEndpointPolicyError('ENDPOINT_DNS_UNSAFE'); } const unique = Array.from( new Map(normalized.map((answerArg) => [`${answerArg.family}:${answerArg.address}`, answerArg])).values(), ); const selected = unique[0] as { address: string; family: 4 | 6 }; return { url, hostname, address: selected.address, family: selected.family, }; } export function createPinnedWebPushAgent( resolvedArg: IWebPushResolvedEndpoint, ): InstanceType { const lookup: LookupFunction = (hostnameArg, optionsArg, callbackArg) => { if (hostnameArg.toLowerCase() !== resolvedArg.hostname) { const error = new Error('Pinned Web Push agent hostname mismatch') as NodeJS.ErrnoException; error.code = 'EHOSTUNREACH'; callbackArg(error, '', 0); return; } if (optionsArg.all) { callbackArg(null, [{ address: resolvedArg.address, family: resolvedArg.family, }]); return; } callbackArg(null, resolvedArg.address, resolvedArg.family); }; return new plugins.https.Agent({ keepAlive: false, maxSockets: 1, maxFreeSockets: 0, lookup, }); }