openvibers/openvibe.shared Commits

1.6.0: openvibe-shared/egress, the one SSRF rule for Live, Events and Tools (strictest union of their drifted copies: 3fff::/20, 5f00::/16, NAT64 /48, Teredo; connect-time safeLookup, assertPublicUrl, EgressDenied), tested down to a refused socket; release.collect() keys beacons by req.ip (behind the proxy every socket was 127.0.0.1, one bucket for the whole site) or keyOf(req); STATUS version

OP OpenVibers committed 24 Sept 2026 · 644d4c37cf · 9 files, +227 −10


Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex b7aa9d0..777c8c3 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,6 +4,20 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
breaking change to any exported module, browser global or served file name is a new major.
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
++## 1.6.0 — 2026-09-24
++
++`openvibe-shared/egress`: the one rule for connecting to an address someone else chose. Live
++(`server/net/egress.js`), Events (`server/egress.js`) and Tools (`apps/_shared/egress.js`) each had a
++copy and they had drifted: Live allowed the 3fff::/20 documentation range and 5f00::/16, and only knew
++the /96 NAT64 prefix. This module is the strictest union: `isPublicAddress`, `embeddedV4`, `expandV6`,
++`normalizeHost`, `isInternalName`, a connect-time `safeLookup` (every DNS answer must be public, so there
++is no rebinding window), `createSafeLookup` for tests, `assertPublicUrl` and `EgressDenied`. Node only;
++the three services keep their own fetch, delivery and throttle wrappers on top of it.
++
++Fixed: `release.collect()` counted client beacons per socket address, which behind the proxy is
++127.0.0.1 for everyone, so one 30-a-minute bucket refused the whole site. It now keys by `req.ip`
++(the app's trust-proxy view), or `keyOf(req)` when given.
++
## 1.5.1 — 2026-09-23
Touch and keyboard feedback in the shared chrome (owner feedback: phones flashed a theme-blind blue
ddiff --git a/README.md b/README.md
iindex 1022e5b..fa0fa7a 100644
---- a/README.md
++++ b/README.md
@@@ -24,7 +24,7 @@ were reconciled first.
| Kind | Files |
|---|---|
| Browser scripts, served at `/shared/<file>` (listed in `files.js`) | `navbar.js`, `nav-icons.js`, `theme-loader.js`, `footer.js`, `notification-ui.js`, `account-switcher.js`, `user-card.js`, `ov-mark.js`, `ov-icons.js`, `history.js`, `sso-client.js`, `panels.js`, `ui.js`, `island.js`, `tooltip.js`, `release-watch.js`, `release-update.js`, `openvibe-sw.js` |
--| Node modules (`require('openvibe-shared/<name>')`) | `index` (`.`), `analytics` (+ `analytics/{privacy,tracker,retention,schema,event,prune-cli}`), `app-icon`, `auth-client`, `brand`, `builtin-themes`, `chrome-ssr`, `legal`, `middleware`, `notifications`, `seo`, `theme-sync`, `url-resolver`, `files`, `release`, `release-compat` (tests), `metrics`, `ready`; `footer` and `icons` (= `ov-icons.js`) work on both sides, and `release-update` gives Node its pure `plan()` |
++| Node modules (`require('openvibe-shared/<name>')`) | `index` (`.`), `analytics` (+ `analytics/{privacy,tracker,retention,schema,event,prune-cli}`), `app-icon`, `auth-client`, `brand`, `builtin-themes`, `chrome-ssr`, `legal`, `middleware`, `notifications`, `seo`, `theme-sync`, `url-resolver`, `files`, `egress` (SSRF-safe addresses and connect-time DNS for outbound fetches of user-chosen hosts), `release`, `release-compat` (tests), `metrics`, `ready`; `footer` and `icons` (= `ov-icons.js`) work on both sides, and `release-update` gives Node its pure `plan()` |
| Schemas | `docs/schemas/analytics-event.v1.json` (`analytics/event.v1`, exported as `openvibe-shared/analytics/event.v1.json`) |
| Generators | `scripts/build-nav-icons.py` (Font Awesome glyphs → `nav-icons.js`, `ov-icons.js`), `scripts/build-navbar-icons.js` (navbar.js's built-in glyphs), `scripts/build-theme-loader.js`, `scripts/build-app-icons.js` |
ddiff --git a/STATUS.json b/STATUS.json
iindex 1e27d93..8dbb73e 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "1.3.0",
++ "version": "1.6.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
-- "updated": "2026-09-23"
++ "updated": "2026-09-24"
}
ddiff --git a/egress.js b/egress.js
nnew file mode 100644
iindex 0000000..75297d5
---- /dev/null
++++ b/egress.js
@@@ -0,0 +1,139 @@
++'use strict';
++/**
++ * openvibe-shared/egress: the one rule for connecting to an address that someone else chose (a URL a
++ * user typed, a developer's webhook endpoint, a host a network tool probes). Live, Events and Tools
++ * each had their own copy and the copies had drifted; this is the strictest union of them.
++ *
++ * isPublicAddress(ip) true only for a globally routable unicast address: no loopback, RFC 1918,
++ * link-local (cloud metadata), CGNAT, 0/8, multicast, documentation
++ * (incl. 3fff::/20), benchmarking, reserved, unique/site-local IPv6, SRv6
++ * SIDs, and no IPv6 form wrapping one of those (v4-mapped, v4-compatible,
++ * NAT64 64:ff9b::/96 and 64:ff9b:1::/48, 6to4, Teredo)
++ * embeddedV4(ip), expandV6(ip)
++ * normalizeHost(host), isInternalName(host) names refused before DNS (localhost, *.local, …)
++ * safeLookup dns.lookup replacement for http/https/net: fails unless EVERY answer is
++ * public, so the socket connects to the checked address (no rebinding).
++ * Node does not call lookup for an IP literal: check literals first
++ * (assertPublicUrl does, on every redirect hop)
++ * createSafeLookup({ lookup, isAllowed }) the same with an injected resolver (tests)
++ * assertPublicUrl(url, { lookup, protocols }) -> URL, or throws EgressDenied
++ * EgressDenied code EGRESS_DENIED
++ *
++ * No dependencies beyond Node. Server-side only.
++ */
++const dns = require('dns');
++const net = require('net');
++
++const V4_BLOCKED = [
++ ['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8], ['169.254.0.0', 16], ['172.16.0.0', 12],
++ ['192.0.0.0', 24], ['192.0.2.0', 24], ['192.88.99.0', 24], ['192.168.0.0', 16], ['198.18.0.0', 15],
++ ['198.51.100.0', 24], ['203.0.113.0', 24], ['224.0.0.0', 4], ['240.0.0.0', 4],
++];
++const V6_BLOCKED = [
++ ['::', 128], ['::1', 128], ['::', 96], ['100::', 64], ['2001::', 23], ['2001:db8::', 32], ['3fff::', 20],
++ ['5f00::', 16], ['fc00::', 7], ['fe80::', 10], ['fec0::', 10], ['ff00::', 8],
++];
++const blocked = new net.BlockList();
++for (const [a, p] of V4_BLOCKED) blocked.addSubnet(a, p, 'ipv4');
++for (const [a, p] of V6_BLOCKED) blocked.addSubnet(a, p, 'ipv6');
++
++/** An IPv6 address as its 8 numeric words, or null. */
++function expandV6(ip) {
++ let s = String(ip || '').toLowerCase();
++ const pct = s.indexOf('%');
++ if (pct >= 0) s = s.slice(0, pct);
++ if (!net.isIPv6(s)) return null;
++ const dq = /(\d+\.\d+\.\d+\.\d+)$/.exec(s);
++ if (dq) {
++ const p = dq[1].split('.').map(Number);
++ s = s.slice(0, -dq[1].length) + `${((p[0] << 8) | p[1]).toString(16)}:${((p[2] << 8) | p[3]).toString(16)}`;
++ }
++ const [head, tail] = s.split('::');
++ const h = head ? head.split(':') : [];
++ const t = tail !== undefined ? (tail ? tail.split(':') : []) : null;
++ const words = t === null ? h : [...h, ...Array(8 - h.length - t.length).fill('0'), ...t];
++ if (words.length !== 8) return null;
++ return words.map((w) => parseInt(w || '0', 16));
++}
++
++/** The IPv4 address an IPv6 address reaches (mapped, compatible, NAT64, 6to4, Teredo), or null. */
++function embeddedV4(ip) {
++ const w = expandV6(ip);
++ if (!w) return null;
++ const v4 = (hi, lo) => `${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`;
++ const zeros = (a, b) => w.slice(a, b).every((x) => x === 0);
++ if (zeros(0, 5) && (w[5] === 0xffff || w[5] === 0) && (w[6] || w[7])) return v4(w[6], w[7]); // ::ffff:a.b.c.d, ::a.b.c.d
++ if (w[0] === 0x64 && w[1] === 0xff9b) return v4(w[6], w[7]); // 64:ff9b::/96 and 64:ff9b:1::/48
++ if (w[0] === 0x2002) return v4(w[1], w[2]); // 6to4
++ if (w[0] === 0x2001 && w[1] === 0) return v4(w[6] ^ 0xffff, w[7] ^ 0xffff); // Teredo client
++ return null;
++}
++
++/** True only for a globally routable unicast address the server may connect to for someone else. */
++function isPublicAddress(ip) {
++ const s = String(ip || '');
++ const family = net.isIP(s);
++ if (family === 4) return !blocked.check(s, 'ipv4');
++ if (family === 6) {
++ const bare = s.split('%')[0];
++ if (blocked.check(bare, 'ipv6')) return false;
++ const v4 = embeddedV4(bare);
++ return v4 === null ? true : !blocked.check(v4, 'ipv4');
++ }
++ return false;
++}
++
++/** Lower-case, drop [brackets] and trailing dots. */
++function normalizeHost(host) {
++ let h = String(host || '').trim().toLowerCase();
++ if (h.startsWith('[') && h.endsWith(']')) h = h.slice(1, -1);
++ return h.replace(/\.+$/, '');
++}
++
++/** Names that can only mean this machine or a private network: refused before any DNS. */
++function isInternalName(host) {
++ const h = normalizeHost(host);
++ if (net.isIP(h)) return false;
++ return !h || h === 'localhost' || h.endsWith('.localhost') || h.endsWith('.local') || h.endsWith('.internal')
++ || h.endsWith('.home.arpa') || !h.includes('.');
++}
++
++class EgressDenied extends Error {
++ constructor(message) { super(message); this.name = 'EgressDenied'; this.code = 'EGRESS_DENIED'; this.status = 403; }
++}
++
++/** A dns.lookup-compatible function that fails unless every answer passes `isAllowed`. */
++function createSafeLookup({ lookup = dns.lookup, isAllowed = isPublicAddress } = {}) {
++ return function safeLookup(hostname, options, callback) {
++ if (typeof options === 'function') { callback = options; options = {}; }
++ const opts = typeof options === 'number' ? { family: options } : { ...(options || {}) };
++ const host = normalizeHost(hostname);
++ const finish = (addrs) => {
++ if (!addrs.length) return callback(new EgressDenied(`no address for ${host}`));
++ if (addrs.some((a) => !isAllowed(a.address))) return callback(new EgressDenied(`${host} resolves to a non-public address`));
++ if (opts.all) return callback(null, addrs);
++ return callback(null, addrs[0].address, addrs[0].family);
++ };
++ if (net.isIP(host)) return finish([{ address: host, family: net.isIP(host) }]);
++ if (isInternalName(host)) return callback(new EgressDenied(`${host} is not a public host name`));
++ lookup(host, { all: true, family: opts.family || 0 }, (err, addrs) => (err ? callback(err) : finish(addrs || [])));
++ };
++}
++const safeLookup = createSafeLookup();
++
++/** The URL, once its scheme is allowed and its host resolves to public addresses only; else throws EgressDenied. */
++async function assertPublicUrl(url, { lookup, protocols = ['http:', 'https:'] } = {}) {
++ let u;
++ try { u = url instanceof URL ? url : new URL(String(url)); } catch { throw new EgressDenied('not a URL'); }
++ if (!protocols.includes(u.protocol)) throw new EgressDenied(`${u.protocol} is not allowed`);
++ if (u.username || u.password) throw new EgressDenied('credentials in the URL are not allowed');
++ const look = lookup ? createSafeLookup({ lookup }) : safeLookup;
++ await new Promise((resolve, reject) => look(u.hostname, { all: true }, (e) => (e ? reject(e) : resolve())));
++ return u;
++}
++
++module.exports = {
++ isPublicAddress, embeddedV4, expandV6, normalizeHost, isInternalName,
++ safeLookup, createSafeLookup, assertPublicUrl, EgressDenied,
++ V4_BLOCKED: Object.freeze(V4_BLOCKED.map((x) => Object.freeze(x))), V6_BLOCKED: Object.freeze(V6_BLOCKED.map((x) => Object.freeze(x))),
++};
ddiff --git a/package-lock.json b/package-lock.json
iindex 05547ca..35b19dc 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-shared",
-- "version": "1.5.1",
++ "version": "1.6.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-shared",
-- "version": "1.5.1",
++ "version": "1.6.0",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0"
ddiff --git a/package.json b/package.json
iindex b921ff5..b4a8255 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "1.5.1",
++ "version": "1.6.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"exports": {
@@@ -20,6 +20,7 @@
"./brand": "./brand.js",
"./builtin-themes": "./builtin-themes.js",
"./chrome-ssr": "./chrome-ssr.js",
++ "./egress": "./egress.js",
"./files": "./files.js",
"./footer": "./footer.js",
"./history": "./history.js",
ddiff --git a/release.js b/release.js
iindex 02517c6..3491c8e 100644
---- a/release.js
++++ b/release.js
@@@ -247,9 +247,10 @@ function readBody(req) {
* POST handler for release-watch beacons: { counts: { applied|reloaded|deferred|failed: { reason: n } } }.
* Counts go to release_client_updates_total{outcome,reason} in `registry` (openvibe-shared/metrics).
* Unknown reasons count as "other"; each reason counts at most 50 per report; a report is at most 4 KB;
-- * one address sends at most `perMinute` reports a minute. Sec-GPC/DNT requests are not counted.
++ * one client (req.ip, which honours the app's trust proxy; or `keyOf(req)`) sends at most `perMinute`
++ * reports a minute. Sec-GPC/DNT requests are not counted.
*/
--function collector(registry, { perMinute = 30 } = {}) {
++function collector(registry, { perMinute = 30, keyOf = null } = {}) {
if (!registry || typeof registry.counter !== 'function') throw new TypeError('release: collect() needs an openvibe-shared/metrics registry');
const counter = updateCounter(registry);
const seen = new Map(); let windowStart = Date.now();
@@@ -259,7 +260,8 @@ function collector(registry, { perMinute = 30 } = {}) {
if (h['sec-gpc'] === '1' || h.dnt === '1') return done(204);
const now = Date.now();
if (now - windowStart > 60000) { seen.clear(); windowStart = now; }
-- const ip = String((req.socket && req.socket.remoteAddress) || req.ip || '');
++ // req.ip first: behind the proxy every socket is 127.0.0.1, and one shared bucket refused everyone.
++ const ip = String((keyOf && keyOf(req)) || req.ip || (req.socket && req.socket.remoteAddress) || '');
const n = (seen.get(ip) || 0) + 1;
if (seen.size < 10000 || seen.has(ip)) seen.set(ip, n);
if (n > perMinute) return done(429);
ddiff --git a/test/egress.test.js b/test/egress.test.js
nnew file mode 100644
iindex 0000000..ccaf350
---- /dev/null
++++ b/test/egress.test.js
@@@ -0,0 +1,61 @@
++'use strict';
++/** openvibe-shared/egress: one public-address rule for Live, Events and Tools, strict where the old copies disagreed. */
++const assert = require('assert');
++const http = require('http');
++const eg = require('../egress');
++
++let n = 0;
++const check = (name, fn) => { fn(); n++; };
++
++check('private, reserved and special IPv4 ranges are refused; public unicast passes', () => {
++ for (const ip of ['0.0.0.0', '10.1.2.3', '100.64.0.1', '127.0.0.1', '169.254.169.254', '172.16.0.1', '172.31.255.255', '192.0.0.8', '192.0.2.1',
++ '192.88.99.1', '192.168.1.1', '198.18.0.1', '198.51.100.7', '203.0.113.9', '224.0.0.1', '240.0.0.1', '255.255.255.255']) assert.strictEqual(eg.isPublicAddress(ip), false, ip);
++ for (const ip of ['1.1.1.1', '8.8.8.8', '93.184.216.34', '172.32.0.1', '100.128.0.1']) assert.strictEqual(eg.isPublicAddress(ip), true, ip);
++});
++
++check('IPv6: special ranges and every form that wraps a private IPv4 are refused', () => {
++ for (const ip of ['::', '::1', 'fe80::1', 'fe80::1%eth0', 'fec0::1', 'fc00::1', 'fd12:3456::1', 'ff02::1', '100::1', '2001:db8::1',
++ '3fff::1', '5f00::1', '::ffff:127.0.0.1', '::ffff:7f00:1', '::ffff:a9fe:a9fe', '::127.0.0.1', '64:ff9b::7f00:1',
++ '64:ff9b:1::a9fe:a9fe', '2002:7f00:1::', '2001:0:4136:e378:8000:63bf:80ff:fffe']) assert.strictEqual(eg.isPublicAddress(ip), false, ip);
++ for (const ip of ['2606:4700:4700::1111', '2a00:1450:4001::200e', '64:ff9b::808:808', '::ffff:8.8.8.8']) assert.strictEqual(eg.isPublicAddress(ip), true, ip);
++ assert.strictEqual(eg.embeddedV4('2001:0:4136:e378:8000:63bf:80ff:fffe'), '127.0.0.1', 'Teredo client address');
++ assert.strictEqual(eg.embeddedV4('2002:c0a8:101::1'), '192.168.1.1', '6to4');
++ assert.strictEqual(eg.isPublicAddress('not an ip'), false);
++});
++
++check('internal names are refused before DNS', () => {
++ for (const h of ['localhost', 'LOCALHOST.', 'api.localhost', 'printer.local', 'db.internal', 'nas.home.arpa', 'intranet', '']) assert.strictEqual(eg.isInternalName(h), true, h);
++ for (const h of ['openvibe.live', 'example.org.', '8.8.8.8', '[::1]']) assert.strictEqual(eg.isInternalName(h), false, h);
++});
++
++const lookupOf = (answers) => (host, _o, cb) => cb(null, answers[host] || []);
++(async () => {
++ const look = eg.createSafeLookup({ lookup: lookupOf({ 'good.example': [{ address: '93.184.216.34', family: 4 }], 'rebind.example': [{ address: '93.184.216.34', family: 4 }, { address: '10.0.0.5', family: 4 }] }) });
++ const call = (h, o) => new Promise((r) => look(h, o, (e, a, f) => r({ e, a, f })));
++ let r = await call('good.example', {});
++ assert.deepStrictEqual([r.e, r.a, r.f], [null, '93.184.216.34', 4]);
++ r = await call('good.example', { all: true });
++ assert.deepStrictEqual(r.a, [{ address: '93.184.216.34', family: 4 }]);
++ r = await call('rebind.example', {});
++ assert.ok(r.e instanceof eg.EgressDenied && r.e.code === 'EGRESS_DENIED', 'any non-public answer fails the whole lookup');
++ assert.ok((await call('localhost', {})).e instanceof eg.EgressDenied);
++ assert.ok((await call('[::ffff:127.0.0.1]', {})).e instanceof eg.EgressDenied, 'a literal is checked without DNS');
++ n++;
++
++ const lk = lookupOf({ 'good.example': [{ address: '93.184.216.34', family: 4 }] });
++ assert.strictEqual((await eg.assertPublicUrl('https://good.example/x', { lookup: lk })).hostname, 'good.example');
++ for (const bad of ['ftp://good.example/', 'https://user:[email protected]/', 'http://127.0.0.1/', 'not a url', 'http://nowhere.example/']) {
++ await assert.rejects(eg.assertPublicUrl(bad, { lookup: lk }), eg.EgressDenied, bad);
++ }
++ n++;
++
++ // The real Node stack: a connection through safeLookup to a loopback server is refused at connect time.
++ const srv = await new Promise((res) => { const s = http.createServer((q, a) => a.end('secret')).listen(0, '127.0.0.1', () => res(s)); });
++ // (Node does not call lookup for an IP literal: callers check literals first, as assertPublicUrl does.)
++ const viaName = eg.createSafeLookup({ lookup: lookupOf({ 'rebound.example': [{ address: '127.0.0.1', family: 4 }] }) });
++ const err = await new Promise((res) => http.get({ host: 'rebound.example', port: srv.address().port, lookup: viaName }, () => res(null)).on('error', res));
++ srv.close();
++ assert.ok(err && err.code === 'EGRESS_DENIED', `connect-time refusal (${err && err.code})`);
++ n++;
++ console.log(`egress: ${n} checks passed`);
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/package.test.js b/test/package.test.js
iindex 64d73ce..e8f55a5 100644
---- a/test/package.test.js
++++ b/test/package.test.js
@@@ -22,7 +22,7 @@ for (const sub of ['analytics', 'app-icon', 'auth-client', 'brand', 'chrome-ssr'
assert.doesNotThrow(() => require.resolve(`openvibe-shared/${sub}`), `openvibe-shared/${sub}`);
}
// Server modules load in Node; the navbar module loads too (UMD, no DOM touched at require time).
--for (const sub of ['analytics', 'app-icon', 'brand', 'chrome-ssr', 'footer', 'icons', 'legal', 'notifications', 'seo', 'url-resolver', 'files', 'nav-icons', 'metrics', 'ready']) require(`openvibe-shared/${sub}`);
++for (const sub of ['analytics', 'app-icon', 'brand', 'chrome-ssr', 'footer', 'icons', 'legal', 'notifications', 'seo', 'url-resolver', 'files', 'nav-icons', 'metrics', 'ready', 'egress']) require(`openvibe-shared/${sub}`);
// analytics is dependency-free: its parts require only Node built-ins and each other.
for (const f of fs.readdirSync(path.join(ROOT, 'analytics'))) {
const src = fs.readFileSync(path.join(ROOT, 'analytics', f), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');