openvibers/openvibe.tools Commits

Secrets never reach a visitor of the gateway or the maps satellite, by test (roadmap WS-R task 5: internal-secret leak). apps/_shared/test/no-network.js is a preload for a started satellite: every connection or lookup that is not loopback fails at once, so third-party calls take their error paths without the internet.

OP OpenVibers committed 26 Sept 2026 · 427b354b0a · 3 files, +212 −0

apps/gateway/test/security-secrets.test.js: sentinels as the OAuth client secret, both internal keys and the Events subscription secret; 691 paths (pages, APIs, the forged sign-in callback, every tool in the registry with its schema and page, every net tool against hosts, internal addresses and nonsense, broken writes): no sentinel in a body, a header or the process output. apps/maps/test/security-secrets.test.js: sentinels as the RIDB, NPS and OpenChargeMap keys and the internal keys; every route with places and nonsense while every source fails (search reports each source's error to the visitor): no key in a body, a header or the output. Both requests come from a fresh client address each, so the per-address limits are not what is read. Private and IDOR on the gateway (webhook bins, job owners) are already pinned by guard.test.js and jobs-facade.test.js.

Changes

ddiff --git a/apps/_shared/test/no-network.js b/apps/_shared/test/no-network.js
nnew file mode 100644
iindex 0000000..b65b041
---- /dev/null
++++ b/apps/_shared/test/no-network.js
@@@ -0,0 +1,45 @@
++'use strict';
++// Preload (node -r) for tests that start a satellite whose code calls third-party APIs: every
++// connection to anything but loopback fails at once (ECONNREFUSED) and every lookup of a name other
++// than localhost fails (ENOTFOUND), so the process's error paths run without touching the internet.
++const dns = require('dns');
++const net = require('net');
++
++const LOCAL = new Set(['127.0.0.1', '::1', 'localhost', '::ffff:127.0.0.1']);
++const isLocal = (h) => h == null || LOCAL.has(String(h).replace(/^\[|\]$/g, '').toLowerCase());
++const notFound = (host) => Object.assign(new Error(`getaddrinfo ENOTFOUND ${host} (no network in tests)`), { code: 'ENOTFOUND', hostname: host });
++
++const realLookup = dns.lookup;
++dns.lookup = function (host, options, cb) {
++ if (typeof options === 'function') { cb = options; options = {}; }
++ if (isLocal(host)) return realLookup.call(this, host, options, cb);
++ process.nextTick(() => cb(notFound(host)));
++ return {};
++};
++const realPromiseLookup = dns.promises.lookup;
++dns.promises.lookup = async function (host, options) {
++ if (isLocal(host)) return realPromiseLookup.call(this, host, options);
++ throw notFound(host);
++};
++const realConnect = net.Socket.prototype.connect;
++net.Socket.prototype.connect = function (...args) {
++ let o = args[0];
++ if (Array.isArray(o)) o = o[0];
++ const host = o && typeof o === 'object' ? (o.path != null ? null : o.host) : (typeof args[1] === 'string' ? args[1] : null);
++ if (o && typeof o === 'object' && o.path != null) return realConnect.apply(this, args);
++ if (!isLocal(host)) {
++ const err = Object.assign(new Error(`connect ECONNREFUSED ${host} (no network in tests)`), { code: 'ECONNREFUSED', syscall: 'connect' });
++ process.nextTick(() => this.destroy(err));
++ return this;
++ }
++ return realConnect.apply(this, args);
++};
++if (typeof globalThis.fetch === 'function') {
++ const realFetch = globalThis.fetch;
++ globalThis.fetch = (url, opts) => {
++ let host = null;
++ try { host = new URL(String((url && url.url) || url)).hostname; } catch { /* */ }
++ if (!isLocal(host)) return Promise.reject(Object.assign(new TypeError('fetch failed'), { cause: notFound(host) }));
++ return realFetch(url, opts);
++ };
++}
ddiff --git a/apps/gateway/test/security-secrets.test.js b/apps/gateway/test/security-secrets.test.js
nnew file mode 100644
iindex 0000000..4e84c37
---- /dev/null
++++ b/apps/gateway/test/security-secrets.test.js
@@@ -0,0 +1,91 @@
++'use strict';
++/**
++ * The gateway's secrets never reach a visitor (roadmap WS-R task 5, the internal-secret class). The
++ * gateway starts with a sentinel as its Network OAuth client secret, its internal API keys and its
++ * Events subscription secret, and with no network (the no-network preload: every third-party call
++ * and lookup fails at once, so the net tools and the sign-in, registry and search calls all take
++ * their error paths). Then its pages, APIs, every tool in its registry (description and schema) and
++ * every net tool with hosts, internal addresses and nonsense are requested; and the sign-in callback
++ * with a forged code (it posts the client secret to Network). No body or header may carry a sentinel,
++ * nor may the process's output.
++ */
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const { startApp } = require('../../_shared/test/spawn');
++
++const SECRETS = {
++ OV_OAUTH_CLIENT_SECRET: 'sentinel-not-a-secret-tools-oauth-client',
++ INTERNAL_API_KEY: 'sentinel-not-a-secret-tools-internal',
++ OV_INTERNAL_KEY: 'sentinel-not-a-secret-tools-ov-internal',
++ TOOLS_EVENTS_SECRET: 'sentinel-not-a-secret-tools-events-subscription',
++};
++const NET_TOOLS = ['blacklist', 'dnsprop', 'dns', 'headers', 'ip', 'ipv4', 'ipv6', 'lookup', 'ping', 'port', 'rdap', 'rdns', 'redirects', 'robots', 'sitemap', 'smtp', 'ssl', 'uptime', 'whois'];
++let seq = 0;
++const client = () => { seq++; return { 'x-forwarded-for': `198.18.${(seq >> 8) & 255}.${seq & 255}` }; };
++
++(async () => {
++ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'tools-sec-'));
++ const gw = await startApp('gateway', {
++ ...SECRETS, DATA_DIR: tmp, OV_OAUTH_CLIENT_ID: 'tools',
++ OV_DOMAINS_URL: 'http://127.0.0.1:9/api/domains', OV_REGISTRY_URL: 'http://127.0.0.1:9/registry', OV_COMMUNITY_INTERNAL_URL: 'http://127.0.0.1:9',
++ NODE_OPTIONS: `--require ${path.join(__dirname, '..', '..', '_shared', 'test', 'no-network.js')}`,
++ });
++ 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 ')}`); } };
++ const get = async (p, opts = {}) => {
++ const ac = new AbortController();
++ const timer = setTimeout(() => ac.abort(), 15000);
++ try {
++ const r = await fetch(gw.base + p, { redirect: 'manual', signal: ac.signal, ...opts, headers: { ...client(), ...(opts.headers || {}) } });
++ const text = await r.text().catch(() => '');
++ return { status: r.status, text: text + JSON.stringify([...r.headers.entries()]) };
++ } catch { return { status: 0, text: '' }; } finally { clearTimeout(timer); }
++ };
++ try {
++ const paths = ['/', '/all-tools', '/tools', '/developers', '/updates', '/tos', '/llms.txt', '/robots.txt', '/sitemap.xml', '/search?q=x', '/me', '/login', '/logout',
++ '/callback?code=forged&state=forged', '/auth/callback?code=forged&state=forged', '/api/health', '/api/ready', '/api/brand', '/api/catalog.json', '/api/v1/openapi.json',
++ '/api/v1/me/recent-tools', '/api/internal/analytics', '/api/nope', '/nope', '/.env', '/api/%', '/api/pastes/x', '/webhook/bins/nope', '/myip', '/opengraph?url=http://127.0.0.1/',
++ '/api/v1/tools', '/api/v1/tools?q=x'];
++ const tools = await get('/api/v1/tools');
++ let ids = [];
++ try { ids = (JSON.parse(tools.text.slice(0, tools.text.lastIndexOf('[['))).tools || []).map((t) => t.id); } catch { /* listed below */ }
++ for (const id of ids) paths.push(`/api/v1/tools/${encodeURIComponent(id)}`, `/api/v1/tools/${encodeURIComponent(id)}/schema`, `/tool/${encodeURIComponent(id)}`);
++ for (const tool of NET_TOOLS) {
++ for (const target of ['example.com', '127.0.0.1', '169.254.169.254', "'\"<x>"]) paths.push(`/${tool}/${encodeURIComponent(target)}`, `/api/net/${tool}?target=${encodeURIComponent(target)}&host=${encodeURIComponent(target)}`);
++ }
++ const found = [];
++ const statuses = {};
++ const limited = [];
++ await check(`every page, API, registered tool and net tool (${paths.length} paths): no sentinel`, async () => {
++ for (const p of paths) {
++ const r = await get(p);
++ statuses[r.status] = (statuses[r.status] || 0) + 1;
++ if (r.status === 429) limited.push(p);
++ for (const [k, v] of Object.entries(SECRETS)) if (r.text.includes(v)) found.push(`GET ${p} → ${r.status} carries ${k}`);
++ }
++ for (const p of ['/api/pastes/x', '/api/net/port', '/auth/fedcm', '/webhook/bins']) {
++ const r = await get(p, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{"broken": ' });
++ for (const [k, v] of Object.entries(SECRETS)) if (r.text.includes(v)) found.push(`POST ${p} → ${r.status} carries ${k}`);
++ }
++ console.log(` (tools listed: ${ids.length}; answers ${JSON.stringify(statuses)})`);
++ assert.ok(ids.length > 10, 'the tool registry was listed');
++ // The net tools also throttle per target (the same host asked too often): only those may be 429.
++ const netTool = new RegExp(`^/(api/net/)?(${NET_TOOLS.join('|')})[/?]`);
++ assert.deepStrictEqual(limited.filter((p) => !netTool.test(p)), [], 'rate-limited outside the net tools');
++ assert.deepStrictEqual(found, []);
++ });
++ await check('the process output carries no sentinel', () => {
++ const out = gw.output();
++ for (const [k, v] of Object.entries(SECRETS)) assert.ok(!out.includes(v), `a log line carries ${k}`);
++ });
++ } finally {
++ gw.kill('SIGTERM');
++ await gw.exited;
++ fs.rmSync(tmp, { recursive: true, force: true });
++ }
++ if (failures) { console.log(`\n${failures} failure(s)`); process.exit(1); }
++ console.log('\ngateway security-secrets: all checks passed');
++ process.exit(0);
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/apps/maps/test/security-secrets.test.js b/apps/maps/test/security-secrets.test.js
nnew file mode 100644
iindex 0000000..d1e6f52
---- /dev/null
++++ b/apps/maps/test/security-secrets.test.js
@@@ -0,0 +1,76 @@
++'use strict';
++/**
++ * The maps satellite's provider keys never reach a visitor (roadmap WS-R task 5, the internal-secret
++ * class). Maps calls RIDB, NPS and OpenChargeMap with keys from its environment, and /api/search
++ * reports each source's failure to the visitor (sourceMeta[].error). The satellite starts with a
++ * sentinel in each key and with no network (test/no-network.js preloaded: every third-party call
++ * fails at once), so every source takes its error path; then every route is requested with places
++ * and nonsense coordinates. No body or header may carry a key; nor may the process's output.
++ */
++const assert = require('assert');
++const path = require('path');
++const { startApp } = require('../../_shared/test/spawn');
++
++const KEYS = {
++ RIDB_API_KEY: 'sentinel-not-a-secret-ridb',
++ NPS_API_KEY: 'sentinel-not-a-secret-nps',
++ OPEN_CHARGE_MAP_KEY: 'sentinel-not-a-secret-ocm',
++ INTERNAL_API_KEY: 'sentinel-not-a-secret-internal',
++ OV_INTERNAL_KEY: 'sentinel-not-a-secret-ov-internal',
++};
++
++// One loopback proxy hop is trusted (guard/ip.js): a fresh client address per request keeps the
++// per-address limit out of what is being read.
++let seq = 0;
++const client = () => { seq++; return { 'x-forwarded-for': `198.18.${(seq >> 8) & 255}.${seq & 255}` }; };
++
++(async () => {
++ const maps = await startApp('maps', { ...KEYS, NODE_OPTIONS: `--require ${path.join(__dirname, '..', '..', '_shared', 'test', 'no-network.js')}` }, undefined, { readyPath: '/api/ready' });
++ 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 ')}`); } };
++ try {
++ const places = ['lat=47.6062&lon=-122.3321&radius=25&q=seattle', 'lat=37.7749&lon=-122.4194&radius=100&q=park&state=CA', 'lat=999&lon=abc&radius=-1&q=%27%22%3Cx%3E', 'q=' + 'x'.repeat(300)];
++ const paths = ['/api/search', '/api/search/stream', '/api/geocode', '/api/weather', '/api/terrain', '/api/food-banks', '/api/foods', '/api/stores', '/api/meal-plan',
++ '/api/internal/analytics', '/api/internal/analytics/bots', '/api/ready', '/api/nope', '/', '/nope'];
++ let answered = 0;
++ const found = [];
++ const statuses = {};
++ await check('every route, with places and nonsense: no provider or internal key in a body or header', async () => {
++ for (const p of paths) {
++ for (const q of places) {
++ let r = null;
++ try {
++ const ac = new AbortController();
++ const timer = setTimeout(() => ac.abort(), 15000);
++ r = await fetch(`${maps.base}${p}?${q}`, { signal: ac.signal, headers: client() });
++ const text = await r.text().catch(() => '');
++ clearTimeout(timer);
++ answered++;
++ statuses[r.status] = (statuses[r.status] || 0) + 1;
++ const all = text + JSON.stringify([...r.headers.entries()]);
++ for (const [k, v] of Object.entries(KEYS)) if (all.includes(v)) found.push(`GET ${p}?${q.slice(0, 40)} → ${r.status} carries ${k}`);
++ } catch { /* a stream cut off by the timeout carries what it carried */ }
++ }
++ }
++ assert.ok(answered >= paths.length * 2, `${answered} answers`);
++ assert.ok(statuses[429] === undefined, `rate-limited answers: ${statuses[429]}`);
++ assert.deepStrictEqual(found, []);
++ });
++ await check('the search really ran its sources into their error paths (the keys were in use)', async () => {
++ const r = await fetch(`${maps.base}/api/search?${places[0]}`, { headers: client() });
++ const body = await r.json().catch(() => ({}));
++ const meta = JSON.stringify(body.sourceMeta || body);
++ assert.match(meta, /RIDB|NPS|OpenChargeMap/, meta.slice(0, 300));
++ });
++ await check('the process output carries no key', async () => {
++ const out = maps.output();
++ for (const [k, v] of Object.entries(KEYS)) assert.ok(!out.includes(v), `a log line carries ${k}`);
++ });
++ } finally {
++ maps.kill('SIGTERM');
++ await maps.exited;
++ }
++ if (failures) { console.log(`\n${failures} failure(s)`); process.exit(1); }
++ console.log('\nmaps security-secrets: all checks passed');
++ process.exit(0);
++})().catch((e) => { console.error(e); process.exit(1); });