openvibers/openvibe.network Commits
Internal API: /INTERNAL/... no longer bypasses the loopback-only nginx rule; key compared in constant time
Changes
@@@ -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@@@ -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' });
@@ -31,10 +31,18 @@ function countLegacyKey(req, res) {
@@ -31,10 +31,18 @@ function countLegacyKey(req, res) {
} catch { /* telemetry is best effort */ }
} 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) {
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 key = req.headers['x-internal-key'];
const config = getConfig(req);
const config = getConfig(req);
- if (key && key === config.internalKey) {
+ if (key && sameKey(key, config.internalKey)) {
req.internalKeyOk = true;
req.internalKeyOk = true;
// Retirement telemetry (roadmap Wave 22): count every legacy-key call per route, so the key can
// 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
// be removed route by route once nothing uses it. Capability-guarded routes also record their
@@ -64,6 +64,14 @@ const sign = (sub, opts = {}) => jwt.sign({ sub, id: sub, username: 'u' }, keys.
@@ -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');
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 });
r = await post('/internal/verify-token', { token: sign(7) }, { 'x-internal-key': config.internalKey });
assert.strictEqual(r.body.valid, true);
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) ──────────────────
// ── Revoked tokens (password changed after iat) ──────────────────
const old = sign(8, { expiresIn: '1h' });
const old = sign(8, { expiresIn: '1h' });