openvibers/openvibe.reviews Commits

Events webhook requires signature v2 (replay window); openvibe-sdk v0.4.0

OP OpenVibers committed 23 Sept 2026 · 3151c05d61 · 5 files, +20 −11

POST /internal/events calls parseDelivery(..., { requireV2: true }) for each configured secret:
a delivery is accepted only when X-OpenVibe-Signature-V2 (HMAC of "<t>.<raw body>") verifies
and t is within 300 s of this clock. A v1-only delivery (v2 stripped from a capture) or a stale
v2 is 401 reviews.bad_signature.

Tests: the delivery helper sends signDeliveryHeaders; new v1-only and stale-v2 (301 s) refusals.

Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex 57f5df0..0920dcb 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -16,7 +16,7 @@
"express-rate-limit": "^7.5.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.20.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.2.1",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0"
},
"engines": {
@@@ -1044,9 +1044,9 @@
}
},
"node_modules/openvibe-sdk": {
-- "version": "0.2.2",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2",
-- "integrity": "sha512-kDifUp3EcmnTWzElAmaCZnX659WErcnm4+qVEoAiYQWX/w/1p4TWvoHbUPsrX65TSROVR3tiyVkl801n/0yyCw==",
++ "version": "0.4.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0",
++ "integrity": "sha512-Hyz55YOMIs0xwCQ1NXsN0sUYhlbKXAX3uWY2fPCdEEL18FXUYVhK8wlG/5JMMypFHnjDiSd9VHzBNG12hWsmkQ==",
"license": "MIT",
"engines": {
"node": ">=20"
ddiff --git a/package.json b/package.json
iindex 291498d..0a75c47 100644
---- a/package.json
++++ b/package.json
@@@ -21,7 +21,7 @@
"express-rate-limit": "^7.5.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.20.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.2.1",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0"
},
"engines": {
ddiff --git a/server/http/consumer.js b/server/http/consumer.js
iindex 882b1a1..6132fd3 100644
---- a/server/http/consumer.js
++++ b/server/http/consumer.js
@@@ -45,7 +45,8 @@ function consumerRouter({ db, svc, sync, config, log = console }) {
if (!secrets.length) return http.sendProblem(res, 503, 'reviews.webhook_disabled', { detail: 'REVIEWS_EVENTS_SECRET is not set', ctx: req.ov });
const raw = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
let delivery = null;
-- for (const s of secrets) { delivery = parseDelivery(raw, req.headers, s); if (delivery) break; }
++ // Signature v2 only: HMAC over "<t>.<raw body>" with t within ±300 s; a v1-only (v2 stripped) or stale delivery is refused.
++ for (const s of secrets) { delivery = parseDelivery(raw, req.headers, s, { requireV2: true }); if (delivery) break; }
if (!delivery) return http.sendProblem(res, 401, 'reviews.bad_signature', { detail: 'X-OpenVibe-Signature does not verify', ctx: req.ov });
const event = delivery.event;
if (!event || typeof event.event_id !== 'string' || !/^evt_[0-9A-HJKMNP-TV-Z]{26}$/.test(event.event_id)) {
ddiff --git a/test/helpers.js b/test/helpers.js
iindex eb169f1..3812fc9 100644
---- a/test/helpers.js
++++ b/test/helpers.js
@@@ -10,7 +10,7 @@ const fs = require('fs');
const os = require('os');
const path = require('path');
const { serviceAuth, ids } = require('openvibe-contracts');
--const { signDelivery } = require('openvibe-sdk/events');
++const { signDelivery, signDeliveryHeaders } = require('openvibe-sdk/events');
const { load } = require('../server/config');
const { start } = require('../server/index');
@@@ -209,10 +209,14 @@ async function createEntity(h, body) {
return r.json.entity;
}
--/** A signed delivery from OpenVibe.Events to POST /internal/events. */
--async function deliver(h, envelope, { secret = WEBHOOK_SECRET } = {}) {
++/**
++ * A signed delivery from OpenVibe.Events to POST /internal/events (v1 and v2 headers, as Events
++ * sends it). `v1Only` sends only X-OpenVibe-Signature; `now` (ms) backdates the v2 timestamp.
++ */
++async function deliver(h, envelope, { secret = WEBHOOK_SECRET, v1Only = false, now } = {}) {
const raw = JSON.stringify({ event: envelope, seq: 1 });
-- return req(h, 'POST', '/internal/events', { body: raw, headers: { 'X-OpenVibe-Signature': signDelivery(raw, secret) } });
++ const headers = v1Only ? { 'X-OpenVibe-Signature': signDelivery(raw, secret) } : signDeliveryHeaders(raw, secret, { now });
++ return req(h, 'POST', '/internal/events', { body: raw, headers });
}
function sourcesEvent(type, item, extra = {}) {
ddiff --git a/test/signals.test.js b/test/signals.test.js
iindex 49640f7..42b2a9b 100644
---- a/test/signals.test.js
++++ b/test/signals.test.js
@@@ -194,7 +194,7 @@ t('every produced event is a valid events.event-envelope@1', async () => {
} finally { await h.stop(); }
});
--t('import needs the capability; a bad webhook signature is refused', async () => {
++t('import needs the capability; a bad, v1-only or stale webhook signature is refused', async () => {
const h = await boot();
try {
const it = h.sources.put(steamItem());
@@@ -203,6 +203,10 @@ t('import needs the capability; a bad webhook signature is refused', async () =>
assert.strictEqual(denied.json.code, 'capability.denied');
const bad = await deliver(h, sourcesEvent('sources.item.created', it), { secret: 'wrong' });
assert.strictEqual(bad.status, 401);
++ const v1only = await deliver(h, sourcesEvent('sources.item.created', it), { v1Only: true });
++ assert.strictEqual(v1only.status, 401, 'v1 only (no v2 header): refused');
++ const stale = await deliver(h, sourcesEvent('sources.item.created', it), { now: Date.now() - 301000 });
++ assert.strictEqual(stale.status, 401, 'stale v2 (outside the 300 s window): refused');
const other = await deliver(h, { ...sourcesEvent('sources.item.created', it), source: 'news' });
assert.strictEqual(other.json.outcome, 'ignored:source');
} finally { await h.stop(); }