openvibers/openvibe.vision Commits
Vision: refuse IPv6 spellings of private addresses (shared egress rule) (#92)
Changes
@@@ -2,8 +2,10 @@/*** Image inputs. An API caller may upload bytes (multipart), send a data: URI, or name a public https URL.-- * The URL path is fetched only after every resolved address is checked: loopback, link-local, private and-- * unique-local addresses are refused (SSRF), redirects are re-checked, and the bytes must really be an image.++ * The URL path is fetched only after every resolved address is checked against openvibe-shared/egress's rule++ * (the one Live, Events, Chat and Tools use): loopback, link-local, private, unique-local, documentation and++ * reserved ranges are refused, including the IPv6 spellings that wrap a private IPv4 address (v4-mapped,++ * v4-compatible, NAT64, 6to4, Teredo). Redirects are re-checked, and the bytes must really be an image.** Nothing here stores an image. The answer writes only sha256, mime and byte count; OCR gets a temporary file* that is removed in a finally block.@@@ -15,6 +17,7 @@ const net = require('net');const os = require('os');const path = require('path');const sharp = require('sharp');++const egress = require('openvibe-shared/egress');const DATA_URL_RE = /^data:(image\/(?:png|jpe?g|webp|gif|bmp|tiff|avif));base64,([A-Za-z0-9+/=\s]+)$/i;const MIME_BY_MAGIC = [@@@ -27,26 +30,13 @@ const MIME_BY_MAGIC = [{ mime: 'image/avif', test: (b) => b.length > 12 && b.toString('ascii', 4, 12) === 'ftypavif' },];++/**++ * The platform's address rule, openvibe-shared/egress, so this copy cannot drift again: any address that is++ * not globally routable unicast is private, including ::127.0.0.1, 64:ff9b::7f00:1 (NAT64), 2002:7f00:1::++ * (6to4) and Teredo, and anything that is not an address at all.++ */function isPrivateAddress(address) {-- if (!net.isIP(address)) return true;-- if (net.isIPv4(address)) {-- const p = address.split('.').map(Number);-- if (p[0] === 10 || p[0] === 127 || p[0] === 0) return true;-- if (p[0] === 169 && p[1] === 254) return true;-- if (p[0] === 172 && p[1] >= 16 && p[1] <= 31) return true;-- if (p[0] === 192 && p[1] === 168) return true;-- if (p[0] === 100 && p[1] >= 64 && p[1] <= 127) return true;-- if (p[0] === 198 && (p[1] === 18 || p[1] === 19)) return true;-- if (p[0] >= 224) return true;-- return false;-- }-- const a = address.toLowerCase();-- if (a === '::' || a === '::1') return true;-- if (a.startsWith('fe8') || a.startsWith('fe9') || a.startsWith('fea') || a.startsWith('feb')) return true;-- if (a.startsWith('fc') || a.startsWith('fd')) return true;-- if (a.startsWith('ff')) return true;-- if (a.startsWith('::ffff:')) return isPrivateAddress(a.slice(7));-- return false;++ return !egress.isPublicAddress(egress.normalizeHost(address));}async function assertPublicUrl(raw, { allowPrivate = false } = {}) {@@@ -55,7 +45,7 @@ async function assertPublicUrl(raw, { allowPrivate = false } = {}) {if (!/^https?:$/.test(u.protocol)) { const e = new Error('an image URL must be http or https'); e.code = 'image.invalid_url'; throw e; }if (u.username || u.password) { const e = new Error('an image URL may not carry credentials'); e.code = 'image.invalid_url'; throw e; }if (!allowPrivate) {-- const host = u.hostname.replace(/^\[|\]$/g, '');++ const host = egress.normalizeHost(u.hostname);if (net.isIP(host)) {if (isPrivateAddress(host)) { const e = new Error('that address is private; Vision fetches public images only'); e.code = 'image.private_url'; throw e; }} else {@@@ -7,10 +7,16 @@ const { boot, check, done } = require('./helpers/boot');const { assertPublicUrl, isPrivateAddress, loadImage } = require('../server/vision/images');(async () => {-- await check('private, loopback, link-local and unique-local addresses are refused', async () => {-- for (const a of ['127.0.0.1', '10.1.2.3', '172.16.0.1', '192.168.1.1', '169.254.169.254', '::1', 'fd00::1', 'fe80::1', '0.0.0.0']) assert.strictEqual(isPrivateAddress(a), true, a);-- for (const a of ['8.8.8.8', '1.1.1.1', '2606:4700:4700::1111']) assert.strictEqual(isPrivateAddress(a), false, a);-- await assert.rejects(assertPublicUrl('http://127.0.0.1/x', { allowPrivate: false }), (e) => e.code === 'image.private_url');++ await check('private, loopback, link-local and unique-local addresses are refused in every spelling', async () => {++ for (const a of ['127.0.0.1', '10.1.2.3', '172.16.0.1', '192.168.1.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', '255.255.255.255', '::1', '::', 'fd00::1', 'fe80::1', '::ffff:127.0.0.1', '::127.0.0.1',++ '64:ff9b::7f00:1', '64:ff9b:1::a9fe:a9fe', '2002:7f00:1::', '2001:0:4136:e378:8000:63bf:80ff:fffe']) assert.strictEqual(isPrivateAddress(a), true, a);++ for (const a of ['8.8.8.8', '1.1.1.1', '2606:4700:4700::1111', '::ffff:8.8.8.8', '64:ff9b::808:808']) assert.strictEqual(isPrivateAddress(a), false, a);++ // Node does not call a custom lookup for an IP-literal host, so the URL check itself has to refuse it.++ for (const u of ['http://127.0.0.1/x', 'http://[::1]/x', 'http://[::ffff:127.0.0.1]/x', 'http://[64:ff9b::7f00:1]/x',++ 'http://[2002:7f00:1::]/x', 'http://[2001:0:4136:e378:8000:63bf:80ff:fffe]/x', 'http://2130706433/x']) {++ await assert.rejects(assertPublicUrl(u, { allowPrivate: false }), (e) => e.code === 'image.private_url', u);++ }await assert.rejects(assertPublicUrl('file:///etc/passwd'), (e) => e.code === 'image.invalid_url');await assert.rejects(assertPublicUrl('https://user:[email protected]/x'), (e) => e.code === 'image.invalid_url');});
@@ -2,8 +2,10 @@
@@ -2,8 +2,10 @@
/**
/**
* Image inputs. An API caller may upload bytes (multipart), send a data: URI, or name a public https URL.
* Image inputs. An API caller may upload bytes (multipart), send a data: URI, or name a public https URL.
- * The URL path is fetched only after every resolved address is checked: loopback, link-local, private and
- * unique-local addresses are refused (SSRF), redirects are re-checked, and the bytes must really be an image.
+ * The URL path is fetched only after every resolved address is checked against openvibe-shared/egress's rule
+ * (the one Live, Events, Chat and Tools use): loopback, link-local, private, unique-local, documentation and
+ * reserved ranges are refused, including the IPv6 spellings that wrap a private IPv4 address (v4-mapped,
+ * v4-compatible, NAT64, 6to4, Teredo). Redirects are re-checked, and the bytes must really be an image.
*
*
* Nothing here stores an image. The answer writes only sha256, mime and byte count; OCR gets a temporary file
* Nothing here stores an image. The answer writes only sha256, mime and byte count; OCR gets a temporary file
* that is removed in a finally block.
* that is removed in a finally block.
@@ -15,6 +17,7 @@ const net = require('net');
@@ -15,6 +17,7 @@ const net = require('net');
const os = require('os');
const os = require('os');
const path = require('path');
const path = require('path');
const sharp = require('sharp');
const sharp = require('sharp');
+const egress = require('openvibe-shared/egress');
const DATA_URL_RE = /^data:(image\/(?:png|jpe?g|webp|gif|bmp|tiff|avif));base64,([A-Za-z0-9+/=\s]+)$/i;
const DATA_URL_RE = /^data:(image\/(?:png|jpe?g|webp|gif|bmp|tiff|avif));base64,([A-Za-z0-9+/=\s]+)$/i;
const MIME_BY_MAGIC = [
const MIME_BY_MAGIC = [
@@ -27,26 +30,13 @@ const MIME_BY_MAGIC = [
@@ -27,26 +30,13 @@ const MIME_BY_MAGIC = [
{ mime: 'image/avif', test: (b) => b.length > 12 && b.toString('ascii', 4, 12) === 'ftypavif' },
{ mime: 'image/avif', test: (b) => b.length > 12 && b.toString('ascii', 4, 12) === 'ftypavif' },
];
];
+/**
+ * The platform's address rule, openvibe-shared/egress, so this copy cannot drift again: any address that is
+ * not globally routable unicast is private, including ::127.0.0.1, 64:ff9b::7f00:1 (NAT64), 2002:7f00:1::
+ * (6to4) and Teredo, and anything that is not an address at all.
+ */
function isPrivateAddress(address) {
function isPrivateAddress(address) {
- if (!net.isIP(address)) return true;
- if (net.isIPv4(address)) {
- const p = address.split('.').map(Number);
- if (p[0] === 10 || p[0] === 127 || p[0] === 0) return true;
- if (p[0] === 169 && p[1] === 254) return true;
- if (p[0] === 172 && p[1] >= 16 && p[1] <= 31) return true;
- if (p[0] === 192 && p[1] === 168) return true;
- if (p[0] === 100 && p[1] >= 64 && p[1] <= 127) return true;
- if (p[0] === 198 && (p[1] === 18 || p[1] === 19)) return true;
- if (p[0] >= 224) return true;
- return false;
- }
- const a = address.toLowerCase();
- if (a === '::' || a === '::1') return true;
- if (a.startsWith('fe8') || a.startsWith('fe9') || a.startsWith('fea') || a.startsWith('feb')) return true;
- if (a.startsWith('fc') || a.startsWith('fd')) return true;
- if (a.startsWith('ff')) return true;
- if (a.startsWith('::ffff:')) return isPrivateAddress(a.slice(7));
- return false;
+ return !egress.isPublicAddress(egress.normalizeHost(address));
}
}
async function assertPublicUrl(raw, { allowPrivate = false } = {}) {
async function assertPublicUrl(raw, { allowPrivate = false } = {}) {
@@ -55,7 +45,7 @@ async function assertPublicUrl(raw, { allowPrivate = false } = {}) {
@@ -55,7 +45,7 @@ async function assertPublicUrl(raw, { allowPrivate = false } = {}) {
if (!/^https?:$/.test(u.protocol)) { const e = new Error('an image URL must be http or https'); e.code = 'image.invalid_url'; throw e; }
if (!/^https?:$/.test(u.protocol)) { const e = new Error('an image URL must be http or https'); e.code = 'image.invalid_url'; throw e; }
if (u.username || u.password) { const e = new Error('an image URL may not carry credentials'); e.code = 'image.invalid_url'; throw e; }
if (u.username || u.password) { const e = new Error('an image URL may not carry credentials'); e.code = 'image.invalid_url'; throw e; }
if (!allowPrivate) {
if (!allowPrivate) {
- const host = u.hostname.replace(/^\[|\]$/g, '');
+ const host = egress.normalizeHost(u.hostname);
if (net.isIP(host)) {
if (net.isIP(host)) {
if (isPrivateAddress(host)) { const e = new Error('that address is private; Vision fetches public images only'); e.code = 'image.private_url'; throw e; }
if (isPrivateAddress(host)) { const e = new Error('that address is private; Vision fetches public images only'); e.code = 'image.private_url'; throw e; }
} else {
} else {
@@ -7,10 +7,16 @@ const { boot, check, done } = require('./helpers/boot');
@@ -7,10 +7,16 @@ const { boot, check, done } = require('./helpers/boot');
const { assertPublicUrl, isPrivateAddress, loadImage } = require('../server/vision/images');
const { assertPublicUrl, isPrivateAddress, loadImage } = require('../server/vision/images');
(async () => {
(async () => {
- await check('private, loopback, link-local and unique-local addresses are refused', async () => {
- for (const a of ['127.0.0.1', '10.1.2.3', '172.16.0.1', '192.168.1.1', '169.254.169.254', '::1', 'fd00::1', 'fe80::1', '0.0.0.0']) assert.strictEqual(isPrivateAddress(a), true, a);
- for (const a of ['8.8.8.8', '1.1.1.1', '2606:4700:4700::1111']) assert.strictEqual(isPrivateAddress(a), false, a);
- await assert.rejects(assertPublicUrl('http://127.0.0.1/x', { allowPrivate: false }), (e) => e.code === 'image.private_url');
+ await check('private, loopback, link-local and unique-local addresses are refused in every spelling', async () => {
+ for (const a of ['127.0.0.1', '10.1.2.3', '172.16.0.1', '192.168.1.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', '255.255.255.255', '::1', '::', 'fd00::1', 'fe80::1', '::ffff:127.0.0.1', '::127.0.0.1',
+ '64:ff9b::7f00:1', '64:ff9b:1::a9fe:a9fe', '2002:7f00:1::', '2001:0:4136:e378:8000:63bf:80ff:fffe']) assert.strictEqual(isPrivateAddress(a), true, a);
+ for (const a of ['8.8.8.8', '1.1.1.1', '2606:4700:4700::1111', '::ffff:8.8.8.8', '64:ff9b::808:808']) assert.strictEqual(isPrivateAddress(a), false, a);
+ // Node does not call a custom lookup for an IP-literal host, so the URL check itself has to refuse it.
+ for (const u of ['http://127.0.0.1/x', 'http://[::1]/x', 'http://[::ffff:127.0.0.1]/x', 'http://[64:ff9b::7f00:1]/x',
+ 'http://[2002:7f00:1::]/x', 'http://[2001:0:4136:e378:8000:63bf:80ff:fffe]/x', 'http://2130706433/x']) {
+ await assert.rejects(assertPublicUrl(u, { allowPrivate: false }), (e) => e.code === 'image.private_url', u);
+ }
await assert.rejects(assertPublicUrl('file:///etc/passwd'), (e) => e.code === 'image.invalid_url');
await assert.rejects(assertPublicUrl('file:///etc/passwd'), (e) => e.code === 'image.invalid_url');
await assert.rejects(assertPublicUrl('https://user:[email protected]/x'), (e) => e.code === 'image.invalid_url');
await assert.rejects(assertPublicUrl('https://user:[email protected]/x'), (e) => e.code === 'image.invalid_url');
});
});