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.
Changes
@@@ -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);nnew file mode 100644@@@ -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); });@@@ -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;nnew file mode 100644@@@ -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);
@@ -316,6 +316,14 @@ app.get('/api/brand', (_req, res) => res.json(BRAND));
@@ -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) => {
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 {
try {
const target = `${config.communityUrl}/api/pastes${req.url}`;
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: {} };
const fetchOpts = { method: req.method, headers: {} };
// Forward the visitor's JWT — Community verifies it and maps it to their subject.
// Forward the visitor's JWT — Community verifies it and maps it to their subject.
const userToken = extractToken(req);
const userToken = extractToken(req);
new file mode 100644
new file mode 100644
@@ -0,0 +1,129 @@
@@ -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); });
@@ -48,6 +48,10 @@ const ALLOWED_DOMAINS = [
@@ -48,6 +48,10 @@ const ALLOWED_DOMAINS = [
function isValidUrl(url) {
function isValidUrl(url) {
try {
try {
const parsed = new URL(url);
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));
return ALLOWED_DOMAINS.some(re => re.test(parsed.hostname));
} catch {
} catch {
return false;
return false;
new file mode 100644
new file mode 100644
@@ -0,0 +1,25 @@
@@ -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);