| 1 | import { createHmac, timingSafeEqual } from 'node:crypto';
|
| 2 | import { lookup } from 'node:dns/promises';
|
| 3 | import { request as httpsRequest } from 'node:https';
|
| 4 | import { isIP } from 'node:net';
|
| 5 |
|
| 6 | const RESPONSE_LIMIT = 64 * 1024;
|
| 7 |
|
| 8 | function isPrivateIpv4(address: string) {
|
| 9 | const values = address.split('.').map(Number);
|
| 10 | const [a, b] = values;
|
| 11 | if (values.length !== 4 || values.some((value) => !Number.isInteger(value))) return true;
|
| 12 | return (
|
| 13 | a === 0 ||
|
| 14 | a === 10 ||
|
| 15 | a === 127 ||
|
| 16 | (a === 100 && b! >= 64 && b! <= 127) ||
|
| 17 | (a === 169 && b === 254) ||
|
| 18 | (a === 172 && b! >= 16 && b! <= 31) ||
|
| 19 | (a === 192 && b === 168) ||
|
| 20 | (a === 192 && b === 0) ||
|
| 21 | (a === 192 && b === 2) ||
|
| 22 | (a === 198 && (b === 18 || b === 19 || b === 51)) ||
|
| 23 | (a === 203 && b === 0) ||
|
| 24 | a! >= 224
|
| 25 | );
|
| 26 | }
|
| 27 |
|
| 28 | function isPrivateIp(address: string) {
|
| 29 | if (isIP(address) === 4) return isPrivateIpv4(address);
|
| 30 | const normalized = address.toLowerCase();
|
| 31 | if (normalized.startsWith('::ffff:')) return isPrivateIpv4(normalized.slice(7));
|
| 32 | return (
|
| 33 | normalized === '::' ||
|
| 34 | normalized === '::1' ||
|
| 35 | normalized.startsWith('fc') ||
|
| 36 | normalized.startsWith('fd') ||
|
| 37 | /^fe[89ab]/.test(normalized) ||
|
| 38 | normalized.startsWith('2001:db8:')
|
| 39 | );
|
| 40 | }
|
| 41 |
|
| 42 | export async function resolvePublicRuntimeEndpoint(value: string) {
|
| 43 | const url = new URL(value);
|
| 44 | if (url.protocol !== 'https:') throw new Error('Runtime endpoint must use HTTPS');
|
| 45 | if (url.username || url.password) throw new Error('Runtime endpoint cannot contain credentials');
|
| 46 | if (url.port && url.port !== '443') throw new Error('Runtime endpoint must use port 443');
|
| 47 | if (isIP(url.hostname) || url.hostname === 'localhost' || url.hostname.endsWith('.local'))
|
| 48 | throw new Error('Runtime endpoint must use a public DNS hostname');
|
| 49 | const addresses = await lookup(url.hostname, { all: true, verbatim: true });
|
| 50 | if (!addresses.length || addresses.some(({ address }) => isPrivateIp(address)))
|
| 51 | throw new Error('Runtime endpoint resolves to a private or reserved network');
|
| 52 | return { url, address: addresses[0]!.address };
|
| 53 | }
|
| 54 |
|
| 55 | export async function postRuntimeJson(
|
| 56 | endpoint: string,
|
| 57 | body: unknown,
|
| 58 | headers: Record<string, string> = {},
|
| 59 | ) {
|
| 60 | const target = await resolvePublicRuntimeEndpoint(endpoint);
|
| 61 | const encoded = JSON.stringify(body);
|
| 62 | return new Promise<{ status: number; body: unknown }>((resolve, reject) => {
|
| 63 | const request = httpsRequest(
|
| 64 | {
|
| 65 | protocol: 'https:',
|
| 66 | hostname: target.address,
|
| 67 | port: 443,
|
| 68 | path: `${target.url.pathname}${target.url.search}`,
|
| 69 | method: 'POST',
|
| 70 | servername: target.url.hostname,
|
| 71 | headers: {
|
| 72 | host: target.url.host,
|
| 73 | 'content-type': 'application/json',
|
| 74 | 'content-length': Buffer.byteLength(encoded),
|
| 75 | ...headers,
|
| 76 | },
|
| 77 | timeout: 10_000,
|
| 78 | },
|
| 79 | (response) => {
|
| 80 | const chunks: Buffer[] = [];
|
| 81 | let size = 0;
|
| 82 | response.on('data', (chunk: Buffer) => {
|
| 83 | size += chunk.length;
|
| 84 | if (size > RESPONSE_LIMIT) request.destroy(new Error('Runtime response is too large'));
|
| 85 | else chunks.push(chunk);
|
| 86 | });
|
| 87 | response.on('end', () => {
|
| 88 | const text = Buffer.concat(chunks).toString('utf8');
|
| 89 | let parsed: unknown = undefined;
|
| 90 | if (text) {
|
| 91 | try {
|
| 92 | parsed = JSON.parse(text);
|
| 93 | } catch {
|
| 94 | parsed = text;
|
| 95 | }
|
| 96 | }
|
| 97 | resolve({ status: response.statusCode ?? 500, body: parsed });
|
| 98 | });
|
| 99 | },
|
| 100 | );
|
| 101 | request.on('timeout', () => request.destroy(new Error('Runtime endpoint timed out')));
|
| 102 | request.on('error', reject);
|
| 103 | request.end(encoded);
|
| 104 | });
|
| 105 | }
|
| 106 |
|
| 107 | export function runtimeDeliverySignature(
|
| 108 | secret: string,
|
| 109 | deliveryId: string,
|
| 110 | timestamp: string,
|
| 111 | body: string,
|
| 112 | ) {
|
| 113 | return createHmac('sha256', secret)
|
| 114 | .update(`${timestamp}.${deliveryId}.${body}`)
|
| 115 | .digest('base64url');
|
| 116 | }
|
| 117 |
|
| 118 | export function verifyRuntimeDeliverySignature(
|
| 119 | secret: string,
|
| 120 | deliveryId: string,
|
| 121 | timestamp: string,
|
| 122 | body: string,
|
| 123 | signature: string,
|
| 124 | now = Date.now(),
|
| 125 | ) {
|
| 126 | if (Math.abs(now - Date.parse(timestamp)) > 5 * 60_000) return false;
|
| 127 | const expected = Buffer.from(
|
| 128 | runtimeDeliverySignature(secret, deliveryId, timestamp, body),
|
| 129 | 'base64url',
|
| 130 | );
|
| 131 | const actual = Buffer.from(signature.replace(/^v1=/, ''), 'base64url');
|
| 132 | return actual.length === expected.length && timingSafeEqual(actual, expected);
|
| 133 | }
|