openvibers/openvibe.host Commits

SSRF regression test for tenant-named hosts (roadmap WS-R task 5). A custom domain is the one host a tenant names, and it is proven by a DNS TXT record through the configured resolver, never by a request to it: test/security-ssrf.test.js adds domains that name or resolve to internal addresses in every spelling (loopback, decimal, hex, IPv6, mapped, metadata, private, .localhost/.local/.internal names), verifies them, serves requests with those Host headers and absolute-form targets, and records every outbound request and connection of the process: only the stand-in Network and the dashboard itself are reached (a real domain is added and verified as the control). And a ratchet over the files in server/ that make outbound requests. The other classes are already pinned for Host: tenant isolation and existence oracles (host-isolation.test.js, the private and IDOR classes), deployment secrets out of public state (host-secrets-dashboard.test.js).

OP OpenVibers committed 26 Sept 2026 · 94e1616495 · 1 file, +89 −0


Changes

ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
nnew file mode 100644
iindex 0000000..a0e844a
---- /dev/null
++++ b/test/security-ssrf.test.js
@@@ -0,0 +1,89 @@
++'use strict';
++/**
++ * Host fetches no URL or host a tenant chose (roadmap WS-R task 5, the SSRF class). The one place a
++ * tenant names a host is a custom domain, and it is proven by a DNS TXT record read through the
++ * configured resolver, never by a request to the domain: this suite adds custom domains that name
++ * or resolve to internal addresses in every spelling (loopback, decimal, IPv6, metadata, private
++ * ranges, internal names), verifies and rechecks them, serves requests with those Host headers and
++ * absolute-form targets, and records every outbound request of the process: none may go to any of
++ * them. (host-isolation.test.js pins that a tenant reaches only its own site through Host headers,
++ * paths and absolute-form targets.) And a ratchet over the files in server/ that make outbound
++ * requests.
++ */
++const assert = require('assert');
++const fs = require('fs');
++const net = require('net');
++const path = require('path');
++
++const outbound = [];
++const realFetch = globalThis.fetch;
++globalThis.fetch = (url, opts) => { outbound.push(String((url && url.url) || url)); return 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(`${o.host}:${o.port}`);
++ return realConnect.apply(this, args);
++};
++
++const { boot, check, done } = require('./stageb/boot');
++
++(async () => {
++ const t = await boot();
++ const alice = t.user('alice');
++ const p = await t.project(alice, 'A');
++ const s = await t.site(alice, p.id, 'alice-site');
++ await t.deploy(alice, s.id, { 'index.html': 'ALICE' });
++
++ await check('custom domains naming internal hosts are refused or proven by TXT only, never requested', async () => {
++ const before = connects.length;
++ const names = ['www.alice-example.org', 'localhost', '127.0.0.1', '2130706433', '0x7f000001', '[::1]', '::ffff:127.0.0.1', '169.254.169.254', '10.0.0.1', 'metadata.google.internal',
++ 'admin.localhost', 'intranet.local', 'db.internal', '127.0.0.1.nip.io'];
++ const added = [];
++ for (const hostname of names) {
++ const r = await t.api('POST', `/api/v1/sites/${s.id}/domains`, { as: alice, json: { hostname } });
++ if (r.status !== 201) continue;
++ added.push(hostname);
++ const dom = r.json().domain;
++ if (t.dns) t.dns.set(`_openvibe-host.${dom.hostname}`, [dom.instructions.verification.value]);
++ await t.api('POST', `/api/v1/domains/${dom.id}/verify`, { as: alice, json: {} });
++ await t.get(dom.hostname, '/');
++ }
++ for (const target of ['http://127.0.0.1:1/', 'http://169.254.169.254/latest/meta-data/', 'http://[::1]/']) {
++ await t.request({ method: 'GET', host: 'alice-site.openvibe.host', path: target });
++ }
++ assert.ok(added.includes('www.alice-example.org'), `a real domain was added and verified (control): ${added.join(', ')}`);
++ assert.ok(t.dnsCalls.length > 0, 'verification asked the resolver for TXT records');
++ // The only outbound requests are to the stand-in Network (keys, tokens); every connection goes
++ // to the dashboard itself or to it.
++ const mine = new URL(t.network.url);
++ assert.deepStrictEqual(outbound.filter((u) => !u.startsWith(t.network.url)), [], 'Host requested something other than Network');
++ const allowed = new Set([String(t.port), mine.port]);
++ const odd = connects.slice(before).filter((c) => !(/^(127\.0\.0\.1|::1|localhost):/.test(c) && allowed.has(c.split(':').pop())));
++ assert.deepStrictEqual(odd, [], 'a connection went to a tenant-named host');
++ });
++
++ await check('ratchet: every file in server/ that makes an outbound request itself is reviewed', () => {
++ const REVIEWED = {
++ 'server/auth/sso.js': 'Network JWKS, OAuth token and revoke (configured)',
++ };
++ const root = path.join(__dirname, '..');
++ const found = [];
++ const walk = (dir) => {
++ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
++ 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\(|\bhttps?\.(get|request)\(|new WebSocket\(|\bnet\.connect\(|require\(['"](axios|got|node-fetch|undici)['"]\)/m.test(src)) found.push(path.relative(root, f));
++ }
++ }
++ };
++ walk(path.join(root, 'server'));
++ assert.ok(found.length >= 1, `the scan finds the known site (${found.join(', ')})`);
++ assert.deepStrictEqual(found.filter((f) => !REVIEWED[f]).sort(), [], 'a new outbound request site: a tenant-chosen host goes through openvibe-shared/egress; then add the file here with where it goes');
++ });
++
++ await t.close();
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });