openvibers/openvibe.shared egress.js

egress.js

main 7.4 KB · 140 lines · javascript

1'use strict';
2/**
3 * openvibe-shared/egress: the one rule for connecting to an address that someone else chose (a URL a
4 * user typed, a developer's webhook endpoint, a host a network tool probes). Live, Events and Tools
5 * each had their own copy and the copies had drifted; this is the strictest union of them.
6 *
7 * isPublicAddress(ip) true only for a globally routable unicast address: no loopback, RFC 1918,
8 * link-local (cloud metadata), CGNAT, 0/8, multicast, documentation
9 * (incl. 3fff::/20), benchmarking, reserved, unique/site-local IPv6, SRv6
10 * SIDs, and no IPv6 form wrapping one of those (v4-mapped, v4-compatible,
11 * NAT64 64:ff9b::/96 and 64:ff9b:1::/48, 6to4, Teredo)
12 * embeddedV4(ip), expandV6(ip)
13 * normalizeHost(host), isInternalName(host) names refused before DNS (localhost, *.local, …)
14 * safeLookup dns.lookup replacement for http/https/net: fails unless EVERY answer is
15 * public, so the socket connects to the checked address (no rebinding).
16 * Node does not call lookup for an IP literal: check literals first
17 * (assertPublicUrl does, on every redirect hop)
18 * createSafeLookup({ lookup, isAllowed }) the same with an injected resolver (tests)
19 * assertPublicUrl(url, { lookup, protocols }) -> URL, or throws EgressDenied
20 * EgressDenied code EGRESS_DENIED
21 *
22 * No dependencies beyond Node. Server-side only.
23 */
24const dns = require('dns');
25const net = require('net');
26
27const V4_BLOCKED = [
28 ['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],
29 ['192.0.0.0', 24], ['192.0.2.0', 24], ['192.88.99.0', 24], ['192.168.0.0', 16], ['198.18.0.0', 15],
30 ['198.51.100.0', 24], ['203.0.113.0', 24], ['224.0.0.0', 4], ['240.0.0.0', 4],
31];
32const V6_BLOCKED = [
33 ['::', 128], ['::1', 128], ['::', 96], ['100::', 64], ['2001::', 23], ['2001:db8::', 32], ['3fff::', 20],
34 ['5f00::', 16], ['fc00::', 7], ['fe80::', 10], ['fec0::', 10], ['ff00::', 8],
35];
36const blocked = new net.BlockList();
37for (const [a, p] of V4_BLOCKED) blocked.addSubnet(a, p, 'ipv4');
38for (const [a, p] of V6_BLOCKED) blocked.addSubnet(a, p, 'ipv6');
39
40/** An IPv6 address as its 8 numeric words, or null. */
41function expandV6(ip) {
42 let s = String(ip || '').toLowerCase();
43 const pct = s.indexOf('%');
44 if (pct >= 0) s = s.slice(0, pct);
45 if (!net.isIPv6(s)) return null;
46 const dq = /(\d+\.\d+\.\d+\.\d+)$/.exec(s);
47 if (dq) {
48 const p = dq[1].split('.').map(Number);
49 s = s.slice(0, -dq[1].length) + `${((p[0] << 8) | p[1]).toString(16)}:${((p[2] << 8) | p[3]).toString(16)}`;
50 }
51 const [head, tail] = s.split('::');
52 const h = head ? head.split(':') : [];
53 const t = tail !== undefined ? (tail ? tail.split(':') : []) : null;
54 const words = t === null ? h : [...h, ...Array(8 - h.length - t.length).fill('0'), ...t];
55 if (words.length !== 8) return null;
56 return words.map((w) => parseInt(w || '0', 16));
57}
58
59/** The IPv4 address an IPv6 address reaches (mapped, compatible, NAT64, 6to4, Teredo), or null. */
60function embeddedV4(ip) {
61 const w = expandV6(ip);
62 if (!w) return null;
63 const v4 = (hi, lo) => `${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`;
64 const zeros = (a, b) => w.slice(a, b).every((x) => x === 0);
65 if (zeros(0, 5) && (w[5] === 0xffff || w[5] === 0) && (w[6] || w[7])) return v4(w[6], w[7]); // ::ffff:a.b.c.d, ::a.b.c.d
66 if (w[0] === 0x64 && w[1] === 0xff9b) return v4(w[6], w[7]); // 64:ff9b::/96 and 64:ff9b:1::/48
67 if (w[0] === 0x2002) return v4(w[1], w[2]); // 6to4
68 if (w[0] === 0x2001 && w[1] === 0) return v4(w[6] ^ 0xffff, w[7] ^ 0xffff); // Teredo client
69 return null;
70}
71
72/** True only for a globally routable unicast address the server may connect to for someone else. */
73function isPublicAddress(ip) {
74 const s = String(ip || '');
75 const family = net.isIP(s);
76 if (family === 4) return !blocked.check(s, 'ipv4');
77 if (family === 6) {
78 const bare = s.split('%')[0];
79 if (blocked.check(bare, 'ipv6')) return false;
80 const v4 = embeddedV4(bare);
81 return v4 === null ? true : !blocked.check(v4, 'ipv4');
82 }
83 return false;
84}
85
86/** Lower-case, drop [brackets] and trailing dots. */
87function normalizeHost(host) {
88 let h = String(host || '').trim().toLowerCase();
89 if (h.startsWith('[') && h.endsWith(']')) h = h.slice(1, -1);
90 return h.replace(/\.+$/, '');
91}
92
93/** Names that can only mean this machine or a private network: refused before any DNS. */
94function isInternalName(host) {
95 const h = normalizeHost(host);
96 if (net.isIP(h)) return false;
97 return !h || h === 'localhost' || h.endsWith('.localhost') || h.endsWith('.local') || h.endsWith('.internal')
98 || h.endsWith('.home.arpa') || !h.includes('.');
99}
100
101class EgressDenied extends Error {
102 constructor(message) { super(message); this.name = 'EgressDenied'; this.code = 'EGRESS_DENIED'; this.status = 403; }
103}
104
105/** A dns.lookup-compatible function that fails unless every answer passes `isAllowed`. */
106function createSafeLookup({ lookup = dns.lookup, isAllowed = isPublicAddress } = {}) {
107 return function safeLookup(hostname, options, callback) {
108 if (typeof options === 'function') { callback = options; options = {}; }
109 const opts = typeof options === 'number' ? { family: options } : { ...(options || {}) };
110 const host = normalizeHost(hostname);
111 const finish = (addrs) => {
112 if (!addrs.length) return callback(new EgressDenied(`no address for ${host}`));
113 if (addrs.some((a) => !isAllowed(a.address))) return callback(new EgressDenied(`${host} resolves to a non-public address`));
114 if (opts.all) return callback(null, addrs);
115 return callback(null, addrs[0].address, addrs[0].family);
116 };
117 if (net.isIP(host)) return finish([{ address: host, family: net.isIP(host) }]);
118 if (isInternalName(host)) return callback(new EgressDenied(`${host} is not a public host name`));
119 lookup(host, { all: true, family: opts.family || 0 }, (err, addrs) => (err ? callback(err) : finish(addrs || [])));
120 };
121}
122const safeLookup = createSafeLookup();
123
124/** The URL, once its scheme is allowed and its host resolves to public addresses only; else throws EgressDenied. */
125async function assertPublicUrl(url, { lookup, protocols = ['http:', 'https:'] } = {}) {
126 let u;
127 try { u = url instanceof URL ? url : new URL(String(url)); } catch { throw new EgressDenied('not a URL'); }
128 if (!protocols.includes(u.protocol)) throw new EgressDenied(`${u.protocol} is not allowed`);
129 if (u.username || u.password) throw new EgressDenied('credentials in the URL are not allowed');
130 const look = lookup ? createSafeLookup({ lookup }) : safeLookup;
131 await new Promise((resolve, reject) => look(u.hostname, { all: true }, (e) => (e ? reject(e) : resolve())));
132 return u;
133}
134
135module.exports = {
136 isPublicAddress, embeddedV4, expandV6, normalizeHost, isInternalName,
137 safeLookup, createSafeLookup, assertPublicUrl, EgressDenied,
138 V4_BLOCKED: Object.freeze(V4_BLOCKED.map((x) => Object.freeze(x))), V6_BLOCKED: Object.freeze(V6_BLOCKED.map((x) => Object.freeze(x))),
139};
140