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