openvibers/openvibe.shared egress.js
egress.js
| 1 | ; |
| 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 | */ |
| 24 | const dns = require('dns'); |
| 25 | const net = require('net'); |
| 26 | |
| 27 | const 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 | ]; |
| 32 | const 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 | ]; |
| 36 | const blocked = new net.BlockList(); |
| 37 | for (const [a, p] of V4_BLOCKED) blocked.addSubnet(a, p, 'ipv4'); |
| 38 | for (const [a, p] of V6_BLOCKED) blocked.addSubnet(a, p, 'ipv6'); |
| 39 | |
| 40 | /** An IPv6 address as its 8 numeric words, or null. */ |
| 41 | function 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. */ |
| 60 | function 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. */ |
| 73 | function 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. */ |
| 87 | function 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. */ |
| 94 | function 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 | |
| 101 | class 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`. */ |
| 106 | function 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 | } |
| 122 | const safeLookup = createSafeLookup(); |
| 123 | |
| 124 | /** The URL, once its scheme is allowed and its host resolves to public addresses only; else throws EgressDenied. */ |
| 125 | async 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 | |
| 135 | module.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 |