openvibers/openvibe.live Commits
The soundboard's audio download cannot be steered inward (roadmap WS-R task 5: SSRF; low; the same fix as OpenVibe.Chat's copy). The 101soundboards audio URL comes from that API's answer; its check allowed only 101soundboards.com but judged one DNS answer with its own list, which missed ::ffff:127.0.0.1 and the other IPv6 spellings of internal addresses and CGNAT, and the download resolved the name again. Every answer must now be public by openvibe-shared/egress's rule, and the download connects through its safeLookup. test/security-ssrf.test.js covers it (a mapped-IPv6, CGNAT and mixed answer refused, rebinding refused at connect time); the in-process security suites keep their DATA_DIR in a temp directory, so nothing lands in the checkout's data/.
Changes
@@@ -1,6 +1,7 @@const https = require('https');const http = require('http');const dns = require('dns');++const { isPublicAddress, safeLookup } = require('openvibe-shared/egress');const path = require('path');const fs = require('fs');const db = require('../db/database');@@@ -257,10 +258,13 @@ async function validateAudioUrl(audioUrl) {if (!ALLOWED.some((s) => host === s || host.endsWith('.' + s))) {throw new Error('Audio URL host is not permitted');}-- const { address } = await dns.promises.lookup(host).catch(() => {++ // Every answer must be a public address (the platform's rule, openvibe-shared/egress: it also knows++ // the IPv6 spellings of internal IPv4 addresses, which isPrivateIp does not); the download then++ // connects through safeLookup, so a second resolution cannot rebind to an internal one.++ const addrs = await dns.promises.lookup(host, { all: true }).catch(() => {throw new Error('Audio URL host could not be resolved');});-- if (isPrivateIp(address)) throw new Error('Audio URL resolved to a restricted IP address');++ if (!addrs.length || addrs.some((a) => isPrivateIp(a.address) || !isPublicAddress(a.address))) throw new Error('Audio URL resolved to a restricted IP address');}async function fetchJsonWithRetry(url, retries = 2) {@@@ -313,7 +317,7 @@ function fetchSoundTitleHtml(soundId) {function downloadAudio(audioUrl) {return new Promise((resolve) => {-- const req = https.get(audioUrl, { headers: { 'User-Agent': 'OpenVibe.Live/1.0' } }, (res) => {++ const req = https.get(audioUrl, { headers: { 'User-Agent': 'OpenVibe.Live/1.0' }, lookup: safeLookup }, (res) => {if (res.statusCode !== 200) {res.resume();return resolve(null);@@@ -30,6 +30,7 @@ const http = require('http');const tmp = path.join(os.tmpdir(), `ov-idor-${process.pid}.db`);process.env.DB_PATH = tmp;++process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-sec-data-')); // nothing lands in the checkout's data/process.env.NODE_ENV = 'test';const quiet = console.log;console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };@@@ -275,6 +276,7 @@ async function check(name, fn) {server.close();for (const ext of ['', '-wal', '-shm']) { try { fs.unlinkSync(tmp + ext); } catch { /* */ } }++ try { fs.rmSync(process.env.DATA_DIR, { recursive: true, force: true }); } catch { /* */ }if (failures) { quiet(`\n${failures} failure(s)`); process.exit(1); }quiet('\nsecurity-idor: all checks passed');process.exit(0);@@@ -23,6 +23,7 @@ const http = require('http');const tmp = path.join(os.tmpdir(), `ov-private-${process.pid}.db`);process.env.DB_PATH = tmp;++process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-sec-data-')); // nothing lands in the checkout's data/process.env.NODE_ENV = 'test';const quiet = console.log;console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };@@@ -164,6 +165,7 @@ async function outsider(user, id, label) {server.close();for (const ext of ['', '-wal', '-shm']) { try { fs.unlinkSync(tmp + ext); } catch { /* */ } }++ try { fs.rmSync(process.env.DATA_DIR, { recursive: true, force: true }); } catch { /* */ }if (failures) { quiet(`\n${failures} failure(s)`); process.exit(1); }quiet('\nsecurity-private: all checks passed');process.exit(0);@@@ -38,6 +38,7 @@ const { PassThrough } = require('stream');const tmp = path.join(os.tmpdir(), `ov-ssrf-${process.pid}.db`);process.env.DB_PATH = tmp;++process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-sec-data-')); // nothing lands in the checkout's data/process.env.NODE_ENV = 'test';process.env.OV_TOOLS_INTERNAL_URL = 'http://127.0.0.1:9'; // the kiosk asks Tools first; nobody is thereconst quiet = console.log;@@@ -53,11 +54,14 @@ const FAKE = {'loop6.example.test': ['::1'],'mapped.example.test': ['::ffff:127.0.0.1'],'mixed.example.test': [PUBLIC_IP, '127.0.0.1'],++ 'mapped.101soundboards.com': ['::ffff:127.0.0.1'],++ 'cgnat.101soundboards.com': ['100.64.0.1'],++ 'mixed.101soundboards.com': [PUBLIC_IP, '10.0.0.1'],};let rebindLookups = 0;function answersFor(hostname) {const h = String(hostname).toLowerCase().replace(/\.$/, '');-- if (h === 'rebind.example.test') { rebindLookups++; return rebindLookups === 1 ? [PUBLIC_IP] : ['127.0.0.1']; }++ if (h === 'rebind.example.test' || h === 'rebind.101soundboards.com') { rebindLookups++; return rebindLookups === 1 ? [PUBLIC_IP] : ['127.0.0.1']; }return FAKE[h] || null;}const realLookup = dns.lookup;@@@ -301,6 +305,31 @@ async function check(name, fn) {assert.strictEqual(hits, 0);});++ await check('soundboard: a 101soundboards audio URL that resolves inward (any spelling, one internal answer, or rebinding at download) is refused', async () => {++ db.setSetting('soundboard_101_api_key', 'sentinel-not-a-secret-soundboard');++ const sb = require('../server/chat/soundboard-service');++ let audioUrl = null;++ const realFetch = globalThis.fetch;++ globalThis.fetch = async (url, opts) => (String(url).startsWith('https://www.101soundboards.com/')++ ? new Response(JSON.stringify({ data: { sound_name: 'Horn', sound_file_url: audioUrl } }), { status: 200, headers: { 'content-type': 'application/json' } })++ : realFetch(url, opts));++ const connects = [];++ const realConnect = net.Socket.prototype.connect;++ net.Socket.prototype.connect = function (...args) { const o = Array.isArray(args[0]) ? args[0][0] : args[0]; if (o && typeof o === 'object' && o.host) connects.push(String(o.host)); return realConnect.apply(this, args); };++ try {++ let id = 5000;++ for (const host of ['mapped', 'cgnat', 'mixed']) {++ audioUrl = `https://${host}.101soundboards.com/a.mp3`;++ await assert.rejects(sb.getSoundboardAudio(String(++id)), /restricted|resolved/, host);++ }++ rebindLookups = 0;++ audioUrl = 'https://rebind.101soundboards.com/a.mp3';++ await sb.getSoundboardAudio(String(++id)).catch(() => null);++ assert.ok(rebindLookups >= 2, `the download resolved again (${rebindLookups})`);++ assert.ok(!connects.some((c) => /^(127\.|::1|::ffff:127|10\.|100\.64\.)/.test(c)), `connected to ${connects.join(', ')}`);++ } finally { globalThis.fetch = realFetch; net.Socket.prototype.connect = realConnect; }++ });++console.log('ssrf: who makes outbound requests');await check('every file that makes an outbound request itself is on the reviewed list (user-chosen URLs go through server/net/egress.js)', () => {// Why each may call fetch/http(s).request/WebSocket directly: its URLs are Live's configured@@@ -334,7 +363,7 @@ async function check(name, fn) {'server/ai/ai-provider.js': 'unused network helpers; kept for its URL helpers','server/arena/arena-service.js': 'the site\'s image provider (owner setting) and Live\'s own frame URLs','server/chat/routes.js': 'GIF providers (fixed hosts)',-- 'server/chat/soundboard-service.js': '101soundboards (host allowlist)',++ 'server/chat/soundboard-service.js': '101soundboards (host allowlist; the audio download through safeLookup)','server/chat/tts-engine.js': 'Google / AWS TTS (fixed hosts)','server/emotes/routes.js': 'FFZ / BTTV / 7TV (fixed hosts)','server/meta/routes.js': 'GitHub API (fixed host)',@@@ -375,6 +404,7 @@ async function check(name, fn) {server.close();internal.close();for (const ext of ['', '-wal', '-shm']) { try { fs.unlinkSync(tmp + ext); } catch { /* */ } }++ try { fs.rmSync(process.env.DATA_DIR, { recursive: true, force: true }); } catch { /* */ }if (failures) { quiet(`\n${failures} failure(s)`); process.exit(1); }quiet('\nsecurity-ssrf: all checks passed');process.exit(0);
@@ -1,6 +1,7 @@
@@ -1,6 +1,7 @@
const https = require('https');
const https = require('https');
const http = require('http');
const http = require('http');
const dns = require('dns');
const dns = require('dns');
+const { isPublicAddress, safeLookup } = require('openvibe-shared/egress');
const path = require('path');
const path = require('path');
const fs = require('fs');
const fs = require('fs');
const db = require('../db/database');
const db = require('../db/database');
@@ -257,10 +258,13 @@ async function validateAudioUrl(audioUrl) {
@@ -257,10 +258,13 @@ async function validateAudioUrl(audioUrl) {
if (!ALLOWED.some((s) => host === s || host.endsWith('.' + s))) {
if (!ALLOWED.some((s) => host === s || host.endsWith('.' + s))) {
throw new Error('Audio URL host is not permitted');
throw new Error('Audio URL host is not permitted');
}
}
- const { address } = await dns.promises.lookup(host).catch(() => {
+ // Every answer must be a public address (the platform's rule, openvibe-shared/egress: it also knows
+ // the IPv6 spellings of internal IPv4 addresses, which isPrivateIp does not); the download then
+ // connects through safeLookup, so a second resolution cannot rebind to an internal one.
+ const addrs = await dns.promises.lookup(host, { all: true }).catch(() => {
throw new Error('Audio URL host could not be resolved');
throw new Error('Audio URL host could not be resolved');
});
});
- if (isPrivateIp(address)) throw new Error('Audio URL resolved to a restricted IP address');
+ if (!addrs.length || addrs.some((a) => isPrivateIp(a.address) || !isPublicAddress(a.address))) throw new Error('Audio URL resolved to a restricted IP address');
}
}
async function fetchJsonWithRetry(url, retries = 2) {
async function fetchJsonWithRetry(url, retries = 2) {
@@ -313,7 +317,7 @@ function fetchSoundTitleHtml(soundId) {
@@ -313,7 +317,7 @@ function fetchSoundTitleHtml(soundId) {
function downloadAudio(audioUrl) {
function downloadAudio(audioUrl) {
return new Promise((resolve) => {
return new Promise((resolve) => {
- const req = https.get(audioUrl, { headers: { 'User-Agent': 'OpenVibe.Live/1.0' } }, (res) => {
+ const req = https.get(audioUrl, { headers: { 'User-Agent': 'OpenVibe.Live/1.0' }, lookup: safeLookup }, (res) => {
if (res.statusCode !== 200) {
if (res.statusCode !== 200) {
res.resume();
res.resume();
return resolve(null);
return resolve(null);
@@ -30,6 +30,7 @@ const http = require('http');
@@ -30,6 +30,7 @@ const http = require('http');
const tmp = path.join(os.tmpdir(), `ov-idor-${process.pid}.db`);
const tmp = path.join(os.tmpdir(), `ov-idor-${process.pid}.db`);
process.env.DB_PATH = tmp;
process.env.DB_PATH = tmp;
+process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-sec-data-')); // nothing lands in the checkout's data/
process.env.NODE_ENV = 'test';
process.env.NODE_ENV = 'test';
const quiet = console.log;
const quiet = console.log;
console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
@@ -275,6 +276,7 @@ async function check(name, fn) {
@@ -275,6 +276,7 @@ async function check(name, fn) {
server.close();
server.close();
for (const ext of ['', '-wal', '-shm']) { try { fs.unlinkSync(tmp + ext); } catch { /* */ } }
for (const ext of ['', '-wal', '-shm']) { try { fs.unlinkSync(tmp + ext); } catch { /* */ } }
+ try { fs.rmSync(process.env.DATA_DIR, { recursive: true, force: true }); } catch { /* */ }
if (failures) { quiet(`\n${failures} failure(s)`); process.exit(1); }
if (failures) { quiet(`\n${failures} failure(s)`); process.exit(1); }
quiet('\nsecurity-idor: all checks passed');
quiet('\nsecurity-idor: all checks passed');
process.exit(0);
process.exit(0);
@@ -23,6 +23,7 @@ const http = require('http');
@@ -23,6 +23,7 @@ const http = require('http');
const tmp = path.join(os.tmpdir(), `ov-private-${process.pid}.db`);
const tmp = path.join(os.tmpdir(), `ov-private-${process.pid}.db`);
process.env.DB_PATH = tmp;
process.env.DB_PATH = tmp;
+process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-sec-data-')); // nothing lands in the checkout's data/
process.env.NODE_ENV = 'test';
process.env.NODE_ENV = 'test';
const quiet = console.log;
const quiet = console.log;
console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
@@ -164,6 +165,7 @@ async function outsider(user, id, label) {
@@ -164,6 +165,7 @@ async function outsider(user, id, label) {
server.close();
server.close();
for (const ext of ['', '-wal', '-shm']) { try { fs.unlinkSync(tmp + ext); } catch { /* */ } }
for (const ext of ['', '-wal', '-shm']) { try { fs.unlinkSync(tmp + ext); } catch { /* */ } }
+ try { fs.rmSync(process.env.DATA_DIR, { recursive: true, force: true }); } catch { /* */ }
if (failures) { quiet(`\n${failures} failure(s)`); process.exit(1); }
if (failures) { quiet(`\n${failures} failure(s)`); process.exit(1); }
quiet('\nsecurity-private: all checks passed');
quiet('\nsecurity-private: all checks passed');
process.exit(0);
process.exit(0);
@@ -38,6 +38,7 @@ const { PassThrough } = require('stream');
@@ -38,6 +38,7 @@ const { PassThrough } = require('stream');
const tmp = path.join(os.tmpdir(), `ov-ssrf-${process.pid}.db`);
const tmp = path.join(os.tmpdir(), `ov-ssrf-${process.pid}.db`);
process.env.DB_PATH = tmp;
process.env.DB_PATH = tmp;
+process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-sec-data-')); // nothing lands in the checkout's data/
process.env.NODE_ENV = 'test';
process.env.NODE_ENV = 'test';
process.env.OV_TOOLS_INTERNAL_URL = 'http://127.0.0.1:9'; // the kiosk asks Tools first; nobody is there
process.env.OV_TOOLS_INTERNAL_URL = 'http://127.0.0.1:9'; // the kiosk asks Tools first; nobody is there
const quiet = console.log;
const quiet = console.log;
@@ -53,11 +54,14 @@ const FAKE = {
@@ -53,11 +54,14 @@ const FAKE = {
'loop6.example.test': ['::1'],
'loop6.example.test': ['::1'],
'mapped.example.test': ['::ffff:127.0.0.1'],
'mapped.example.test': ['::ffff:127.0.0.1'],
'mixed.example.test': [PUBLIC_IP, '127.0.0.1'],
'mixed.example.test': [PUBLIC_IP, '127.0.0.1'],
+ 'mapped.101soundboards.com': ['::ffff:127.0.0.1'],
+ 'cgnat.101soundboards.com': ['100.64.0.1'],
+ 'mixed.101soundboards.com': [PUBLIC_IP, '10.0.0.1'],
};
};
let rebindLookups = 0;
let rebindLookups = 0;
function answersFor(hostname) {
function answersFor(hostname) {
const h = String(hostname).toLowerCase().replace(/\.$/, '');
const h = String(hostname).toLowerCase().replace(/\.$/, '');
- if (h === 'rebind.example.test') { rebindLookups++; return rebindLookups === 1 ? [PUBLIC_IP] : ['127.0.0.1']; }
+ if (h === 'rebind.example.test' || h === 'rebind.101soundboards.com') { rebindLookups++; return rebindLookups === 1 ? [PUBLIC_IP] : ['127.0.0.1']; }
return FAKE[h] || null;
return FAKE[h] || null;
}
}
const realLookup = dns.lookup;
const realLookup = dns.lookup;
@@ -301,6 +305,31 @@ async function check(name, fn) {
@@ -301,6 +305,31 @@ async function check(name, fn) {
assert.strictEqual(hits, 0);
assert.strictEqual(hits, 0);
});
});
+ await check('soundboard: a 101soundboards audio URL that resolves inward (any spelling, one internal answer, or rebinding at download) is refused', async () => {
+ db.setSetting('soundboard_101_api_key', 'sentinel-not-a-secret-soundboard');
+ const sb = require('../server/chat/soundboard-service');
+ let audioUrl = null;
+ const realFetch = globalThis.fetch;
+ globalThis.fetch = async (url, opts) => (String(url).startsWith('https://www.101soundboards.com/')
+ ? new Response(JSON.stringify({ data: { sound_name: 'Horn', sound_file_url: audioUrl } }), { status: 200, headers: { 'content-type': 'application/json' } })
+ : realFetch(url, opts));
+ const connects = [];
+ const realConnect = net.Socket.prototype.connect;
+ net.Socket.prototype.connect = function (...args) { const o = Array.isArray(args[0]) ? args[0][0] : args[0]; if (o && typeof o === 'object' && o.host) connects.push(String(o.host)); return realConnect.apply(this, args); };
+ try {
+ let id = 5000;
+ for (const host of ['mapped', 'cgnat', 'mixed']) {
+ audioUrl = `https://${host}.101soundboards.com/a.mp3`;
+ await assert.rejects(sb.getSoundboardAudio(String(++id)), /restricted|resolved/, host);
+ }
+ rebindLookups = 0;
+ audioUrl = 'https://rebind.101soundboards.com/a.mp3';
+ await sb.getSoundboardAudio(String(++id)).catch(() => null);
+ assert.ok(rebindLookups >= 2, `the download resolved again (${rebindLookups})`);
+ assert.ok(!connects.some((c) => /^(127\.|::1|::ffff:127|10\.|100\.64\.)/.test(c)), `connected to ${connects.join(', ')}`);
+ } finally { globalThis.fetch = realFetch; net.Socket.prototype.connect = realConnect; }
+ });
+
console.log('ssrf: who makes outbound requests');
console.log('ssrf: who makes outbound requests');
await check('every file that makes an outbound request itself is on the reviewed list (user-chosen URLs go through server/net/egress.js)', () => {
await check('every file that makes an outbound request itself is on the reviewed list (user-chosen URLs go through server/net/egress.js)', () => {
// Why each may call fetch/http(s).request/WebSocket directly: its URLs are Live's configured
// Why each may call fetch/http(s).request/WebSocket directly: its URLs are Live's configured
@@ -334,7 +363,7 @@ async function check(name, fn) {
@@ -334,7 +363,7 @@ async function check(name, fn) {
'server/ai/ai-provider.js': 'unused network helpers; kept for its URL helpers',
'server/ai/ai-provider.js': 'unused network helpers; kept for its URL helpers',
'server/arena/arena-service.js': 'the site\'s image provider (owner setting) and Live\'s own frame URLs',
'server/arena/arena-service.js': 'the site\'s image provider (owner setting) and Live\'s own frame URLs',
'server/chat/routes.js': 'GIF providers (fixed hosts)',
'server/chat/routes.js': 'GIF providers (fixed hosts)',
- 'server/chat/soundboard-service.js': '101soundboards (host allowlist)',
+ 'server/chat/soundboard-service.js': '101soundboards (host allowlist; the audio download through safeLookup)',
'server/chat/tts-engine.js': 'Google / AWS TTS (fixed hosts)',
'server/chat/tts-engine.js': 'Google / AWS TTS (fixed hosts)',
'server/emotes/routes.js': 'FFZ / BTTV / 7TV (fixed hosts)',
'server/emotes/routes.js': 'FFZ / BTTV / 7TV (fixed hosts)',
'server/meta/routes.js': 'GitHub API (fixed host)',
'server/meta/routes.js': 'GitHub API (fixed host)',
@@ -375,6 +404,7 @@ async function check(name, fn) {
@@ -375,6 +404,7 @@ async function check(name, fn) {
server.close();
server.close();
internal.close();
internal.close();
for (const ext of ['', '-wal', '-shm']) { try { fs.unlinkSync(tmp + ext); } catch { /* */ } }
for (const ext of ['', '-wal', '-shm']) { try { fs.unlinkSync(tmp + ext); } catch { /* */ } }
+ try { fs.rmSync(process.env.DATA_DIR, { recursive: true, force: true }); } catch { /* */ }
if (failures) { quiet(`\n${failures} failure(s)`); process.exit(1); }
if (failures) { quiet(`\n${failures} failure(s)`); process.exit(1); }
quiet('\nsecurity-ssrf: all checks passed');
quiet('\nsecurity-ssrf: all checks passed');
process.exit(0);
process.exit(0);