openvibers/openvibe.tools Commits

X-Internal-Key is gone from Tools (plan T2): GET /api/internal/analytics[/bots] on the gateway and the seven tool sites take only a Network service token with tools.analytics.read (401 token.missing without one), loopback only; the gateway forwards only the caller's Bearer. apps/_shared/internal-auth.js is deleted (loopbackOnly moved into internal-token.js); INTERNAL_API_KEY/OV_INTERNAL_KEY leave .env.example and the README. Tests: the key alone and a bad Bearer beside the key are 401, the token passes and is forwarded, and no server file reads the key. npm test green (verified).

OP OpenVibers committed 29 Sept 2026 · 139ece3044 · 9 files, +85 −88


Changes

ddiff --git a/README.md b/README.md
iindex c530377..d90598c 100644
---- a/README.md
++++ b/README.md
@@@ -398,7 +398,7 @@ address). Past a limit: `429` problem+json `rate_limited` with `Retry-After`, on
|---|---|---|
| every app | Registry reads `GET`/`HEAD /api/v1/tools[/:id[/schema]]` by a signed-in person or a third-party principal (`app:…`, `mod:…`) | `TOOLS_LIMITS_MINUTE` / `TOOLS_LIMITS_HOUR` (120 / 3000) |
| gateway, img, audio, docs | Backstop on job submits and retries (`POST /api/v1/jobs`, `…/:id/retry`) and runs (`POST /api/v1/tools/:id/run`) | a person or an address 600 / 20 000; a principal 3000 / 100 000 |
--| gateway | `GET /api/internal/analytics` (the Network admin's analytics page, after the internal key) | 30 / 300 |
++| gateway | `GET /api/internal/analytics` (the Network admin's analytics page, with a Network service token) | 30 / 300 |
Signed-out registry reads keep only the per-address `/api/` limit (many visitors share a carrier or campus
address); a first-party service (`svc:…`) reading for itself is not counted. The backstop sits above the
@@@ -601,7 +601,7 @@ sniffed against the descriptor; every tool that reaches a visitor-chosen host go
(`apps/_shared/egress.js`: public addresses only, checked after DNS, redirect hops re-checked); `trust proxy`
is one loopback hop; sign-in `next` accepts only relative paths, `*.openvibe.tools` and `openvibe.network`.
Secrets (`OV_OAUTH_CLIENT_SECRET`, `TOOLS_EVENTS_SECRET`, `NET_IPINFO_TOKEN`, `RIDB_API_KEY`, `NPS_API_KEY`,
--`OPEN_CHARGE_MAP_KEY`, `YT_COOKIES_FILE`, the fallback `INTERNAL_API_KEY`) live in `/etc/openvibe/tools.env`
++`OPEN_CHARGE_MAP_KEY`, `YT_COOKIES_FILE`) live in `/etc/openvibe/tools.env`
(0600), by name only.
## Deploy
ddiff --git a/apps/_shared/internal-auth.js b/apps/_shared/internal-auth.js
ddeleted file mode 100644
iindex 7d918f8..0000000
---- a/apps/_shared/internal-auth.js
++++ /dev/null
@@@ -1,25 +0,0 @@
--'use strict';
--// Internal-only routes (analytics totals for the Network's admin and ranking).
--// Two independent conditions, both required:
--// 1. X-Internal-Key equals INTERNAL_API_KEY from the unit's environment (never a value in the repo;
--// with no key configured every internal route is closed).
--// 2. The request did not come through the public proxy: nginx always adds X-Forwarded-For and
--// X-Real-IP, service-to-service calls on loopback never do.
--const crypto = require('crypto');
--
--/** The request came straight from loopback, not through the public proxy (nginx adds the headers). */
--function loopbackOnly(req) {
-- if (req.headers['x-forwarded-for'] || req.headers['x-real-ip'] || req.headers['cf-connecting-ip']) return false;
-- const ip = String(req.socket && req.socket.remoteAddress || '');
-- return /^(::1|::ffff:127\.|127\.)/.test(ip);
--}
--
--function internalOk(req) {
-- const want = String(process.env.INTERNAL_API_KEY || process.env.OV_INTERNAL_KEY || '');
-- const got = String(req.headers['x-internal-key'] || '');
-- if (want.length < 16 || got.length !== want.length) return false;
-- if (!loopbackOnly(req)) return false;
-- return crypto.timingSafeEqual(Buffer.from(got), Buffer.from(want));
--}
--
--module.exports = { internalOk, loopbackOnly };
ddiff --git a/apps/_shared/internal-token.js b/apps/_shared/internal-token.js
iindex 07a02e9..9280d45 100644
---- a/apps/_shared/internal-token.js
++++ b/apps/_shared/internal-token.js
@@@ -1,20 +1,24 @@
'use strict';
// ═══════════════════════════════════════════════════════════════
// Internal analytics access (GET /api/internal/analytics[/bots]), shared by the gateway and every
--// satellite. Replaces the X-Internal-Key alone with a token-or-key check (contracts v0.79.0,
--// capability tools.analytics.read, ADR-014 service tokens) while the key is retired.
--// Two ways in, both loopback-only (./internal-auth.js refuses anything that came through the proxy):
--// • Authorization: Bearer <Network service token>: aud openvibe.tools, cap tools.analytics.read,
--// never a sandbox token. A request that presents a Bearer is judged on the token alone and is
--// never downgraded to the key: 401 for a bad or missing token, 403 without the capability.
--// • X-Internal-Key, exactly as before, when no Bearer is presented (the key goes in a later step).
--// → { ok: true, via: 'token'|'key', bearer, claims } | { ok: false, status, body }
--// The gateway forwards `bearer` to the satellites when the caller used a token (same audience), and
--// keeps forwarding the key when the caller did not.
++// satellite. The X-Internal-Key is retired (plan T2): a Network service token alone opens the route
++// (contracts v0.79.0, capability tools.analytics.read, ADR-014 service tokens).
++// One way in, loopback-only (loopbackOnly below refuses anything that came through the proxy):
++// Authorization: Bearer <Network service token>: aud openvibe.tools, cap tools.analytics.read,
++// never a sandbox token. 401 for a bad or malformed token, 403 without the capability, and 401
++// token.missing when no Bearer is presented at all.
++// → { ok: true, via: 'token', bearer, claims } | { ok: false, status, body }
++// The gateway forwards the caller's Bearer to the satellites unchanged (same audience).
// ═══════════════════════════════════════════════════════════════
--const { internalOk, loopbackOnly } = require('./internal-auth');
const tokens = require('./guard/tokens');
++/** The request came straight from loopback, not through the public proxy (nginx adds the headers). */
++function loopbackOnly(req) {
++ if (req.headers['x-forwarded-for'] || req.headers['x-real-ip'] || req.headers['cf-connecting-ip']) return false;
++ const ip = String(req.socket && req.socket.remoteAddress || '');
++ return /^(::1|::ffff:127\.|127\.)/.test(ip);
++}
++
const ANALYTICS_CAPABILITY = 'tools.analytics.read';
const authHeader = (req) => String((req.headers && req.headers.authorization) || '');
@@@ -32,21 +36,18 @@ const authHeader = (req) => String((req.headers && req.headers.authorization) ||
function checkAccess(req, o = {}) {
if (!loopbackOnly(req)) return { ok: false, status: 404, body: { error: 'Not found' } };
const header = authHeader(req);
-- if (header) {
-- const m = /^Bearer\s+(\S+)$/i.exec(header.trim());
-- if (!m) return { ok: false, status: 401, body: { error: 'Unauthorized', code: 'token.malformed' } };
-- const publicKey = o.getPublicKey ? o.getPublicKey() : (o.publicKey || null);
-- const r = tokens.verifyServiceToken(m[1], {
-- publicKey, issuer: o.issuer, audience: o.audience || tokens.AUDIENCE, acceptSandbox: false, contracts: o.contracts || null,
-- });
-- if (!r.ok) return { ok: false, status: 401, body: { error: 'Unauthorized', code: r.code } };
-- const cap = o.capability || ANALYTICS_CAPABILITY;
-- const denied = tokens.capabilityDenied(r.claims, cap, o.contracts || null);
-- if (denied) return { ok: false, status: 403, body: { error: 'Forbidden', code: denied, detail: `${cap} not granted` } };
-- return { ok: true, via: 'token', bearer: header, claims: r.claims };
-- }
-- if (internalOk(req)) return { ok: true, via: 'key', bearer: null, claims: null };
-- return { ok: false, status: 404, body: { error: 'Not found' } };
++ if (!header) return { ok: false, status: 401, body: { error: 'Unauthorized', code: 'token.missing' } };
++ const m = /^Bearer\s+(\S+)$/i.exec(header.trim());
++ if (!m) return { ok: false, status: 401, body: { error: 'Unauthorized', code: 'token.malformed' } };
++ const publicKey = o.getPublicKey ? o.getPublicKey() : (o.publicKey || null);
++ const r = tokens.verifyServiceToken(m[1], {
++ publicKey, issuer: o.issuer, audience: o.audience || tokens.AUDIENCE, acceptSandbox: false, contracts: o.contracts || null,
++ });
++ if (!r.ok) return { ok: false, status: 401, body: { error: 'Unauthorized', code: r.code } };
++ const cap = o.capability || ANALYTICS_CAPABILITY;
++ const denied = tokens.capabilityDenied(r.claims, cap, o.contracts || null);
++ if (denied) return { ok: false, status: 403, body: { error: 'Forbidden', code: denied, detail: `${cap} not granted` } };
++ return { ok: true, via: 'token', bearer: header, claims: r.claims };
}
/**
ddiff --git a/apps/gateway/.env.example b/apps/gateway/.env.example
iindex e338c90..469d52c 100644
---- a/apps/gateway/.env.example
++++ b/apps/gateway/.env.example
@@@ -27,9 +27,6 @@ OV_OAUTH_REDIRECT_URI=http://localhost:4001/auth/callback
MEDIA_URL=http://127.0.0.1:4100
MEDIA_PUBLIC_URL=https://openvibe.media
--# Internal API key for Network /internal/* calls (if ever needed)
--INTERNAL_API_KEY=change-me-to-a-random-64-char-string
--
# Net tools (optional). With an ipinfo.io token, IP lookups use ipinfo over HTTPS instead of ip-api.com's
# free tier (plain HTTP). Answers are cached either way. The Globalping token is read but no tool uses it yet.
#NET_IPINFO_TOKEN=
ddiff --git a/apps/gateway/server/index.js b/apps/gateway/server/index.js
iindex 2ddef3b..6732901 100644
---- a/apps/gateway/server/index.js
++++ b/apps/gateway/server/index.js
@@@ -403,15 +403,15 @@ function sendTool(req, res, file) {
// ── Internal analytics (Network admin + ranking) ─────────────
// The gateway keeps no analytics of its own; it adds up the satellites'. Loopback only, with a Network
--// service token (capability tools.analytics.read) or the internal key (apps/_shared/internal-token.js).
++// service token (capability tools.analytics.read, apps/_shared/internal-token.js).
{
const { requireInternalAccess } = require('../../_shared/internal-token');
-- // After the token-or-key check, before the fan-out: per-actor limit (actorLimits.admin, 30 a minute).
++ // After the token check, before the fan-out: per-actor limit (actorLimits.admin, 30 a minute).
app.get('/api/internal/analytics', requireInternalAccess({ keys: guard.keys, issuer: guard.issuer, audience: guard.audience, contracts: require('openvibe-contracts') }), actorLimits.admin, async (req, res) => {
const days = Math.min(parseInt(req.query.days, 10) || 30, 365);
const hours = req.query.hours ? Math.min(parseInt(req.query.hours, 10), 8760) : null;
-- // The same proof the caller used goes to the satellites: the Bearer with a token, the key otherwise.
-- const forward = req.ovInternal.via === 'token' ? { Authorization: req.ovInternal.bearer } : { 'X-Internal-Key': String(req.headers['x-internal-key']) };
++ // The caller's Bearer is what the satellites get (the same audience, no key any more).
++ const forward = { Authorization: req.ovInternal.bearer };
const parts = await Promise.all(Object.entries(SATELLITES).map(async ([name, port]) => {
try {
const r = await fetch(`http://127.0.0.1:${port}/api/internal/analytics?days=${days}${hours ? '&hours=' + hours : ''}`, { headers: forward, signal: AbortSignal.timeout(5000) });
ddiff --git a/apps/gateway/test/internal-analytics.test.js b/apps/gateway/test/internal-analytics.test.js
iindex 43cb534..cc1c15a 100644
---- a/apps/gateway/test/internal-analytics.test.js
++++ b/apps/gateway/test/internal-analytics.test.js
@@@ -1,10 +1,9 @@
'use strict';
// The internal analytics route on the real gateway (apps/_shared/internal-token.js, contracts v0.79.0):
--// a Network service token with tools.analytics.read (aud openvibe.tools) opens it, a token without the
--// capability is 403, a wrong audience or a sandbox token is 401, a bad Bearer beside the right key is
--// still 401 (never downgraded to the key), the key alone still works, a proxied request is refused, and
--// the incoming Bearer is forwarded unchanged to the satellites while the key stays forwarded for a key
--// caller. A stub satellite records what the gateway sends.
++// a Network service token with tools.analytics.read (aud openvibe.tools) opens it and the Bearer is
++// forwarded unchanged to all seven satellites, a token without the capability is 403, a wrong audience
++// or a sandbox token is 401, the retired X-Internal-Key alone is 401 (token.missing), a bad Bearer beside
++// the key is still 401, and a proxied request is refused. A stub satellite records what is sent.
const assert = require('assert');
const crypto = require('crypto');
const fs = require('fs');
@@@ -68,26 +67,26 @@ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-gw-internal-'));
// 4. A sandbox token is 401.
assert.strictEqual((await get({ Authorization: `Bearer ${sandbox}` })).status, 401);
-- // 5. A Bearer request is judged on the token alone: the right key beside it does not help.
++ // 5. A bad Bearer is 401 whatever else is sent beside it (the retired key does not help).
assert.strictEqual((await get({ Authorization: 'Bearer not-a-jwt', 'X-Internal-Key': KEY })).status, 401);
-- assert.strictEqual((await get({ Authorization: `Bearer ${noCap}`, 'X-Internal-Key': KEY })).status, 403);
// 6. A proxied request is refused with key or token (loopback only, as before).
assert.strictEqual((await get({ 'X-Internal-Key': KEY, 'X-Forwarded-For': '203.0.113.9' })).status, 404);
assert.strictEqual((await get({ Authorization: `Bearer ${good}`, 'X-Forwarded-For': '203.0.113.9' })).status, 404);
-- // 7. The key alone still works, and the key is what the gateway forwards.
++ // 7. The retired X-Internal-Key alone is 401 token.missing, with or without the env var set.
seen.length = 0;
r = await get({ 'X-Internal-Key': KEY });
body = await r.json();
-- assert.strictEqual(r.status, 200, JSON.stringify(body));
-- assert.strictEqual(fanout().length, 7);
-- assert.ok(fanout().every(s => s.key === KEY && s.authorization === null), 'the key is forwarded when the caller used the key');
++ assert.strictEqual(r.status, 401, JSON.stringify(body));
++ assert.strictEqual(body.code, 'token.missing');
++ assert.strictEqual(fanout().length, 0, 'nothing is fanned out without a token');
-- // 8. No credentials is 404, as before.
-- assert.strictEqual((await get()).status, 404);
++ // 8. No credentials is 401 too.
++ r = await get();
++ assert.strictEqual(r.status, 401, JSON.stringify(await r.json()));
-- console.log('gateway internal analytics: token (tools.analytics.read) opens and is forwarded, 403 without it, 401 for wrong audience/sandbox/bad Bearer, key alone still works, proxied refused');
++ console.log('gateway internal analytics: token (tools.analytics.read) opens and is forwarded, 403 without it, 401 for wrong audience/sandbox/bad Bearer, the key alone is 401, proxied refused');
} finally {
if (gw) await gw.kill();
await new Promise(r => stub.close(r));
ddiff --git a/apps/gateway/test/security-secrets.test.js b/apps/gateway/test/security-secrets.test.js
iindex 4e84c37..2102980 100644
---- a/apps/gateway/test/security-secrets.test.js
++++ b/apps/gateway/test/security-secrets.test.js
@@@ -1,7 +1,9 @@
'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
++ * first check is static: the retired internal key (X-Internal-Key, plan T2) is read nowhere in the
++ * serving code. Then the
++ * gateway starts with a sentinel as its Network OAuth client secret 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
@@@ -17,8 +19,6 @@ 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'];
@@@ -34,6 +34,30 @@ const client = () => { seq++; return { 'x-forwarded-for': `198.18.${(seq >> 8) &
});
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 stripComments = (src) => src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:'"`])\/\/.*$/gm, '$1');
++ const RETIRED_KEY = /\bINTERNAL_API_KEY\b|\bOV_INTERNAL_KEY\b|x-internal-key/i;
++ await check('no server file or _shared helper reads the retired internal key', () => {
++ const roots = [path.join(__dirname, '..', '..'), path.join(__dirname, '..', '..', '_shared')];
++ const files = [];
++ const walk = (dir) => {
++ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
++ const full = path.join(dir, e.name);
++ if (e.isDirectory()) { if (e.name === 'node_modules' || e.name === 'test') continue; walk(full); }
++ else if (/\.(js|cjs|mjs|ts)$/.test(e.name)) files.push(full);
++ }
++ };
++ for (const root of roots) {
++ for (const e of fs.readdirSync(root, { withFileTypes: true })) {
++ const full = path.join(root, e.name);
++ if (!e.isDirectory() || e.name !== 'server') continue;
++ for (const f of fs.readdirSync(full)) if (/\.js$/.test(f)) files.push(path.join(full, f));
++ }
++ }
++ walk(roots[1]);
++ assert.ok(files.length > 10, `found ${files.length} files to scan`);
++ const hits = files.filter((f) => RETIRED_KEY.test(stripComments(fs.readFileSync(f, 'utf8'))));
++ assert.deepStrictEqual(hits.map((f) => path.relative(path.join(__dirname, '..', '..'), f)), []);
++ });
const get = async (p, opts = {}) => {
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), 15000);
ddiff --git a/apps/img/test/internal-analytics.test.js b/apps/img/test/internal-analytics.test.js
iindex 781fe6e..5b6cac5 100644
---- a/apps/img/test/internal-analytics.test.js
++++ b/apps/img/test/internal-analytics.test.js
@@@ -2,8 +2,8 @@
// The internal analytics routes on a satellite (apps/_shared/internal-token.js): the image service
// accepts a Network service token with tools.analytics.read (aud openvibe.tools) on /analytics and
// /bots, refuses a token without the capability (403), a wrong audience or a sandbox token (401), a
--// bad Bearer beside the right key (401, never downgraded to the key), a proxied request (404), and
--// still accepts the internal key alone (404 with nothing, as before).
++// bad Bearer beside the retired key (401), a proxied request (404), and the retired key alone or
++// nothing at all (401 token.missing).
const assert = require('assert');
const crypto = require('crypto');
const fs = require('fs');
@@@ -33,7 +33,7 @@ const data = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-img-internal-'));
// A token with the capability passes both routes.
for (const p of ['/api/internal/analytics', '/api/internal/analytics/bots']) {
-- const r = await get(p, { Authorization: `Bearer ${good}` });
++ let r = await get(p, { Authorization: `Bearer ${good}` });
const body = await r.json();
assert.strictEqual(r.status, 200, `${p}: ${JSON.stringify(body)}`);
assert.ok(body.ok, p);
@@@ -42,17 +42,20 @@ const data = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-img-internal-'));
// Another audience, and a sandbox token: 401.
assert.strictEqual((await get(p, { Authorization: `Bearer ${wrongAud}` })).status, 401, p);
assert.strictEqual((await get(p, { Authorization: `Bearer ${sandbox}` })).status, 401, p);
-- // A Bearer request is judged on the token alone: the key beside a bad Bearer does not help.
++ // A bad Bearer is 401 whatever else is sent beside it (the retired key does not help).
assert.strictEqual((await get(p, { Authorization: 'Bearer not-a-jwt', 'X-Internal-Key': KEY })).status, 401, p);
-- // The key alone still passes.
-- assert.strictEqual((await get(p, { 'X-Internal-Key': KEY })).status, 200, p);
++ // The retired key alone is 401 token.missing, and so is no credentials at all.
++ r = await get(p, { 'X-Internal-Key': KEY });
++ assert.strictEqual(r.status, 401, p);
++ assert.strictEqual((await r.json()).code, 'token.missing', p);
++ r = await get(p);
++ assert.strictEqual(r.status, 401, p);
++ assert.strictEqual((await r.json()).code, 'token.missing', p);
// A proxied request is refused with key or token.
assert.strictEqual((await get(p, { 'X-Internal-Key': KEY, 'X-Forwarded-For': '203.0.113.9' })).status, 404, p);
assert.strictEqual((await get(p, { Authorization: `Bearer ${good}`, 'X-Forwarded-For': '203.0.113.9' })).status, 404, p);
-- // No credentials: 404, as before.
-- assert.strictEqual((await get(p)).status, 404, p);
}
-- console.log('img internal analytics: token (tools.analytics.read) passes, 403 without it, 401 wrong audience/sandbox/bad Bearer, key alone passes, proxied refused');
++ console.log('img internal analytics: token (tools.analytics.read) passes, 403 without it, 401 wrong audience/sandbox/bad Bearer, the retired key alone is 401, proxied refused');
} finally {
if (app) await app.kill();
await net.close();
ddiff --git a/apps/maps/test/security-secrets.test.js b/apps/maps/test/security-secrets.test.js
iindex d1e6f52..aa9a344 100644
---- a/apps/maps/test/security-secrets.test.js
++++ b/apps/maps/test/security-secrets.test.js
@@@ -15,8 +15,6 @@ 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