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
Changes
@@@ -4,6 +4,20 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semvebreaking 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-23Touch and keyboard feedback in the shared chrome (owner feedback: phones flashed a theme-blind blue@@@ -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` |@@@ -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"}nnew file mode 100644@@@ -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))),++};@@@ -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"@@@ -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",@@@ -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);nnew file mode 100644@@@ -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); });@@@ -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, '');
@@ -4,6 +4,20 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
@@ -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.
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).
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
## 1.5.1 — 2026-09-23
Touch and keyboard feedback in the shared chrome (owner feedback: phones flashed a theme-blind blue
Touch and keyboard feedback in the shared chrome (owner feedback: phones flashed a theme-blind blue
@@ -24,7 +24,7 @@ were reconciled first.
@@ -24,7 +24,7 @@ were reconciled first.
| Kind | Files |
| 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` |
| 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`) |
| 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` |
| 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` |
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "1.3.0",
+ "version": "1.6.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
- "updated": "2026-09-23"
+ "updated": "2026-09-24"
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,139 @@
@@ -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))),
+};
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "1.5.1",
+ "version": "1.6.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "1.5.1",
+ "version": "1.6.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0"
"jsonwebtoken": "^9.0.0"
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"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",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"exports": {
"exports": {
@@ -20,6 +20,7 @@
@@ -20,6 +20,7 @@
"./brand": "./brand.js",
"./brand": "./brand.js",
"./builtin-themes": "./builtin-themes.js",
"./builtin-themes": "./builtin-themes.js",
"./chrome-ssr": "./chrome-ssr.js",
"./chrome-ssr": "./chrome-ssr.js",
+ "./egress": "./egress.js",
"./files": "./files.js",
"./files": "./files.js",
"./footer": "./footer.js",
"./footer": "./footer.js",
"./history": "./history.js",
"./history": "./history.js",
@@ -247,9 +247,10 @@ function readBody(req) {
@@ -247,9 +247,10 @@ function readBody(req) {
* POST handler for release-watch beacons: { counts: { applied|reloaded|deferred|failed: { reason: n } } }.
* 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).
* 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;
* 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');
if (!registry || typeof registry.counter !== 'function') throw new TypeError('release: collect() needs an openvibe-shared/metrics registry');
const counter = updateCounter(registry);
const counter = updateCounter(registry);
const seen = new Map(); let windowStart = Date.now();
const seen = new Map(); let windowStart = Date.now();
@@ -259,7 +260,8 @@ function collector(registry, { perMinute = 30 } = {}) {
@@ -259,7 +260,8 @@ function collector(registry, { perMinute = 30 } = {}) {
if (h['sec-gpc'] === '1' || h.dnt === '1') return done(204);
if (h['sec-gpc'] === '1' || h.dnt === '1') return done(204);
const now = Date.now();
const now = Date.now();
if (now - windowStart > 60000) { seen.clear(); windowStart = 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;
const n = (seen.get(ip) || 0) + 1;
if (seen.size < 10000 || seen.has(ip)) seen.set(ip, n);
if (seen.size < 10000 || seen.has(ip)) seen.set(ip, n);
if (n > perMinute) return done(429);
if (n > perMinute) return done(429);
new file mode 100644
new file mode 100644
@@ -0,0 +1,61 @@
@@ -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); });
@@ -22,7 +22,7 @@ for (const sub of ['analytics', 'app-icon', 'auth-client', 'brand', 'chrome-ssr'
@@ -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}`);
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).
// 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.
// analytics is dependency-free: its parts require only Node built-ins and each other.
for (const f of fs.readdirSync(path.join(ROOT, 'analytics'))) {
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, '');
const src = fs.readFileSync(path.join(ROOT, 'analytics', f), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');