openvibers/openvibe.events Commits
Document subscription secret bounds and pin them with a test (#9)
Changes
@@@ -104,7 +104,7 @@ POST /api/v1/subscriptions→ 201 { "id": "sub_…", "secret": "whsec_…", … } # the secret is shown once```--Topic patterns are dot-separated segments where `*` stands for one or more whole segments (`media.vod.*`, `*.created`, `media.*.ready`, `*`). Endpoints must be `http(s)` on `127.0.0.1` or `openvibe.<tld>`/its subdomains (`EVENTS_ENDPOINT_HOSTS`); redirects are never followed.++Topic patterns are dot-separated segments where `*` stands for one or more whole segments (`media.vod.*`, `*.created`, `media.*.ready`, `*`). Endpoints must be `http(s)` on `127.0.0.1` or `openvibe.<tld>`/its subdomains (`EVENTS_ENDPOINT_HOSTS`); redirects are never followed. A caller may supply its own `secret` (a string of 32..256 characters; the same bound on `POST /api/v1/subscriptions/:id/rotate-secret`), otherwise Events generates a `whsec_…` one; either way it is returned once, on creation, and the consumer needs it to verify the v2 signature. The consumer is the calling service, and re-creating a subscription it already holds for the same `topic_pattern` and `endpoint` answers `409 events.subscription_exists` with the existing `subscription_id`, so an ensure-at-boot script re-runs safely.Each delivery is `POST <endpoint>` with body `{ "event": <envelope>, "seq": n }` and headers `X-OpenVibe-Event-Id`, `X-OpenVibe-Event-Type`, `X-OpenVibe-Seq`, `X-OpenVibe-Subscription-Id`, `X-OpenVibe-Delivery-Attempt`, `X-OpenVibe-Timestamp: <unix seconds>`, `X-OpenVibe-Signature-V2: t=<that timestamp>,v2=<HMAC-SHA256 of "<t>.<raw body>">` and `traceparent` (the event's trace). During secret rotation, the v2 header also carries a second `v2=` signature made with the previous secret. Any 2xx is delivered. Anything else is retried after 1 s, 5 s, 30 s, 2 min, 10 min, 1 h (then hourly) up to 8 attempts, after which the delivery is `dead`. Priority classes go first (`critical`, `important`, `low`, then seq); one delivery per subscription is in flight at a time and at most 20 overall. Delivery is at least once and not strictly ordered; consumers dedupe with an inbox and order with `subject.revision`.@@@ -74,6 +74,43 @@ t('subscription API: create, list own, secret shown once', async () => {await stub.close();});++t('subscription secret: supplied 32..256 kept, a duplicate 409 names the existing id', async () => {++ const stub = await subscriber();++ const secret = `whsec_${'ab'.repeat(32)}`; // 70 characters++ const created = await subscribe(media, { topic_pattern: 'live.secret.*', endpoint: stub.url, secret });++ assert.strictEqual(created.status, 201, created.text);++ assert.strictEqual(created.body.secret, secret, 'the supplied secret is kept and returned once');++ const dup = await subscribe(media, { topic_pattern: 'live.secret.*', endpoint: stub.url, secret });++ assert.strictEqual(dup.status, 409);++ assert.strictEqual(dup.body.code, 'events.subscription_exists');++ assert.match(dup.body.subscription_id, /^sub_/, 'the 409 carries the existing id');++ assert.strictEqual(dup.body.subscription_id, created.body.id);++++ // The bound is 32..256 (server/api/subscriptions.js); refuse it here, not with a late delivery failure.++ const bounds = [++ ['boundshort', 'x'.repeat(31), 422],++ ['boundlong', 'x'.repeat(257), 422],++ ['bound32', 'x'.repeat(32), 201],++ ['bound256', 'x'.repeat(256), 201],++ ];++ const made = [];++ for (const [service, value, status] of bounds) {++ const token = serviceToken(service, ['events.subscription.manage']);++ const res = await subscribe(token, { topic_pattern: 'live.bound.*', endpoint: stub.url, secret: value });++ assert.strictEqual(res.status, status, `${service}: ${res.text}`);++ if (status === 422) {++ assert.strictEqual(res.body.code, 'events.bad_request');++ assert.match(res.body.detail, /32\.\.256/);++ } else {++ assert.strictEqual(res.body.secret, value);++ made.push([token, res.body.id]);++ }++ }++ await request(h.base, 'POST', `/api/v1/subscriptions/${created.body.id}/disable`, { token: media });++ for (const [token, id] of made) await request(h.base, 'POST', `/api/v1/subscriptions/${id}/disable`, { token });++ await stub.close();++});++t('SSRF: only http(s) endpoints on 127.0.0.1 or *.openvibe.* are accepted', async () => {const bad = ['http://10.0.0.5/hook', 'http://169.254.169.254/latest/meta-data', 'http://localhost:4000/x', 'http://[::1]:4000/x',
@@ -104,7 +104,7 @@ POST /api/v1/subscriptions
@@ -104,7 +104,7 @@ POST /api/v1/subscriptions
→ 201 { "id": "sub_…", "secret": "whsec_…", … } # the secret is shown once
→ 201 { "id": "sub_…", "secret": "whsec_…", … } # the secret is shown once
```
```
-Topic patterns are dot-separated segments where `*` stands for one or more whole segments (`media.vod.*`, `*.created`, `media.*.ready`, `*`). Endpoints must be `http(s)` on `127.0.0.1` or `openvibe.<tld>`/its subdomains (`EVENTS_ENDPOINT_HOSTS`); redirects are never followed.
+Topic patterns are dot-separated segments where `*` stands for one or more whole segments (`media.vod.*`, `*.created`, `media.*.ready`, `*`). Endpoints must be `http(s)` on `127.0.0.1` or `openvibe.<tld>`/its subdomains (`EVENTS_ENDPOINT_HOSTS`); redirects are never followed. A caller may supply its own `secret` (a string of 32..256 characters; the same bound on `POST /api/v1/subscriptions/:id/rotate-secret`), otherwise Events generates a `whsec_…` one; either way it is returned once, on creation, and the consumer needs it to verify the v2 signature. The consumer is the calling service, and re-creating a subscription it already holds for the same `topic_pattern` and `endpoint` answers `409 events.subscription_exists` with the existing `subscription_id`, so an ensure-at-boot script re-runs safely.
Each delivery is `POST <endpoint>` with body `{ "event": <envelope>, "seq": n }` and headers `X-OpenVibe-Event-Id`, `X-OpenVibe-Event-Type`, `X-OpenVibe-Seq`, `X-OpenVibe-Subscription-Id`, `X-OpenVibe-Delivery-Attempt`, `X-OpenVibe-Timestamp: <unix seconds>`, `X-OpenVibe-Signature-V2: t=<that timestamp>,v2=<HMAC-SHA256 of "<t>.<raw body>">` and `traceparent` (the event's trace). During secret rotation, the v2 header also carries a second `v2=` signature made with the previous secret. Any 2xx is delivered. Anything else is retried after 1 s, 5 s, 30 s, 2 min, 10 min, 1 h (then hourly) up to 8 attempts, after which the delivery is `dead`. Priority classes go first (`critical`, `important`, `low`, then seq); one delivery per subscription is in flight at a time and at most 20 overall. Delivery is at least once and not strictly ordered; consumers dedupe with an inbox and order with `subject.revision`.
Each delivery is `POST <endpoint>` with body `{ "event": <envelope>, "seq": n }` and headers `X-OpenVibe-Event-Id`, `X-OpenVibe-Event-Type`, `X-OpenVibe-Seq`, `X-OpenVibe-Subscription-Id`, `X-OpenVibe-Delivery-Attempt`, `X-OpenVibe-Timestamp: <unix seconds>`, `X-OpenVibe-Signature-V2: t=<that timestamp>,v2=<HMAC-SHA256 of "<t>.<raw body>">` and `traceparent` (the event's trace). During secret rotation, the v2 header also carries a second `v2=` signature made with the previous secret. Any 2xx is delivered. Anything else is retried after 1 s, 5 s, 30 s, 2 min, 10 min, 1 h (then hourly) up to 8 attempts, after which the delivery is `dead`. Priority classes go first (`critical`, `important`, `low`, then seq); one delivery per subscription is in flight at a time and at most 20 overall. Delivery is at least once and not strictly ordered; consumers dedupe with an inbox and order with `subject.revision`.
@@ -74,6 +74,43 @@ t('subscription API: create, list own, secret shown once', async () => {
@@ -74,6 +74,43 @@ t('subscription API: create, list own, secret shown once', async () => {
await stub.close();
await stub.close();
});
});
+t('subscription secret: supplied 32..256 kept, a duplicate 409 names the existing id', async () => {
+ const stub = await subscriber();
+ const secret = `whsec_${'ab'.repeat(32)}`; // 70 characters
+ const created = await subscribe(media, { topic_pattern: 'live.secret.*', endpoint: stub.url, secret });
+ assert.strictEqual(created.status, 201, created.text);
+ assert.strictEqual(created.body.secret, secret, 'the supplied secret is kept and returned once');
+ const dup = await subscribe(media, { topic_pattern: 'live.secret.*', endpoint: stub.url, secret });
+ assert.strictEqual(dup.status, 409);
+ assert.strictEqual(dup.body.code, 'events.subscription_exists');
+ assert.match(dup.body.subscription_id, /^sub_/, 'the 409 carries the existing id');
+ assert.strictEqual(dup.body.subscription_id, created.body.id);
+
+ // The bound is 32..256 (server/api/subscriptions.js); refuse it here, not with a late delivery failure.
+ const bounds = [
+ ['boundshort', 'x'.repeat(31), 422],
+ ['boundlong', 'x'.repeat(257), 422],
+ ['bound32', 'x'.repeat(32), 201],
+ ['bound256', 'x'.repeat(256), 201],
+ ];
+ const made = [];
+ for (const [service, value, status] of bounds) {
+ const token = serviceToken(service, ['events.subscription.manage']);
+ const res = await subscribe(token, { topic_pattern: 'live.bound.*', endpoint: stub.url, secret: value });
+ assert.strictEqual(res.status, status, `${service}: ${res.text}`);
+ if (status === 422) {
+ assert.strictEqual(res.body.code, 'events.bad_request');
+ assert.match(res.body.detail, /32\.\.256/);
+ } else {
+ assert.strictEqual(res.body.secret, value);
+ made.push([token, res.body.id]);
+ }
+ }
+ await request(h.base, 'POST', `/api/v1/subscriptions/${created.body.id}/disable`, { token: media });
+ for (const [token, id] of made) await request(h.base, 'POST', `/api/v1/subscriptions/${id}/disable`, { token });
+ await stub.close();
+});
+
t('SSRF: only http(s) endpoints on 127.0.0.1 or *.openvibe.* are accepted', async () => {
t('SSRF: only http(s) endpoints on 127.0.0.1 or *.openvibe.* are accepted', async () => {
const bad = [
const bad = [
'http://10.0.0.5/hook', 'http://169.254.169.254/latest/meta-data', 'http://localhost:4000/x', 'http://[::1]:4000/x',
'http://10.0.0.5/hook', 'http://169.254.169.254/latest/meta-data', 'http://localhost:4000/x', 'http://[::1]:4000/x',