openvibers/openvibe.network Commits

The frame's legal links point where the documents are (10 sites' footers 404'd) (#530)

OP OpenVibers committed 11 days ago · 0b35be48df · 3 files, +48 −5

* The frame's legal links point where the documents are: a site without its own gets openvibe.network's

Every footer links to its site's own /terms, /privacy and /dmca (the frame's footer.legal). On 11-02 ten open
sites did not serve them (AI, Bench, OpenRestream, Design, Domains, Events, Report, Surf, Watch, Search), so
their footers' legal links answered 404 in every browser. The frame now asks each open site's /privacy once
a day (GET, no redirects, 5 s); a site that does not answer 200 gets openvibe.network's documents until it
does, and gets its own back when it serves them. Before the first answer a site keeps its own, as before.

* SSRF review: the frame's legal probe (registry hosts' /privacy only, no redirects, 5 s)

Changes

ddiff --git a/server/frame/service.js b/server/frame/service.js
iindex 4444c263..6a90bff8 100644
---- a/server/frame/service.js
++++ b/server/frame/service.js
@@@ -160,8 +160,9 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
const open = orderedOpen(); const pool = linkPool();
const ai = copy.sites[site.id];
const discover = (ai ? ai.picks.map(id => pool.get(id)) : open.filter(s => s.id !== site.id).slice(0, 4).map(s => pool.get('site:' + s.id))).filter(Boolean).map(l => ({ name: l.name, url: l.url }));
-- // Legal documents live on the site's own apex, so each domain answers for itself.
-- const legalBase = `https://${site.host}`;
++ // Legal documents live on the site's own apex, so each domain answers for itself, once it serves them: a site
++ // whose /privacy does not answer gets the network-wide documents here instead of three links that 404.
++ const legalBase = legalOwn.get(site.host) === false ? 'https://openvibe.network' : `https://${site.host}`;
return {
updated: new Date(version).toISOString(), site: { id: site.id, name: site.name, host: site.host, status: site.status, state: site.state },
// Every open site, most used first, each followed by the products it serves on their own domains (`of`).
@@@ -300,6 +301,27 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
res.status(204).end();
});
++ // ── Which sites serve their own legal documents ──────────────────────────────────────────────────────────────
++ // A site's footer links to its own /terms, /privacy and /dmca (openvibe-shared/legal, wording by its manifest's
++ // legalProfile). On 11-02 ten open sites did not serve them, so their footers' legal links answered 404. Every open
++ // site's /privacy is asked once a day (GET, no redirects, 5 s); a site that does not answer 200 gets openvibe.network's
++ // documents until it does. Before the first answer a site keeps its own links, as before.
++ const legalOwn = new Map(); // host → true (serves them) | false (does not)
++ async function probeLegal({ fetchImpl = fetch } = {}) {
++ let changed = false;
++ await Promise.all(orderedOpen().map(async (s) => {
++ let ok;
++ try {
++ const r = await fetchImpl(`https://${s.host}/privacy`, { redirect: 'manual', signal: AbortSignal.timeout(5000), headers: { 'user-agent': 'OpenVibe.Network frame (legal links)', accept: 'text/html' } });
++ ok = r.status === 200;
++ if (r.body && typeof r.body.cancel === 'function') r.body.cancel().catch(() => {});
++ } catch { return; } // no answer: keep what was known
++ if (legalOwn.get(s.host) !== ok) { legalOwn.set(s.host, ok); changed = true; }
++ }));
++ if (changed) version = Date.now();
++ return Object.fromEntries(legalOwn);
++ }
++
let timers = [];
async function start() {
const rankAge = Date.now() - ((await load('rank') || {}).at || 0), copyAge = Date.now() - ((await load('copy') || {}).at || 0);
@@@ -307,7 +329,9 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
const i1 = setInterval(() => refreshRank().catch(() => {}), RANK_MS); i1.unref();
const t2 = setTimeout(() => refreshCopy().catch(() => {}), copyAge > COPY_MS ? 60_000 : COPY_MS - copyAge); t2.unref();
const i2 = setInterval(() => refreshCopy().catch(() => {}), COPY_MS); i2.unref();
-- timers = [t1, i1, t2, i2];
++ const t3 = setTimeout(() => probeLegal().catch(() => {}), 45_000); t3.unref();
++ const i3 = setInterval(() => probeLegal().catch(() => {}), 24 * 3600 * 1000); i3.unref();
++ timers = [t1, i1, t2, i2, t3, i3];
}
/** Graceful stop: no further rank or copy refreshes. */
function stop() { for (const t of timers) { clearTimeout(t); clearInterval(t); } timers = []; }
@@@ -317,7 +341,7 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
return { at: rankedAt, order: orderedOpen().map(s => s.id), everyMs: RANK_MS };
}
-- return { router, start, stop, refreshRank, refreshCopy, payloadFor, ranking, _state: () => ({ rank, copy }) };
++ return { router, start, stop, refreshRank, refreshCopy, probeLegal, payloadFor, ranking, _state: () => ({ rank, copy }) };
}
module.exports = { createFrameService, BANNED };
ddiff --git a/test/frame.test.js b/test/frame.test.js
iindex ff81606e..f7d20d3c 100644
---- a/test/frame.test.js
++++ b/test/frame.test.js
@@@ -13,6 +13,25 @@ const svc = await createFrameService(db, { services: {} }, null);
assert.equal(siteForHost('yt.openvibe.tools').id, 'tools');
assert.equal(siteForHost('evil.example'), null);
assert.strictEqual(svc.ranking().at, null, 'cold start: no use counted yet (the registry\'s featured list says so)');
++
++// Legal links (11-02: ten open sites did not serve /privacy, so their footers' Terms, Privacy and DMCA answered 404):
++// a site whose /privacy does not answer 200 gets openvibe.network's documents until it does.
++{
++ assert.equal(svc.payloadFor('openvibe.events').footer.legal.privacy, 'https://openvibe.events/privacy', 'not asked yet: its own, as before');
++ const seen = await svc.probeLegal({ fetchImpl: async (url) => ({ status: /openvibe\.(events|report)\//.test(url) ? 404 : 200 }) });
++ assert.strictEqual(seen['openvibe.events'], false);
++ assert.strictEqual(seen['openvibe.live'], true);
++ assert.deepStrictEqual(svc.payloadFor('openvibe.events').footer.legal, { terms: 'https://openvibe.network/terms', privacy: 'https://openvibe.network/privacy', dmca: 'https://openvibe.network/dmca' });
++ assert.equal(svc.payloadFor('openvibe.live').footer.legal.privacy, 'https://openvibe.live/privacy', 'a site that serves them keeps its own');
++ // A site that gains its pages gets its own links back; a site that does not answer keeps what was known.
++ await svc.probeLegal({ fetchImpl: async (url) => { if (/openvibe\.report\//.test(url)) throw new Error('down'); return { status: 200 }; } });
++ assert.equal(svc.payloadFor('openvibe.events').footer.legal.privacy, 'https://openvibe.events/privacy');
++ assert.equal(svc.payloadFor('openvibe.report').footer.legal.privacy, 'https://openvibe.network/privacy', 'no answer: kept');
++ // A redirect is not the page.
++ await svc.probeLegal({ fetchImpl: async (url) => ({ status: /openvibe\.events\//.test(url) ? 302 : 200 }) });
++ assert.equal(svc.payloadFor('openvibe.events').footer.legal.privacy, 'https://openvibe.network/privacy');
++ await svc.probeLegal({ fetchImpl: async () => ({ status: 200 }) });
++}
let p = svc.payloadFor('openvibe.media');
assert.deepEqual(p.nav.filter(n => !n.of).map(n => n.id), ['live', 'tools', 'space', 'community', 'games', 'media', 'network', 'chat', 'services', 'blog', 'wiki', 'news', 'reviews', 'tips', 'vip', 'trade', 'host', 'deals', 'coupons', 'stream', 'media-hub', 'bot', 'codes', 'actor', 'food', 'help', 'work', 'quest', 'rent', 'watch', 'inventory', 'events', 'ai', 'search', 'run', 'play', 'zone', 'domains', 'report', 'design', 'today', 'surf', 'meme', 'vision', 'exchange', 'fun', 'cam', 'bench', 'casino', 'tours', 'shoes', 'connect'], 'cold start follows the base order');
// The launcher: every open site in one of the groups, and the products other services serve on their own domains
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 0c118e87..c38c96a5 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -121,7 +121,7 @@ const REVIEWED = {
'server/domains/catalog.js fetch': [3, 'the tools catalog URL from operator config'],
'server/today/live-follows.js fetch': [1, 'Live\'s internal URL from config (OV_LIVE_INTERNAL_URL): its public list of live streams, fixed path, 2.5 s timeout, held 10 s; never a request value'],
'server/coins/routes.js fetch': [1, 'Billing internal URL from config (the person\'s Vibes with their own token, 2 s timeout)'],
-- 'server/frame/service.js fetch': [2, 'Live and AI internal URLs from config (navbar ranking copy)'],
++ 'server/frame/service.js fetch': [3, 'Live and AI internal URLs from config (navbar ranking copy); https://<open site host>/privacy, hosts from the site registry only, no redirects, 5 s (legal links)'],
'server/stats/open.js fetch': [1, 'open stats (/stats): fixed first-party loopback URLs only — Live (OV_LIVE_INTERNAL_URL, its public home numbers and service-token analytics totals) and Prometheus (PROMETHEUS_URL, loopback by default) with fixed queries; the request only picks 7, 30 or 90 days'],
'server/admin/service-proxies.js fetch': [1, 'the admin panel proxies: Live\'s and Chat\'s internal URLs from config, fixed path prefixes, staff only'],
'server/integrations/github.js fetch': [1, 'fixed host api.github.com (asserted below)'],