openvibers/openvibe.ai Commits
AI: use the shared egress address rule for DNS answers (#323)
Changes
@@@ -7,8 +7,9 @@* - https only, to a hostname on the allow-list (exact, or '*.suffix'); the one exception is the* operator-configured Media internal base (OV_MEDIA_INTERNAL_URL), matched by exact origin* - the address actually connected to must be public unicast: the DNS answer is checked inside-- * the connection's own lookup (no second resolution, so no DNS rebinding), and IPv4-mapped-- * IPv6 spellings of internal addresses are caught too++ * the connection's own lookup (no second resolution, so no DNS rebinding), using the platform's++ * rule (openvibe-shared/egress), which also catches the IPv6 spellings of an internal address++ * (v4-mapped and v4-compatible, NAT64, 6to4, Teredo)* - redirects are followed by hand (max 3) and every hop is judged by the same rules; the internal* Media origin is reachable by redirect only from the internal origin itself, never from a* public hop (an allow-listed host that redirects must not reach the loopback Media service)@@@ -26,41 +27,29 @@ const https = require('https');const fs = require('fs');const os = require('os');const path = require('path');++const egress = require('openvibe-shared/egress');const { AiError } = require('./util');/** A Media object id (contracts media/media-ref.v1: med_ + a ULID). */const MEDIA_OBJECT_ID = /^med_[0-9A-HJKMNP-TV-Z]{26}$/;--const blocked = new net.BlockList();--for (const [a, p] of [['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], ['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], ['198.51.100.0', 24], ['203.0.113.0', 24], ['224.0.0.0', 4], ['240.0.0.0', 4]]) blocked.addSubnet(a, p, 'ipv4');--for (const [a, p] of [['::', 128], ['::1', 128], ['fe80::', 10], ['fec0::', 10], ['fc00::', 7], ['ff00::', 8], ['100::', 64], ['2001:db8::', 32], ['2001::', 23]]) blocked.addSubnet(a, p, 'ipv6');----function isPublicAddress(ip) {-- if (net.isIPv4(ip)) return !blocked.check(ip, 'ipv4');-- if (net.isIPv6(ip)) {-- const m = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(ip);-- if (m) return !blocked.check(m[1], 'ipv4');-- const hex = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(ip);-- if (hex) {-- const n = (parseInt(hex[1], 16) << 16) >>> 0 | parseInt(hex[2], 16);-- const v4 = [(n >>> 24) & 255, (n >>> 16) & 255, (n >>> 8) & 255, n & 255].join('.');-- return !blocked.check(v4, 'ipv4');-- }-- return !blocked.check(ip, 'ipv6');-- }-- return false;--}++/**++ * The platform's address rule, openvibe-shared/egress, so this copy cannot drift again: it refuses the IPv6++ * spellings of a private IPv4 (v4-mapped and v4-compatible, NAT64, 6to4, Teredo) as well as loopback, RFC 1918,++ * link-local, CGNAT, documentation, benchmarking, multicast and reserved ranges.++ */++const { isPublicAddress } = egress;/** dns.lookup replacement used at connect time: fails when any answer is not public unicast. */function safeLookup(hostname, options, cb) {if (typeof options === 'function') { cb = options; options = {}; }-- dns.lookup(hostname, { ...options, all: true }, (err, addrs) => {++ const host = egress.normalizeHost(hostname);++ dns.lookup(host, { ...options, all: true }, (err, addrs) => {if (err) return cb(err);const list = Array.isArray(addrs) ? addrs : [{ address: addrs, family: options.family || 4 }];const bad = list.find(a => !isPublicAddress(a.address));if (bad || !list.length) {-- const e = new Error(`refused: ${hostname} resolves to a non-public address`);++ const e = new Error(`refused: ${host} resolves to a non-public address`);e.code = 'EADDRNOTPUBLIC';return cb(e);}@@@ -7,7 +7,7 @@const assert = require('assert');const nodeHttp = require('http');const { boot, request, token, suite, ALL } = require('./helpers');--const { createFetcher, isPublicAddress } = require('../server/fetcher');++const { createFetcher, isPublicAddress, safeLookup } = require('../server/fetcher');const { load } = require('../server/config');const t = suite('ssrf');@@@ -22,10 +22,29 @@ const refused = async (fetcher, url) => {};t.test('address policy: internal, loopback, link-local, mapped IPv6 are not public', () => {-- for (const ip of ['127.0.0.1', '10.1.2.3', '192.168.1.1', '172.16.0.1', '169.254.169.254', '0.0.0.0', '::1', '::ffff:127.0.0.1', '::ffff:7f00:1', '::ffff:a9fe:a9fe', 'fe80::1', 'fc00::1', '100.64.0.1']) {++ for (const ip of ['127.0.0.1', '10.1.2.3', '192.168.1.1', '172.16.0.1', '169.254.169.254', '0.0.0.0', '192.0.2.1', '198.51.100.7', '203.0.113.9', '192.88.99.1',++ '::1', '::', '::ffff:127.0.0.1', '::ffff:7f00:1', '::ffff:a9fe:a9fe', '::127.0.0.1', '64:ff9b::7f00:1', '64:ff9b:1::a9fe:a9fe', '2002:7f00:1::',++ '2001:0:4136:e378:8000:63bf:80ff:fffe', 'fe80::1', 'fc00::1', '100.64.0.1']) {assert.strictEqual(isPublicAddress(ip), false, ip);}-- for (const ip of ['1.1.1.1', '93.184.216.34', '2606:4700:4700::1111']) assert.strictEqual(isPublicAddress(ip), true, ip);++ for (const ip of ['1.1.1.1', '93.184.216.34', '2606:4700:4700::1111', '::ffff:8.8.8.8', '64:ff9b::808:808']) assert.strictEqual(isPublicAddress(ip), true, ip);++ // One address rule for the platform: this file uses openvibe-shared/egress's own function, not a copy.++ assert.strictEqual(isPublicAddress, require('openvibe-shared/egress').isPublicAddress, 'the platform address rule, not a local copy');++});++++t.test('a DNS answer in an IPv6 spelling of a private IPv4 is refused at connect time', async () => {++ const dns = require('dns');++ const real = dns.lookup;++ dns.lookup = (host, opts, cb) => {++ if (String(host).toLowerCase() === 'nat64.test') return process.nextTick(() => cb(null, [{ address: '64:ff9b::7f00:1', family: 6 }]));++ return real(host, opts, cb);++ };++ try {++ const err = await new Promise((resolve) => safeLookup('nat64.test', {}, resolve));++ assert.ok(err && err.code === 'EADDRNOTPUBLIC', 'the NAT64 spelling of 127.0.0.1 is refused');++ } finally {++ dns.lookup = real;++ }});t.test('object storage is followed only as a redirect from Media', async () => {
@@ -7,8 +7,9 @@
@@ -7,8 +7,9 @@
* - https only, to a hostname on the allow-list (exact, or '*.suffix'); the one exception is the
* - https only, to a hostname on the allow-list (exact, or '*.suffix'); the one exception is the
* operator-configured Media internal base (OV_MEDIA_INTERNAL_URL), matched by exact origin
* operator-configured Media internal base (OV_MEDIA_INTERNAL_URL), matched by exact origin
* - the address actually connected to must be public unicast: the DNS answer is checked inside
* - the address actually connected to must be public unicast: the DNS answer is checked inside
- * the connection's own lookup (no second resolution, so no DNS rebinding), and IPv4-mapped
- * IPv6 spellings of internal addresses are caught too
+ * the connection's own lookup (no second resolution, so no DNS rebinding), using the platform's
+ * rule (openvibe-shared/egress), which also catches the IPv6 spellings of an internal address
+ * (v4-mapped and v4-compatible, NAT64, 6to4, Teredo)
* - redirects are followed by hand (max 3) and every hop is judged by the same rules; the internal
* - redirects are followed by hand (max 3) and every hop is judged by the same rules; the internal
* Media origin is reachable by redirect only from the internal origin itself, never from a
* Media origin is reachable by redirect only from the internal origin itself, never from a
* public hop (an allow-listed host that redirects must not reach the loopback Media service)
* public hop (an allow-listed host that redirects must not reach the loopback Media service)
@@ -26,41 +27,29 @@ const https = require('https');
@@ -26,41 +27,29 @@ const https = require('https');
const fs = require('fs');
const fs = require('fs');
const os = require('os');
const os = require('os');
const path = require('path');
const path = require('path');
+const egress = require('openvibe-shared/egress');
const { AiError } = require('./util');
const { AiError } = require('./util');
/** A Media object id (contracts media/media-ref.v1: med_ + a ULID). */
/** A Media object id (contracts media/media-ref.v1: med_ + a ULID). */
const MEDIA_OBJECT_ID = /^med_[0-9A-HJKMNP-TV-Z]{26}$/;
const MEDIA_OBJECT_ID = /^med_[0-9A-HJKMNP-TV-Z]{26}$/;
-const blocked = new net.BlockList();
-for (const [a, p] of [['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], ['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], ['198.51.100.0', 24], ['203.0.113.0', 24], ['224.0.0.0', 4], ['240.0.0.0', 4]]) blocked.addSubnet(a, p, 'ipv4');
-for (const [a, p] of [['::', 128], ['::1', 128], ['fe80::', 10], ['fec0::', 10], ['fc00::', 7], ['ff00::', 8], ['100::', 64], ['2001:db8::', 32], ['2001::', 23]]) blocked.addSubnet(a, p, 'ipv6');
-
-function isPublicAddress(ip) {
- if (net.isIPv4(ip)) return !blocked.check(ip, 'ipv4');
- if (net.isIPv6(ip)) {
- const m = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(ip);
- if (m) return !blocked.check(m[1], 'ipv4');
- const hex = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(ip);
- if (hex) {
- const n = (parseInt(hex[1], 16) << 16) >>> 0 | parseInt(hex[2], 16);
- const v4 = [(n >>> 24) & 255, (n >>> 16) & 255, (n >>> 8) & 255, n & 255].join('.');
- return !blocked.check(v4, 'ipv4');
- }
- return !blocked.check(ip, 'ipv6');
- }
- return false;
-}
+/**
+ * The platform's address rule, openvibe-shared/egress, so this copy cannot drift again: it refuses the IPv6
+ * spellings of a private IPv4 (v4-mapped and v4-compatible, NAT64, 6to4, Teredo) as well as loopback, RFC 1918,
+ * link-local, CGNAT, documentation, benchmarking, multicast and reserved ranges.
+ */
+const { isPublicAddress } = egress;
/** dns.lookup replacement used at connect time: fails when any answer is not public unicast. */
/** dns.lookup replacement used at connect time: fails when any answer is not public unicast. */
function safeLookup(hostname, options, cb) {
function safeLookup(hostname, options, cb) {
if (typeof options === 'function') { cb = options; options = {}; }
if (typeof options === 'function') { cb = options; options = {}; }
- dns.lookup(hostname, { ...options, all: true }, (err, addrs) => {
+ const host = egress.normalizeHost(hostname);
+ dns.lookup(host, { ...options, all: true }, (err, addrs) => {
if (err) return cb(err);
if (err) return cb(err);
const list = Array.isArray(addrs) ? addrs : [{ address: addrs, family: options.family || 4 }];
const list = Array.isArray(addrs) ? addrs : [{ address: addrs, family: options.family || 4 }];
const bad = list.find(a => !isPublicAddress(a.address));
const bad = list.find(a => !isPublicAddress(a.address));
if (bad || !list.length) {
if (bad || !list.length) {
- const e = new Error(`refused: ${hostname} resolves to a non-public address`);
+ const e = new Error(`refused: ${host} resolves to a non-public address`);
e.code = 'EADDRNOTPUBLIC';
e.code = 'EADDRNOTPUBLIC';
return cb(e);
return cb(e);
}
}
@@ -7,7 +7,7 @@
@@ -7,7 +7,7 @@
const assert = require('assert');
const assert = require('assert');
const nodeHttp = require('http');
const nodeHttp = require('http');
const { boot, request, token, suite, ALL } = require('./helpers');
const { boot, request, token, suite, ALL } = require('./helpers');
-const { createFetcher, isPublicAddress } = require('../server/fetcher');
+const { createFetcher, isPublicAddress, safeLookup } = require('../server/fetcher');
const { load } = require('../server/config');
const { load } = require('../server/config');
const t = suite('ssrf');
const t = suite('ssrf');
@@ -22,10 +22,29 @@ const refused = async (fetcher, url) => {
@@ -22,10 +22,29 @@ const refused = async (fetcher, url) => {
};
};
t.test('address policy: internal, loopback, link-local, mapped IPv6 are not public', () => {
t.test('address policy: internal, loopback, link-local, mapped IPv6 are not public', () => {
- for (const ip of ['127.0.0.1', '10.1.2.3', '192.168.1.1', '172.16.0.1', '169.254.169.254', '0.0.0.0', '::1', '::ffff:127.0.0.1', '::ffff:7f00:1', '::ffff:a9fe:a9fe', 'fe80::1', 'fc00::1', '100.64.0.1']) {
+ for (const ip of ['127.0.0.1', '10.1.2.3', '192.168.1.1', '172.16.0.1', '169.254.169.254', '0.0.0.0', '192.0.2.1', '198.51.100.7', '203.0.113.9', '192.88.99.1',
+ '::1', '::', '::ffff:127.0.0.1', '::ffff:7f00:1', '::ffff:a9fe:a9fe', '::127.0.0.1', '64:ff9b::7f00:1', '64:ff9b:1::a9fe:a9fe', '2002:7f00:1::',
+ '2001:0:4136:e378:8000:63bf:80ff:fffe', 'fe80::1', 'fc00::1', '100.64.0.1']) {
assert.strictEqual(isPublicAddress(ip), false, ip);
assert.strictEqual(isPublicAddress(ip), false, ip);
}
}
- for (const ip of ['1.1.1.1', '93.184.216.34', '2606:4700:4700::1111']) assert.strictEqual(isPublicAddress(ip), true, ip);
+ for (const ip of ['1.1.1.1', '93.184.216.34', '2606:4700:4700::1111', '::ffff:8.8.8.8', '64:ff9b::808:808']) assert.strictEqual(isPublicAddress(ip), true, ip);
+ // One address rule for the platform: this file uses openvibe-shared/egress's own function, not a copy.
+ assert.strictEqual(isPublicAddress, require('openvibe-shared/egress').isPublicAddress, 'the platform address rule, not a local copy');
+});
+
+t.test('a DNS answer in an IPv6 spelling of a private IPv4 is refused at connect time', async () => {
+ const dns = require('dns');
+ const real = dns.lookup;
+ dns.lookup = (host, opts, cb) => {
+ if (String(host).toLowerCase() === 'nat64.test') return process.nextTick(() => cb(null, [{ address: '64:ff9b::7f00:1', family: 6 }]));
+ return real(host, opts, cb);
+ };
+ try {
+ const err = await new Promise((resolve) => safeLookup('nat64.test', {}, resolve));
+ assert.ok(err && err.code === 'EADDRNOTPUBLIC', 'the NAT64 spelling of 127.0.0.1 is refused');
+ } finally {
+ dns.lookup = real;
+ }
});
});
t.test('object storage is followed only as a redirect from Media', async () => {
t.test('object storage is followed only as a redirect from Media', async () => {