openvibers/openvibe.tools Commits

The paste proxy reaches only Community's paste API, and yt-dlp only YouTube links (roadmap WS-R task 5: SSRF; low). The gateway forwarded /api/pastes/* to `${communityUrl}/api/pastes${req.url}` with the visitor's token: "/api/pastes/../../internal/x" (or its %2e%2e form) resolved to Community's /internal/x, any other Community route likewise, and encoded slashes or backslashes became path separators on the other side (the hole Community's own Live proxy had). A path that does not resolve inside /api/pastes, or carries %2f/%5c, is now a 404 and never sent. yt's link check accepted any scheme and port on a YouTube host (file:, ftp:, gopher:, :6379); it now takes http(s) on the default ports without credentials.

OP OpenVibers committed 26 Sept 2026 · 3680e838eb · 4 files, +166 −0

apps/gateway/test/security-ssrf.test.js: the egress guard's address rule is openvibe-shared/egress's own (not a copy that can drift); the proxy's dot-segment, %2e%2e, encoded-slash and backslash paths never reach a Community route outside the paste API (a recording stand-in Community), with a control; and a ratchet over every file under apps/ that makes outbound requests, each listed with where it goes. apps/yt/test/security-ssrf.test.js: YouTube links pass; schemes, ports, credentials, look-alikes and internal addresses do not.

Changes

ddiff --git a/apps/gateway/server/index.js b/apps/gateway/server/index.js
iindex 95c6153..63524b9 100644
---- a/apps/gateway/server/index.js
++++ b/apps/gateway/server/index.js
@@@ -316,6 +316,14 @@ app.get('/api/brand', (_req, res) => res.json(BRAND));
app.use('/api/pastes', guard.legacyLimiter(rateLimit, { windowMs: 60_000, anonymous: 120, signedIn: 240, message: 'Too many requests. Please try again later.' }), async (req, res) => {
try {
const target = `${config.communityUrl}/api/pastes${req.url}`;
++ // The path is the visitor's: "/api/pastes/../../internal/x" (or %2e%2e) resolves to another of
++ // Community's routes, carrying the visitor's token; an encoded slash or backslash becomes one
++ // once Community decodes it. Only Community's paste API is proxied.
++ let resolved = null;
++ try { resolved = new URL(target).pathname; } catch { /* not a URL: refused below */ }
++ if (!resolved || (resolved !== '/api/pastes' && !resolved.startsWith('/api/pastes/')) || /%(2f|5c)/i.test(resolved) || resolved.includes('\\')) {
++ return res.status(404).json({ error: 'Not found' });
++ }
const fetchOpts = { method: req.method, headers: {} };
// Forward the visitor's JWT — Community verifies it and maps it to their subject.
const userToken = extractToken(req);
ddiff --git a/apps/gateway/test/security-ssrf.test.js b/apps/gateway/test/security-ssrf.test.js
nnew file mode 100644
iindex 0000000..fdfd29e
---- /dev/null
++++ b/apps/gateway/test/security-ssrf.test.js
@@@ -0,0 +1,129 @@
++'use strict';
++/**
++ * The gateway's outbound requests go where they are meant to (roadmap WS-R task 5, the SSRF class).
++ * net-ssrf.test.js and net-family.test.js drive every tool that connects to a host a visitor chose
++ * through the egress guard; this suite adds what they do not:
++ *
++ * - the guard's address rule IS openvibe-shared/egress's (the rule Live, Events, Chat and Sources
++ * use), not a copy that can drift (Media's and Sources' copies had);
++ * - the paste proxy (/api/pastes/* → OpenVibe.Community) forwards only to Community's paste API:
++ * "/api/pastes/../../internal/x", its %2e%2e form and encoded slashes resolved to other Community
++ * routes, with the visitor's token (the same hole Community's own Live proxy had);
++ * - a ratchet: every file under apps/ that makes an outbound request itself is on a reviewed list
++ * with where it goes; a new one fails until it uses the egress guard or is reviewed here.
++ */
++const assert = require('assert');
++const fs = require('fs');
++const http = require('http');
++const net = require('net');
++const os = require('os');
++const path = require('path');
++const { startApp } = require('../../_shared/test/spawn');
++
++const APPS = path.join(__dirname, '..', '..');
++let failures = 0;
++const check = async (name, fn) => { try { await fn(); console.log(` ✓ ${name}`); } catch (e) { failures++; console.log(` ✗ ${name}\n ${String(e.stack || e.message).split('\n').slice(0, 8).join('\n ')}`); } };
++
++/** A raw request line: the path goes out exactly as written (no client-side dot-segment removal). */
++function rawGet(port, p, headers = {}) {
++ return new Promise((resolve) => {
++ const s = net.connect(port, '127.0.0.1', () => s.write(`GET ${p} HTTP/1.1\r\nHost: tools.test\r\nConnection: close\r\n${Object.entries(headers).map(([k, v]) => `${k}: ${v}\r\n`).join('')}\r\n`));
++ let data = '';
++ s.on('data', (c) => { data += c; });
++ s.on('end', () => resolve(data));
++ s.on('error', () => resolve(data));
++ setTimeout(() => { s.destroy(); resolve(data); }, 5000);
++ });
++}
++
++(async () => {
++ await check('the egress guard\'s address rule is openvibe-shared/egress\'s own', () => {
++ const guard = require('../../_shared/egress');
++ const shared = require(require.resolve('openvibe-shared/egress', { paths: [path.join(APPS, 'gateway')] }));
++ assert.strictEqual(guard.isPublicAddress, shared.isPublicAddress);
++ });
++
++ // A stand-in Community that records every path it is asked for.
++ const seen = [];
++ const community = http.createServer((req, res) => { seen.push(req.url); res.setHeader('content-type', 'application/json'); res.end(JSON.stringify({ path: req.url, secret: req.url.startsWith('/api/pastes') ? null : 'internal-route-reached' })); });
++ await new Promise((r) => community.listen(0, '127.0.0.1', r));
++ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'tools-sec-'));
++ const gw = await startApp('gateway', { DATA_DIR: tmp, OV_COMMUNITY_INTERNAL_URL: `http://127.0.0.1:${community.address().port}`, OV_DOMAINS_URL: 'http://127.0.0.1:9/api/domains', OV_REGISTRY_URL: 'http://127.0.0.1:9/registry' });
++ const port = Number(new URL(gw.base).port);
++ try {
++ await check('the paste proxy forwards paste API paths (control)', async () => {
++ const r = await rawGet(port, '/api/pastes/recent?limit=5', { 'X-Forwarded-For': '198.18.0.1' });
++ assert.match(r, /^HTTP\/1\.1 200/, r.slice(0, 200));
++ assert.ok(seen.includes('/api/pastes/recent?limit=5'), seen.join(', '));
++ });
++ await check('the paste proxy never reaches another Community route (dot segments, %2e%2e, encoded slashes, backslashes)', async () => {
++ let n = 0;
++ for (const p of ['/api/pastes/../../internal/x', '/api/pastes/%2e%2e/%2e%2e/internal/x', '/api/pastes/%2E%2E/%2E%2E/internal/x', '/api/pastes/.%2e/.%2e/internal/x',
++ '/api/pastes/..%2f..%2finternal%2fx', '/api/pastes/..%5c..%5cinternal%5cx', '/api/pastes/x/%2e%2e/%2e%2e/%2e%2e/api/v1/admin']) {
++ const r = await rawGet(port, p, { 'X-Forwarded-For': `198.18.1.${++n}` });
++ assert.ok(!r.includes('internal-route-reached'), `${p}: ${r.slice(0, 160)}`);
++ }
++ const outside = seen.filter((u) => !u.startsWith('/api/pastes'));
++ assert.deepStrictEqual(outside, [], 'Community was asked for a path outside its paste API');
++ assert.ok(!seen.some((u) => /%2f|%5c/i.test(u)), `an encoded separator reached Community: ${seen.join(', ')}`);
++ });
++ } finally {
++ gw.kill('SIGTERM');
++ await gw.exited;
++ community.close();
++ fs.rmSync(tmp, { recursive: true, force: true });
++ }
++
++ await check('ratchet: every file that makes an outbound request itself is reviewed', () => {
++ const REVIEWED = {
++ 'gateway/server/auth/routes.js': 'Network JWKS, OAuth token and revoke (configured)',
++ 'gateway/server/index.js': 'OpenVibe.Community paste API (configured; paths held to /api/pastes) and the satellites on loopback',
++ 'gateway/server/net/routes.js': 'visitor-chosen hosts, through the egress guard',
++ 'gateway/server/registry/descriptors.js': 'the satellites on loopback',
++ 'gateway/server/registry/host-middleware.js': 'the satellites on loopback (fixed ports)',
++ 'gateway/server/registry/index.js': 'the satellites on loopback',
++ 'gateway/server/registry/services.js': 'Network registry (configured)',
++ 'gateway/server/revocation-events.js': 'OpenVibe.Events (configured)',
++ 'gateway/server/run/jobs-facade.js': 'the satellites on loopback',
++ 'gateway/server/search-index.js': 'OpenVibe.Search (configured)',
++ 'food/server/index.js': 'the maps satellite (configured; fixed paths, only the query string is the visitor\'s)',
++ 'maps/server/index.js': 'fixed third-party map, weather and geocoding hosts',
++ 'maps/server/sources/bathrooms.js': 'fixed third-party host', 'maps/server/sources/bridges.js': 'fixed third-party host',
++ 'maps/server/sources/freecampsites.js': 'fixed third-party host', 'maps/server/sources/grocery.js': 'fixed third-party host',
++ 'maps/server/sources/ioverlander.js': 'fixed third-party host', 'maps/server/sources/nps.js': 'fixed third-party host (NPS)',
++ 'maps/server/sources/openchargemap.js': 'fixed third-party host (OpenChargeMap)', 'maps/server/sources/overpass.js': 'fixed third-party host (Overpass)',
++ 'maps/server/sources/resources.js': 'fixed third-party host', 'maps/server/sources/ridb.js': 'fixed third-party host (RIDB)',
++ 'maps/server/sources/scraper.js': 'fixed third-party hosts', 'maps/server/sources/terrain.js': 'fixed third-party host',
++ 'maps/server/sources/usfs.js': 'fixed third-party host', 'maps/server/sources/utils.js': 'fixed third-party host (Overpass)',
++ 'maps/server/sources/weather.js': 'fixed third-party host',
++ '_shared/guard/tokens.js': 'Network JWKS / token endpoint (configured)',
++ '_shared/jobs/client.js': 'the satellites on loopback', '_shared/jobs/http.js': 'the satellites on loopback',
++ '_shared/jobs/index.js': 'OpenVibe.Events (configured)', '_shared/jobs/media.js': 'OpenVibe.Media (configured)',
++ '_shared/observe.js': 'the satellites on loopback', '_shared/tools/proxy.js': 'the satellites on loopback',
++ '_shared/tools/run.js': 'the satellites on loopback', '_shared/usage.js': 'Network user modules (configured)',
++ '_shared/egress.js': 'the egress guard itself',
++ };
++ const found = [];
++ const walk = (dir) => {
++ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
++ if (e.name === 'node_modules' || e.name === 'test' || e.name === 'public' || e.name === 'data') continue;
++ const f = path.join(dir, e.name);
++ if (e.isDirectory()) walk(f);
++ else if (e.name.endsWith('.js')) {
++ const src = fs.readFileSync(f, 'utf8').replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:'"`\\])\/\/.*$/gm, '$1');
++ if (/(^|[^.\w])(fetch|fetchImpl)\(|\bhttps?\.(get|request)\(|\bmod\.(get|request)\(|new WebSocket\(|require\(['"](axios|got|node-fetch|undici)['"]\)/m.test(src)) found.push(path.relative(APPS, f));
++ }
++ }
++ };
++ for (const app of fs.readdirSync(APPS)) {
++ if (app === '_shared') { for (const e of fs.readdirSync(path.join(APPS, '_shared'), { withFileTypes: true })) { if (e.isFile() && e.name.endsWith('.js')) { const f = path.join(APPS, '_shared', e.name); const src = fs.readFileSync(f, 'utf8').replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:'"`\\])\/\/.*$/gm, '$1'); if (/(^|[^.\w])(fetch|fetchImpl)\(|\bhttps?\.(get|request)\(|\bmod\.(get|request)\(|new WebSocket\(|require\(['"](axios|got|node-fetch|undici)['"]\)/m.test(src)) found.push(path.relative(APPS, f)); } else if (e.isDirectory() && !['node_modules', 'test'].includes(e.name)) walk(path.join(APPS, '_shared', e.name)); } continue; }
++ if (fs.existsSync(path.join(APPS, app, 'server'))) walk(path.join(APPS, app, 'server'));
++ }
++ assert.ok(found.length >= 20, `the scan finds the known sites (${found.length})`);
++ assert.deepStrictEqual(found.filter((f) => !REVIEWED[f]).sort(), [], 'a new outbound request site: a visitor-chosen host goes through apps/_shared/egress.js; then add the file here with where it goes');
++ });
++
++ if (failures) { console.log(`\n${failures} failure(s)`); process.exit(1); }
++ console.log('\ngateway security-ssrf: all checks passed');
++ process.exit(0);
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/apps/yt/server/downloader.js b/apps/yt/server/downloader.js
iindex 76fb505..3b64444 100644
---- a/apps/yt/server/downloader.js
++++ b/apps/yt/server/downloader.js
@@@ -48,6 +48,10 @@ const ALLOWED_DOMAINS = [
function isValidUrl(url) {
try {
const parsed = new URL(url);
++ // A YouTube host is not enough: file:, ftp: or a non-default port would send yt-dlp somewhere
++ // else, and a user:password part has no business in a video link.
++ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') return false;
++ if (parsed.username || parsed.password || (parsed.port && parsed.port !== '443' && parsed.port !== '80')) return false;
return ALLOWED_DOMAINS.some(re => re.test(parsed.hostname));
} catch {
return false;
ddiff --git a/apps/yt/test/security-ssrf.test.js b/apps/yt/test/security-ssrf.test.js
nnew file mode 100644
iindex 0000000..568e3d9
---- /dev/null
++++ b/apps/yt/test/security-ssrf.test.js
@@@ -0,0 +1,25 @@
++'use strict';
++/**
++ * yt-dlp is handed only YouTube video links (roadmap WS-R task 5, the SSRF class). The downloader
++ * runs yt-dlp on the link a visitor pastes, so the link decides where yt-dlp connects: a YouTube
++ * host was the only check, and file:, ftp:, gopher: or a YouTube host on another port passed it.
++ * Now only http(s) on YouTube's hosts and default ports, with no credentials, are accepted; every
++ * look-alike, internal address and scheme trick is refused before yt-dlp starts.
++ */
++const assert = require('assert');
++const { isValidUrl } = require('../server/downloader');
++
++for (const good of ['https://www.youtube.com/watch?v=dQw4w9WgXcQ', 'https://youtu.be/dQw4w9WgXcQ', 'https://m.youtube.com/shorts/abcdefghijk', 'http://youtube.com/watch?v=dQw4w9WgXcQ', 'https://music.youtube.com/watch?v=dQw4w9WgXcQ']) {
++ assert.strictEqual(isValidUrl(good), true, good);
++}
++for (const bad of [
++ 'file://www.youtube.com/etc/passwd', 'ftp://www.youtube.com/x', 'gopher://youtube.com/_x', 'data:text/plain,youtube.com', 'javascript://youtube.com/%0aalert(1)',
++ 'https://www.youtube.com:8443/watch?v=x', 'http://youtube.com:6379/', 'https://user:[email protected]/watch?v=x',
++ 'https://[email protected]/watch?v=x', 'https://youtube.com.evil.example/watch?v=x', 'https://evil.example/www.youtube.com/watch?v=x',
++ 'https://evil.example/?u=https://www.youtube.com/watch', 'https://notyoutube.com/watch?v=x', 'http://127.0.0.1/watch?v=x', 'http://2130706433/watch?v=x',
++ 'http://[::1]/watch?v=x', 'http://169.254.169.254/latest/meta-data/', 'not a url', '',
++]) {
++ assert.strictEqual(isValidUrl(bad), false, bad);
++}
++console.log('yt security-ssrf: only YouTube video links reach yt-dlp');
++process.exit(0);