openvibers/openvibe.network Commits

Internal API: /INTERNAL/... no longer bypasses the loopback-only nginx rule; key compared in constant time

OP OpenVibers committed 23 Sept 2026 · a4d715c56d · 2 files, +17 −1

Exploit: nginx restricts `location /internal/` to loopback, but that match is case-sensitive while
Express mounts are not, so https://openvibe.network/INTERNAL/issue-token (or any other spelling)
reached the internal router from the internet through `location /`, leaving the X-Internal-Key as
the only barrier in front of routes like /internal/issue-token (a session for any user id). The
internal router now answers only its exact /internal spelling (404 otherwise), and the key is
compared with timingSafeEqual instead of ===.

Changes

ddiff --git a/server/internal/routes.js b/server/internal/routes.js
iindex d7a2c3df..060daa7d 100644
---- a/server/internal/routes.js
++++ b/server/internal/routes.js
@@@ -31,10 +31,18 @@ function countLegacyKey(req, res) {
} catch { /* telemetry is best effort */ }
});
}
++function sameKey(a, b) {
++ const x = Buffer.from(String(a || ''));
++ const y = Buffer.from(String(b || ''));
++ return x.length === y.length && x.length > 0 && require('crypto').timingSafeEqual(x, y);
++}
function requireInternalKey(req, res, next) {
++ // Express mounts are case-insensitive, nginx locations are not: /INTERNAL/... would skip the
++ // proxy's loopback-only `location /internal/` rule. Only the exact spelling is served.
++ if (req.baseUrl !== '/internal') return res.status(404).json({ error: 'Not found' });
const key = req.headers['x-internal-key'];
const config = getConfig(req);
-- if (key && key === config.internalKey) {
++ if (key && sameKey(key, config.internalKey)) {
req.internalKeyOk = true;
// Retirement telemetry (roadmap Wave 22): count every legacy-key call per route, so the key can
// be removed route by route once nothing uses it. Capability-guarded routes also record their
ddiff --git a/test/security-session.test.js b/test/security-session.test.js
iindex d15f344b..f84c2a32 100644
---- a/test/security-session.test.js
++++ b/test/security-session.test.js
@@@ -64,6 +64,14 @@ const sign = (sub, opts = {}) => jwt.sign({ sub, id: sub, username: 'u' }, keys.
assert.strictEqual(r.body.valid, false, 'internal verify-token does not call a FedCM assertion a user token');
r = await post('/internal/verify-token', { token: sign(7) }, { 'x-internal-key': config.internalKey });
assert.strictEqual(r.body.valid, true);
++ // nginx only lets loopback reach `location /internal/` (case-sensitive); Express mounts are not,
++ // so another spelling must not reach the internal router from outside.
++ r = await post('/INTERNAL/verify-token', { token: sign(7) }, { 'x-internal-key': config.internalKey });
++ assert.strictEqual(r.status, 404, '/INTERNAL/... is not the internal API');
++ r = await post('/Internal/verify-token', { token: sign(7) }, { 'x-internal-key': config.internalKey });
++ assert.strictEqual(r.status, 404);
++ r = await post('/internal/verify-token', { token: sign(7) }, { 'x-internal-key': 'k'.repeat(31) + 'x' });
++ assert.strictEqual(r.status, 403, 'wrong internal key');
// ── Revoked tokens (password changed after iat) ──────────────────
const old = sign(8, { expiresIn: '1h' });