openvibers/openvibe.events Commits
Developer apps cannot disrupt first-party event flow
Changes
@@@ -45,6 +45,8 @@ EVENTS_RECEIPT_RETENTION_DAYS=90#EVENTS_WORKER=on#EVENTS_WORKER_INTERVAL_MS=500#EVENTS_MAX_INFLIGHT=20++# Of which developer-app deliveries may use at most (default: half of EVENTS_MAX_INFLIGHT)++#EVENTS_APP_MAX_INFLIGHT=10#EVENTS_DELIVERY_TIMEOUT_MS=10000#EVENTS_MAX_ATTEMPTS=8#EVENTS_BACKOFF_MS=1000,5000,30000,120000,600000,3600000@@@ -50,7 +50,7 @@ Errors are RFC 9457 problem+json (`openvibe-contracts` `http.problem`) with a st- `source` must be the calling service (`svc:live` publishes `source: "live"`), and `event_type` must start with a prefix that source owns (`live.*`, `media.*`, `network.*`, `community.*`, `chat.*`, `openre.*`, `billing.*`, `tips.*`, `vip.*`, `ai.*`, `games.*`, `tools.*`, `codes.*`, `host.*`; `EVENTS_SOURCE_PREFIXES` overrides).- Idempotent on `event_id`: a repeat answers `200 { event_id, seq, duplicate: true }` and is never stored twice (even after retention, for `EVENTS_RECEIPT_RETENTION_DAYS`).- Missing `trace_id` is taken from the request's `traceparent`; `priority` defaults to `important`, `visibility` to `internal`.--- Loop guard: an event whose trace already carries 8 hops (the cross-service chain depth, or the same source/type/subject repeating in the trace) is refused with `409 events.loop_detected`.++- Loop guard: an event whose trace already carries 8 hops (the cross-service chain depth, or the same source/type/subject repeating in the trace) is refused with `409 events.loop_detected`. The chain depth of a first-party publish counts first-party events only (developer-app events in the same trace never count toward it), so an app cannot poison a trace it has seen.- Each accepted event gets a global `seq` and is committed with one delivery row per matching subscription before anything is sent.## Subscriptions and delivery@@@ -45,6 +45,7 @@ function load(env = process.env) {const nodeEnv = env.NODE_ENV || 'development';const isProduction = nodeEnv === 'production';const port = int(env.PORT, 4300);++ const maxInflight = int(env.EVENTS_MAX_INFLIGHT, 20);return {port,host: env.HOST || '127.0.0.1',@@@ -77,7 +78,9 @@ function load(env = process.env) {worker: {enabled: env.EVENTS_WORKER !== 'off',intervalMs: int(env.EVENTS_WORKER_INTERVAL_MS, 500),-- maxInflight: int(env.EVENTS_MAX_INFLIGHT, 20),++ maxInflight,++ // Developer-app deliveries share at most this many of the slots (default: half).++ maxAppInflight: Math.max(1, Math.min(maxInflight, int(env.EVENTS_APP_MAX_INFLIGHT, Math.floor(maxInflight / 2)))),timeoutMs: int(env.EVENTS_DELIVERY_TIMEOUT_MS, 10000),maxAttempts: int(env.EVENTS_MAX_ATTEMPTS, 8),// Wait after attempt n fails = backoffMs[n-1] (the last value repeats).@@@ -119,14 +119,15 @@ async function checkAppEndpoint(endpoint, { lookup } = {}) {* POST to an app endpoint through the guard. Returns { status } or throws (err.permanent = true* when the endpoint itself is refused). `requestImpl`/`isAllowed` exist for tests only.*/--function createGuardedPost({ lookup = dns.lookup, requestImpl = https.request, isAllowed = isPublicAddress, maxResponseBytes = 64 * 1024 } = {}) {++function createGuardedPost({ lookup = dns.lookup, requestImpl = https.request, isAllowed = isPublicAddress } = {}) {return function guardedPost(endpoint, { headers, body, timeoutMs = 10000 }) {const p = parseAppEndpoint(endpoint);if (!p.ok) return Promise.reject(Object.assign(new Error(`endpoint not allowed: ${p.reason}`), { permanent: true }));if (p.literal && !isAllowed(p.host)) return Promise.reject(Object.assign(new Error('endpoint not allowed: not a public address'), { permanent: true }));return new Promise((resolve, reject) => {let settled = false;-- const done = (fn, v) => { if (!settled) { settled = true; fn(v); } };++ let deadline = null;++ const done = (fn, v) => { if (!settled) { settled = true; clearTimeout(deadline); fn(v); } };// The socket's own lookup: the address we connect to is the one we checked.const guardedLookup = (hostname, opts, cb) => {if (typeof opts === 'function') { cb = opts; opts = {}; }@@@ -150,13 +151,16 @@ function createGuardedPost({ lookup = dns.lookup, requestImpl = https.request, iservername: p.literal ? undefined : p.host,timeout: timeoutMs,}, (res) => {-- let n = 0;-- res.on('data', (c) => { n += c.length; if (n > maxResponseBytes) res.destroy(); });-- res.on('end', () => done(resolve, { status: res.statusCode }));-- res.on('close', () => done(resolve, { status: res.statusCode }));-- res.on('error', () => done(resolve, { status: res.statusCode }));++ // The status is all a delivery needs: settle now and drop the body, so an endpoint++ // that drips its response cannot keep this attempt (and its worker slot) open.++ done(resolve, { status: res.statusCode });++ res.destroy();});-- req.on('timeout', () => { req.destroy(Object.assign(new Error('timeout'), { name: 'TimeoutError' })); });++ const timeout = () => { req.destroy(Object.assign(new Error('timeout'), { name: 'TimeoutError' })); };++ // `timeout` above is only the socket's idle timer; this bounds the whole attempt, however++ // slowly the endpoint trickles its status line and headers.++ deadline = setTimeout(timeout, timeoutMs);++ req.on('timeout', timeout);req.on('error', (err) => {if (err.code === 'EOV_NOT_PUBLIC' || (err.cause && err.cause.code === 'EOV_NOT_PUBLIC')) err.permanent = true;done(reject, err);@@@ -193,7 +193,12 @@ function createStore(db, { clock = { now: () => Date.now() }, maxHops = 8 } = {}nextSeq: db.prepare("UPDATE sequences SET value = value + 1 WHERE name = 'events' RETURNING value"),lastSeq: db.prepare("SELECT value FROM sequences WHERE name = 'events'"),minSeq: db.prepare('SELECT MIN(seq) AS s FROM events'),-- chainHops: db.prepare('SELECT MAX(hops) AS h FROM events WHERE trace_id = ? AND source != ?'),++ // Hop depth counts only rows the publisher's own tenancy can have caused: a first-party++ // publish ignores app events (an app that saw a public trace_id cannot poison that trace), an++ // app publish counts first-party rows plus its own project and environment.++ chainHops: db.prepare('SELECT MAX(hops) AS h FROM events WHERE trace_id = ? AND source != ? AND project_id IS NULL'),++ chainHopsApp: db.prepare(`SELECT MAX(hops) AS h FROM events WHERE trace_id = ? AND source != ?++ AND (project_id IS NULL OR (project_id = ? AND env = ?))`),selfRepeats: db.prepare(`SELECT COUNT(*) AS n FROM events WHERE trace_id = ? AND source = ? AND event_type = ?AND subject_type = ? AND subject_id = ? AND (request_id IS NULL OR request_id != ?)`),insertEvent: db.prepare(`INSERT INTO events (id, seq, event_type, version, source, actor, subject_type, subject_id,@@@ -252,7 +257,10 @@ function createStore(db, { clock = { now: () => Date.now() }, maxHops = 8 } = {}}// Loop guard: depth of the cross-service chain in this trace, and how often this exact// (source, type, subject) already happened in the trace from other publish calls.-- const chain = (q.chainHops.get(env.trace_id, env.source).h || 0) + 1;++ const chainRow = project++ ? q.chainHopsApp.get(env.trace_id, env.source, project.projectId, project.env)++ : q.chainHops.get(env.trace_id, env.source);++ const chain = (chainRow.h || 0) + 1;const repeats = q.selfRepeats.get(env.trace_id, env.source, env.event_type, env.subject.type, env.subject.id, requestId || '').n + 1;const hops = Math.max(chain, repeats);if (hops > maxHops) {@@@ -416,12 +424,14 @@ function createStore(db, { clock = { now: () => Date.now() }, maxHops = 8 } = {}* Next due delivery per subscription (at most one each, so per-subscription concurrency stays 1),* ordered by priority class, then seq. `busy` are subscription ids with a delivery in flight.*/-- function dueDeliveries(now, limit, busy = []) {++ function dueDeliveries(now, limit, busy = [], { firstPartyOnly = false } = {}) {++ // app_project_id: set for developer-app subscriptions (the worker caps their share of slots).const rows = db.prepare(`SELECT * FROM (-- SELECT d.*, ROW_NUMBER() OVER (PARTITION BY d.subscription_id ORDER BY d.priority, d.seq) AS rn++ SELECT d.*, s.project_id AS app_project_id, ROW_NUMBER() OVER (PARTITION BY d.subscription_id ORDER BY d.priority, d.seq) AS rnFROM deliveries d JOIN subscriptions s ON s.id = d.subscription_idWHERE d.status IN ('pending', 'failed') AND d.next_attempt_at <= ? AND s.enabled = 1++ ${firstPartyOnly ? 'AND s.project_id IS NULL' : ''}) WHERE rn = 1 ORDER BY priority, seq LIMIT ?`).all(now, limit + busy.length);const skip = new Set(busy);return rows.filter(r => !skip.has(r.subscription_id)).slice(0, limit);@@@ -14,7 +14,8 @@* address, redirects refused. A refused address is permanent (dead at once; replay requeues it).** Scheduling: a setTimeout chain (never overlapping ticks); priority classes critical > important >-- * low, then seq; one delivery in flight per subscription; at most maxInflight overall.++ * low, then seq; one delivery in flight per subscription; at most maxInflight overall, of which at++ * most maxAppInflight to developer-app endpoints (so apps can never hold every slot).*/const crypto = require('crypto');const { sign } = require('../lib/client');@@@ -27,6 +28,7 @@ function createWorker({ store, config, clock = { now: () => Date.now() }, fetchIconst opts = config.worker;const busy = new Set(); // subscription ids with a delivery in flightconst inflight = new Set(); // promises++ let appInflight = 0; // of which to developer-app endpoints (at most opts.maxAppInflight)let timer = null;let running = false;let lastTickAt = null;@@@ -103,19 +105,36 @@ function createWorker({ store, config, clock = { now: () => Date.now() }, fetchIlastTickAt = Date.now();const room = opts.maxInflight - inflight.size;if (room <= 0) return 0;-- const due = store.dueDeliveries(clock.now(), room, [...busy]);-- for (const d of due) {-- busy.add(d.subscription_id);-- const p = send(d)-- .catch(err => log.error(`[worker] delivery ${d.event_id} -> ${d.subscription_id} crashed: ${err.stack || err}`))-- .finally(() => {-- busy.delete(d.subscription_id);-- inflight.delete(p);-- if (running) schedule(0);-- });-- inflight.add(p);-- }-- return due.length;++ let started = 0;++ let skippedApp = false;++ const startAll = (due) => {++ for (const d of due) {++ if (started >= room) return;++ const isApp = Boolean(d.app_project_id);++ if (isApp && appInflight >= opts.maxAppInflight) { skippedApp = true; continue; }++ start(d, isApp);++ started++;++ }++ };++ startAll(store.dueDeliveries(clock.now(), room, [...busy], { firstPartyOnly: appInflight >= opts.maxAppInflight }));++ // App deliveries (which can outrank first-party ones by priority) filled the app share: the++ // rest of the room goes to first-party subscriptions.++ if (skippedApp && started < room) startAll(store.dueDeliveries(clock.now(), room - started, [...busy], { firstPartyOnly: true }));++ return started;++ }++++ function start(d, isApp) {++ busy.add(d.subscription_id);++ if (isApp) appInflight++;++ const p = send(d)++ .catch(err => log.error(`[worker] delivery ${d.event_id} -> ${d.subscription_id} crashed: ${err.stack || err}`))++ .finally(() => {++ busy.delete(d.subscription_id);++ if (isApp) appInflight--;++ inflight.delete(p);++ if (running) schedule(0);++ });++ inflight.add(p);}function schedule(ms) {nnew file mode 100644@@@ -0,0 +1,136 @@++'use strict';++// Developer apps must not be able to hurt first-party event flow: the loop guard's hop count is++// kept per tenancy (an app cannot poison a first-party trace), and app deliveries cannot hold every++// delivery slot (bounded time per attempt, a capped share of the in-flight slots).++const assert = require('assert');++const net = require('net');++const nodeHttp = require('http');++const { ids } = require('openvibe-contracts');++const apps = require('../server/apps');++const { createGuardedPost, isPublicAddress } = require('../server/egress');++const { boot, request, serviceToken, appToken, envelope, subscriber, suite, sleep } = require('./helpers');++++const t = suite('apps-abuse');++const ALL = ['events.app.publish', 'events.app.read', 'events.app.subscribe'];++const prj = `prj_${ids.ulid()}`;++const app = ids.newId('app');++const key = apps.projectKey(prj);++const tok = () => appToken({ appId: app, projectId: prj, env: 'sandbox', cap: ALL });++const appEvent = (over = {}) => envelope('x', {++ source: apps.appSource(app), event_type: `app.${key}.poke`, actor: { type: 'app', id: app }, subject: { type: 'thing', id: '1' }, ...over,++});++++t('loop guard: an app cannot poison a first-party trace it has seen', async () => {++ const h = await boot({ env: { EVENTS_APP_SANDBOX_PUBLISH_PER_MINUTE: '0' } });++ try {++ const live = serviceToken('live', ['events.event.publish']);++ const media = serviceToken('media', ['events.event.publish']);++ const trace = 'ab'.repeat(16);++ // A public first-party event: every app can read it, trace_id included.++ let r = await request(h.base, 'POST', '/api/v1/events', { token: live, body: envelope('live', { trace_id: trace, visibility: 'public' }) });++ assert.strictEqual(r.status, 201, r.text);++ // The app republishes into that trace until its own hop count reaches the maximum (8).++ for (let i = 0; i < 8; i++) {++ r = await request(h.base, 'POST', '/api/v1/events', { token: tok(), body: appEvent({ trace_id: trace }) });++ assert.strictEqual(r.status, 201, r.text);++ }++ // A first-party consumer reacting to the live event in the same trace must still publish.++ r = await request(h.base, 'POST', '/api/v1/events', {++ token: media, body: envelope('media', { event_type: 'media.recording.started', trace_id: trace }),++ });++ assert.strictEqual(r.status, 201, `first-party publish refused: ${r.text}`);++ // And a first-party loop is still caught.++ let last;++ for (let i = 0; i < 9; i++) {++ last = await request(h.base, 'POST', '/api/v1/events', {++ token: media, body: envelope('media', { event_type: 'media.recording.started', trace_id: trace }),++ });++ }++ assert.strictEqual(last.status, 409); assert.strictEqual(last.body.code, 'events.loop_detected');++ } finally {++ await h.stop();++ }++});++++// ── Slow endpoints ──++function fakeLookup(host, opts, cb) {++ if (typeof opts === 'function') { cb = opts; opts = {}; }++ return opts.all ? cb(null, [{ address: '127.0.0.1', family: 4 }]) : cb(null, '127.0.0.1', 4);++}++++/** A raw TCP server that answers slowly: 'headers' drips header lines, 'body' drips the body. */++async function dripServer(mode) {++ const sockets = new Set();++ const server = net.createServer((sock) => {++ sockets.add(sock);++ sock.on('error', () => {});++ sock.once('data', () => {++ if (mode === 'headers') sock.write('HTTP/1.1 200 OK\r\n');++ else sock.write('HTTP/1.1 200 OK\r\nContent-Length: 100000\r\n\r\n');++ const timer = setInterval(() => { if (!sock.destroyed) sock.write(mode === 'headers' ? 'X-Drip: 1\r\n' : 'x'); }, 50);++ sock.on('close', () => { clearInterval(timer); sockets.delete(sock); });++ });++ });++ await new Promise(r => server.listen(0, '127.0.0.1', r));++ return {++ port: server.address().port,++ close: () => new Promise(r => { for (const s of sockets) s.destroy(); server.close(() => r()); }),++ };++}++++t('egress: a slow endpoint cannot hold a delivery past its timeout', async () => {++ for (const mode of ['headers', 'body']) {++ const srv = await dripServer(mode);++ try {++ const post = createGuardedPost({++ lookup: fakeLookup,++ requestImpl: (o, cb) => nodeHttp.request({ ...o, port: srv.port }, cb),++ isAllowed: (ip) => ip === '127.0.0.1' || isPublicAddress(ip),++ });++ const started = Date.now();++ const outcome = await Promise.race([++ post('https://hooks.example.com/x', { headers: {}, body: '{}', timeoutMs: 300 }).then(v => ({ v }), e => ({ e })),++ sleep(2000).then(() => ({ hung: true })),++ ]);++ assert.ok(!outcome.hung, `${mode}: the delivery was still in flight after 2 s (timeout 300 ms)`);++ assert.ok(Date.now() - started < 1500, mode);++ } finally {++ await srv.close();++ }++ }++});++++t('worker: app deliveries never take every in-flight slot', async () => {++ const pending = [];++ const appPost = () => new Promise((resolve) => pending.push(resolve)); // an app endpoint that never answers++ const h = await boot({++ appPost, dnsLookup: (host, opts, cb) => cb(null, [{ address: '93.184.216.34', family: 4 }]),++ env: { EVENTS_MAX_INFLIGHT: '2', EVENTS_APP_SANDBOX_PUBLISH_PER_MINUTE: '0', EVENTS_APP_SANDBOX_MAX_SUBSCRIPTIONS: '0' },++ });++ const firstParty = await subscriber();++ try {++ for (const p of ['a', 'b', 'c']) {++ const r = await request(h.base, 'POST', '/api/v1/subscriptions', { token: tok(), body: { topic_pattern: `app.${key}.*`, endpoint: `https://hooks.example.com/${p}` } });++ assert.strictEqual(r.status, 201, r.text);++ }++ const s = await request(h.base, 'POST', '/api/v1/subscriptions', { token: serviceToken('chat', ['events.subscription.manage']), body: { topic_pattern: 'live.*', endpoint: firstParty.url } });++ assert.strictEqual(s.status, 201, s.text);++ // Critical app events are queued before the first-party one.++ for (let i = 0; i < 3; i++) {++ const r = await request(h.base, 'POST', '/api/v1/events', { token: tok(), body: appEvent({ priority: 'critical' }) });++ assert.strictEqual(r.status, 201, r.text);++ }++ const r = await request(h.base, 'POST', '/api/v1/events', { token: serviceToken('live', ['events.event.publish']), body: envelope('live') });++ assert.strictEqual(r.status, 201);++ h.worker.dispatch();++ for (let i = 0; i < 40 && !firstParty.calls.length; i++) await sleep(25);++ assert.strictEqual(firstParty.calls.length, 1, 'the first-party delivery got a slot');++ assert.ok(pending.length >= 1, 'app deliveries still run');++ } finally {++ for (const resolve of pending) resolve({ status: 204 });++ await firstParty.close();++ await h.stop();++ }++});++++t.run();
@@ -45,6 +45,8 @@ EVENTS_RECEIPT_RETENTION_DAYS=90
@@ -45,6 +45,8 @@ EVENTS_RECEIPT_RETENTION_DAYS=90
#EVENTS_WORKER=on
#EVENTS_WORKER=on
#EVENTS_WORKER_INTERVAL_MS=500
#EVENTS_WORKER_INTERVAL_MS=500
#EVENTS_MAX_INFLIGHT=20
#EVENTS_MAX_INFLIGHT=20
+# Of which developer-app deliveries may use at most (default: half of EVENTS_MAX_INFLIGHT)
+#EVENTS_APP_MAX_INFLIGHT=10
#EVENTS_DELIVERY_TIMEOUT_MS=10000
#EVENTS_DELIVERY_TIMEOUT_MS=10000
#EVENTS_MAX_ATTEMPTS=8
#EVENTS_MAX_ATTEMPTS=8
#EVENTS_BACKOFF_MS=1000,5000,30000,120000,600000,3600000
#EVENTS_BACKOFF_MS=1000,5000,30000,120000,600000,3600000
@@ -50,7 +50,7 @@ Errors are RFC 9457 problem+json (`openvibe-contracts` `http.problem`) with a st
@@ -50,7 +50,7 @@ Errors are RFC 9457 problem+json (`openvibe-contracts` `http.problem`) with a st
- `source` must be the calling service (`svc:live` publishes `source: "live"`), and `event_type` must start with a prefix that source owns (`live.*`, `media.*`, `network.*`, `community.*`, `chat.*`, `openre.*`, `billing.*`, `tips.*`, `vip.*`, `ai.*`, `games.*`, `tools.*`, `codes.*`, `host.*`; `EVENTS_SOURCE_PREFIXES` overrides).
- `source` must be the calling service (`svc:live` publishes `source: "live"`), and `event_type` must start with a prefix that source owns (`live.*`, `media.*`, `network.*`, `community.*`, `chat.*`, `openre.*`, `billing.*`, `tips.*`, `vip.*`, `ai.*`, `games.*`, `tools.*`, `codes.*`, `host.*`; `EVENTS_SOURCE_PREFIXES` overrides).
- Idempotent on `event_id`: a repeat answers `200 { event_id, seq, duplicate: true }` and is never stored twice (even after retention, for `EVENTS_RECEIPT_RETENTION_DAYS`).
- Idempotent on `event_id`: a repeat answers `200 { event_id, seq, duplicate: true }` and is never stored twice (even after retention, for `EVENTS_RECEIPT_RETENTION_DAYS`).
- Missing `trace_id` is taken from the request's `traceparent`; `priority` defaults to `important`, `visibility` to `internal`.
- Missing `trace_id` is taken from the request's `traceparent`; `priority` defaults to `important`, `visibility` to `internal`.
-- Loop guard: an event whose trace already carries 8 hops (the cross-service chain depth, or the same source/type/subject repeating in the trace) is refused with `409 events.loop_detected`.
+- Loop guard: an event whose trace already carries 8 hops (the cross-service chain depth, or the same source/type/subject repeating in the trace) is refused with `409 events.loop_detected`. The chain depth of a first-party publish counts first-party events only (developer-app events in the same trace never count toward it), so an app cannot poison a trace it has seen.
- Each accepted event gets a global `seq` and is committed with one delivery row per matching subscription before anything is sent.
- Each accepted event gets a global `seq` and is committed with one delivery row per matching subscription before anything is sent.
## Subscriptions and delivery
## Subscriptions and delivery
@@ -45,6 +45,7 @@ function load(env = process.env) {
@@ -45,6 +45,7 @@ function load(env = process.env) {
const nodeEnv = env.NODE_ENV || 'development';
const nodeEnv = env.NODE_ENV || 'development';
const isProduction = nodeEnv === 'production';
const isProduction = nodeEnv === 'production';
const port = int(env.PORT, 4300);
const port = int(env.PORT, 4300);
+ const maxInflight = int(env.EVENTS_MAX_INFLIGHT, 20);
return {
return {
port,
port,
host: env.HOST || '127.0.0.1',
host: env.HOST || '127.0.0.1',
@@ -77,7 +78,9 @@ function load(env = process.env) {
@@ -77,7 +78,9 @@ function load(env = process.env) {
worker: {
worker: {
enabled: env.EVENTS_WORKER !== 'off',
enabled: env.EVENTS_WORKER !== 'off',
intervalMs: int(env.EVENTS_WORKER_INTERVAL_MS, 500),
intervalMs: int(env.EVENTS_WORKER_INTERVAL_MS, 500),
- maxInflight: int(env.EVENTS_MAX_INFLIGHT, 20),
+ maxInflight,
+ // Developer-app deliveries share at most this many of the slots (default: half).
+ maxAppInflight: Math.max(1, Math.min(maxInflight, int(env.EVENTS_APP_MAX_INFLIGHT, Math.floor(maxInflight / 2)))),
timeoutMs: int(env.EVENTS_DELIVERY_TIMEOUT_MS, 10000),
timeoutMs: int(env.EVENTS_DELIVERY_TIMEOUT_MS, 10000),
maxAttempts: int(env.EVENTS_MAX_ATTEMPTS, 8),
maxAttempts: int(env.EVENTS_MAX_ATTEMPTS, 8),
// Wait after attempt n fails = backoffMs[n-1] (the last value repeats).
// Wait after attempt n fails = backoffMs[n-1] (the last value repeats).
@@ -119,14 +119,15 @@ async function checkAppEndpoint(endpoint, { lookup } = {}) {
@@ -119,14 +119,15 @@ async function checkAppEndpoint(endpoint, { lookup } = {}) {
* POST to an app endpoint through the guard. Returns { status } or throws (err.permanent = true
* POST to an app endpoint through the guard. Returns { status } or throws (err.permanent = true
* when the endpoint itself is refused). `requestImpl`/`isAllowed` exist for tests only.
* when the endpoint itself is refused). `requestImpl`/`isAllowed` exist for tests only.
*/
*/
-function createGuardedPost({ lookup = dns.lookup, requestImpl = https.request, isAllowed = isPublicAddress, maxResponseBytes = 64 * 1024 } = {}) {
+function createGuardedPost({ lookup = dns.lookup, requestImpl = https.request, isAllowed = isPublicAddress } = {}) {
return function guardedPost(endpoint, { headers, body, timeoutMs = 10000 }) {
return function guardedPost(endpoint, { headers, body, timeoutMs = 10000 }) {
const p = parseAppEndpoint(endpoint);
const p = parseAppEndpoint(endpoint);
if (!p.ok) return Promise.reject(Object.assign(new Error(`endpoint not allowed: ${p.reason}`), { permanent: true }));
if (!p.ok) return Promise.reject(Object.assign(new Error(`endpoint not allowed: ${p.reason}`), { permanent: true }));
if (p.literal && !isAllowed(p.host)) return Promise.reject(Object.assign(new Error('endpoint not allowed: not a public address'), { permanent: true }));
if (p.literal && !isAllowed(p.host)) return Promise.reject(Object.assign(new Error('endpoint not allowed: not a public address'), { permanent: true }));
return new Promise((resolve, reject) => {
return new Promise((resolve, reject) => {
let settled = false;
let settled = false;
- const done = (fn, v) => { if (!settled) { settled = true; fn(v); } };
+ let deadline = null;
+ const done = (fn, v) => { if (!settled) { settled = true; clearTimeout(deadline); fn(v); } };
// The socket's own lookup: the address we connect to is the one we checked.
// The socket's own lookup: the address we connect to is the one we checked.
const guardedLookup = (hostname, opts, cb) => {
const guardedLookup = (hostname, opts, cb) => {
if (typeof opts === 'function') { cb = opts; opts = {}; }
if (typeof opts === 'function') { cb = opts; opts = {}; }
@@ -150,13 +151,16 @@ function createGuardedPost({ lookup = dns.lookup, requestImpl = https.request, i
@@ -150,13 +151,16 @@ function createGuardedPost({ lookup = dns.lookup, requestImpl = https.request, i
servername: p.literal ? undefined : p.host,
servername: p.literal ? undefined : p.host,
timeout: timeoutMs,
timeout: timeoutMs,
}, (res) => {
}, (res) => {
- let n = 0;
- res.on('data', (c) => { n += c.length; if (n > maxResponseBytes) res.destroy(); });
- res.on('end', () => done(resolve, { status: res.statusCode }));
- res.on('close', () => done(resolve, { status: res.statusCode }));
- res.on('error', () => done(resolve, { status: res.statusCode }));
+ // The status is all a delivery needs: settle now and drop the body, so an endpoint
+ // that drips its response cannot keep this attempt (and its worker slot) open.
+ done(resolve, { status: res.statusCode });
+ res.destroy();
});
});
- req.on('timeout', () => { req.destroy(Object.assign(new Error('timeout'), { name: 'TimeoutError' })); });
+ const timeout = () => { req.destroy(Object.assign(new Error('timeout'), { name: 'TimeoutError' })); };
+ // `timeout` above is only the socket's idle timer; this bounds the whole attempt, however
+ // slowly the endpoint trickles its status line and headers.
+ deadline = setTimeout(timeout, timeoutMs);
+ req.on('timeout', timeout);
req.on('error', (err) => {
req.on('error', (err) => {
if (err.code === 'EOV_NOT_PUBLIC' || (err.cause && err.cause.code === 'EOV_NOT_PUBLIC')) err.permanent = true;
if (err.code === 'EOV_NOT_PUBLIC' || (err.cause && err.cause.code === 'EOV_NOT_PUBLIC')) err.permanent = true;
done(reject, err);
done(reject, err);
@@ -193,7 +193,12 @@ function createStore(db, { clock = { now: () => Date.now() }, maxHops = 8 } = {}
@@ -193,7 +193,12 @@ function createStore(db, { clock = { now: () => Date.now() }, maxHops = 8 } = {}
nextSeq: db.prepare("UPDATE sequences SET value = value + 1 WHERE name = 'events' RETURNING value"),
nextSeq: db.prepare("UPDATE sequences SET value = value + 1 WHERE name = 'events' RETURNING value"),
lastSeq: db.prepare("SELECT value FROM sequences WHERE name = 'events'"),
lastSeq: db.prepare("SELECT value FROM sequences WHERE name = 'events'"),
minSeq: db.prepare('SELECT MIN(seq) AS s FROM events'),
minSeq: db.prepare('SELECT MIN(seq) AS s FROM events'),
- chainHops: db.prepare('SELECT MAX(hops) AS h FROM events WHERE trace_id = ? AND source != ?'),
+ // Hop depth counts only rows the publisher's own tenancy can have caused: a first-party
+ // publish ignores app events (an app that saw a public trace_id cannot poison that trace), an
+ // app publish counts first-party rows plus its own project and environment.
+ chainHops: db.prepare('SELECT MAX(hops) AS h FROM events WHERE trace_id = ? AND source != ? AND project_id IS NULL'),
+ chainHopsApp: db.prepare(`SELECT MAX(hops) AS h FROM events WHERE trace_id = ? AND source != ?
+ AND (project_id IS NULL OR (project_id = ? AND env = ?))`),
selfRepeats: db.prepare(`SELECT COUNT(*) AS n FROM events WHERE trace_id = ? AND source = ? AND event_type = ?
selfRepeats: db.prepare(`SELECT COUNT(*) AS n FROM events WHERE trace_id = ? AND source = ? AND event_type = ?
AND subject_type = ? AND subject_id = ? AND (request_id IS NULL OR request_id != ?)`),
AND subject_type = ? AND subject_id = ? AND (request_id IS NULL OR request_id != ?)`),
insertEvent: db.prepare(`INSERT INTO events (id, seq, event_type, version, source, actor, subject_type, subject_id,
insertEvent: db.prepare(`INSERT INTO events (id, seq, event_type, version, source, actor, subject_type, subject_id,
@@ -252,7 +257,10 @@ function createStore(db, { clock = { now: () => Date.now() }, maxHops = 8 } = {}
@@ -252,7 +257,10 @@ function createStore(db, { clock = { now: () => Date.now() }, maxHops = 8 } = {}
}
}
// Loop guard: depth of the cross-service chain in this trace, and how often this exact
// Loop guard: depth of the cross-service chain in this trace, and how often this exact
// (source, type, subject) already happened in the trace from other publish calls.
// (source, type, subject) already happened in the trace from other publish calls.
- const chain = (q.chainHops.get(env.trace_id, env.source).h || 0) + 1;
+ const chainRow = project
+ ? q.chainHopsApp.get(env.trace_id, env.source, project.projectId, project.env)
+ : q.chainHops.get(env.trace_id, env.source);
+ const chain = (chainRow.h || 0) + 1;
const repeats = q.selfRepeats.get(env.trace_id, env.source, env.event_type, env.subject.type, env.subject.id, requestId || '').n + 1;
const repeats = q.selfRepeats.get(env.trace_id, env.source, env.event_type, env.subject.type, env.subject.id, requestId || '').n + 1;
const hops = Math.max(chain, repeats);
const hops = Math.max(chain, repeats);
if (hops > maxHops) {
if (hops > maxHops) {
@@ -416,12 +424,14 @@ function createStore(db, { clock = { now: () => Date.now() }, maxHops = 8 } = {}
@@ -416,12 +424,14 @@ function createStore(db, { clock = { now: () => Date.now() }, maxHops = 8 } = {}
* Next due delivery per subscription (at most one each, so per-subscription concurrency stays 1),
* Next due delivery per subscription (at most one each, so per-subscription concurrency stays 1),
* ordered by priority class, then seq. `busy` are subscription ids with a delivery in flight.
* ordered by priority class, then seq. `busy` are subscription ids with a delivery in flight.
*/
*/
- function dueDeliveries(now, limit, busy = []) {
+ function dueDeliveries(now, limit, busy = [], { firstPartyOnly = false } = {}) {
+ // app_project_id: set for developer-app subscriptions (the worker caps their share of slots).
const rows = db.prepare(`
const rows = db.prepare(`
SELECT * FROM (
SELECT * FROM (
- SELECT d.*, ROW_NUMBER() OVER (PARTITION BY d.subscription_id ORDER BY d.priority, d.seq) AS rn
+ SELECT d.*, s.project_id AS app_project_id, ROW_NUMBER() OVER (PARTITION BY d.subscription_id ORDER BY d.priority, d.seq) AS rn
FROM deliveries d JOIN subscriptions s ON s.id = d.subscription_id
FROM deliveries d JOIN subscriptions s ON s.id = d.subscription_id
WHERE d.status IN ('pending', 'failed') AND d.next_attempt_at <= ? AND s.enabled = 1
WHERE d.status IN ('pending', 'failed') AND d.next_attempt_at <= ? AND s.enabled = 1
+ ${firstPartyOnly ? 'AND s.project_id IS NULL' : ''}
) WHERE rn = 1 ORDER BY priority, seq LIMIT ?`).all(now, limit + busy.length);
) WHERE rn = 1 ORDER BY priority, seq LIMIT ?`).all(now, limit + busy.length);
const skip = new Set(busy);
const skip = new Set(busy);
return rows.filter(r => !skip.has(r.subscription_id)).slice(0, limit);
return rows.filter(r => !skip.has(r.subscription_id)).slice(0, limit);
@@ -14,7 +14,8 @@
@@ -14,7 +14,8 @@
* address, redirects refused. A refused address is permanent (dead at once; replay requeues it).
* address, redirects refused. A refused address is permanent (dead at once; replay requeues it).
*
*
* Scheduling: a setTimeout chain (never overlapping ticks); priority classes critical > important >
* Scheduling: a setTimeout chain (never overlapping ticks); priority classes critical > important >
- * low, then seq; one delivery in flight per subscription; at most maxInflight overall.
+ * low, then seq; one delivery in flight per subscription; at most maxInflight overall, of which at
+ * most maxAppInflight to developer-app endpoints (so apps can never hold every slot).
*/
*/
const crypto = require('crypto');
const crypto = require('crypto');
const { sign } = require('../lib/client');
const { sign } = require('../lib/client');
@@ -27,6 +28,7 @@ function createWorker({ store, config, clock = { now: () => Date.now() }, fetchI
@@ -27,6 +28,7 @@ function createWorker({ store, config, clock = { now: () => Date.now() }, fetchI
const opts = config.worker;
const opts = config.worker;
const busy = new Set(); // subscription ids with a delivery in flight
const busy = new Set(); // subscription ids with a delivery in flight
const inflight = new Set(); // promises
const inflight = new Set(); // promises
+ let appInflight = 0; // of which to developer-app endpoints (at most opts.maxAppInflight)
let timer = null;
let timer = null;
let running = false;
let running = false;
let lastTickAt = null;
let lastTickAt = null;
@@ -103,19 +105,36 @@ function createWorker({ store, config, clock = { now: () => Date.now() }, fetchI
@@ -103,19 +105,36 @@ function createWorker({ store, config, clock = { now: () => Date.now() }, fetchI
lastTickAt = Date.now();
lastTickAt = Date.now();
const room = opts.maxInflight - inflight.size;
const room = opts.maxInflight - inflight.size;
if (room <= 0) return 0;
if (room <= 0) return 0;
- const due = store.dueDeliveries(clock.now(), room, [...busy]);
- for (const d of due) {
- busy.add(d.subscription_id);
- const p = send(d)
- .catch(err => log.error(`[worker] delivery ${d.event_id} -> ${d.subscription_id} crashed: ${err.stack || err}`))
- .finally(() => {
- busy.delete(d.subscription_id);
- inflight.delete(p);
- if (running) schedule(0);
- });
- inflight.add(p);
- }
- return due.length;
+ let started = 0;
+ let skippedApp = false;
+ const startAll = (due) => {
+ for (const d of due) {
+ if (started >= room) return;
+ const isApp = Boolean(d.app_project_id);
+ if (isApp && appInflight >= opts.maxAppInflight) { skippedApp = true; continue; }
+ start(d, isApp);
+ started++;
+ }
+ };
+ startAll(store.dueDeliveries(clock.now(), room, [...busy], { firstPartyOnly: appInflight >= opts.maxAppInflight }));
+ // App deliveries (which can outrank first-party ones by priority) filled the app share: the
+ // rest of the room goes to first-party subscriptions.
+ if (skippedApp && started < room) startAll(store.dueDeliveries(clock.now(), room - started, [...busy], { firstPartyOnly: true }));
+ return started;
+ }
+
+ function start(d, isApp) {
+ busy.add(d.subscription_id);
+ if (isApp) appInflight++;
+ const p = send(d)
+ .catch(err => log.error(`[worker] delivery ${d.event_id} -> ${d.subscription_id} crashed: ${err.stack || err}`))
+ .finally(() => {
+ busy.delete(d.subscription_id);
+ if (isApp) appInflight--;
+ inflight.delete(p);
+ if (running) schedule(0);
+ });
+ inflight.add(p);
}
}
function schedule(ms) {
function schedule(ms) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,136 @@
@@ -0,0 +1,136 @@
+'use strict';
+// Developer apps must not be able to hurt first-party event flow: the loop guard's hop count is
+// kept per tenancy (an app cannot poison a first-party trace), and app deliveries cannot hold every
+// delivery slot (bounded time per attempt, a capped share of the in-flight slots).
+const assert = require('assert');
+const net = require('net');
+const nodeHttp = require('http');
+const { ids } = require('openvibe-contracts');
+const apps = require('../server/apps');
+const { createGuardedPost, isPublicAddress } = require('../server/egress');
+const { boot, request, serviceToken, appToken, envelope, subscriber, suite, sleep } = require('./helpers');
+
+const t = suite('apps-abuse');
+const ALL = ['events.app.publish', 'events.app.read', 'events.app.subscribe'];
+const prj = `prj_${ids.ulid()}`;
+const app = ids.newId('app');
+const key = apps.projectKey(prj);
+const tok = () => appToken({ appId: app, projectId: prj, env: 'sandbox', cap: ALL });
+const appEvent = (over = {}) => envelope('x', {
+ source: apps.appSource(app), event_type: `app.${key}.poke`, actor: { type: 'app', id: app }, subject: { type: 'thing', id: '1' }, ...over,
+});
+
+t('loop guard: an app cannot poison a first-party trace it has seen', async () => {
+ const h = await boot({ env: { EVENTS_APP_SANDBOX_PUBLISH_PER_MINUTE: '0' } });
+ try {
+ const live = serviceToken('live', ['events.event.publish']);
+ const media = serviceToken('media', ['events.event.publish']);
+ const trace = 'ab'.repeat(16);
+ // A public first-party event: every app can read it, trace_id included.
+ let r = await request(h.base, 'POST', '/api/v1/events', { token: live, body: envelope('live', { trace_id: trace, visibility: 'public' }) });
+ assert.strictEqual(r.status, 201, r.text);
+ // The app republishes into that trace until its own hop count reaches the maximum (8).
+ for (let i = 0; i < 8; i++) {
+ r = await request(h.base, 'POST', '/api/v1/events', { token: tok(), body: appEvent({ trace_id: trace }) });
+ assert.strictEqual(r.status, 201, r.text);
+ }
+ // A first-party consumer reacting to the live event in the same trace must still publish.
+ r = await request(h.base, 'POST', '/api/v1/events', {
+ token: media, body: envelope('media', { event_type: 'media.recording.started', trace_id: trace }),
+ });
+ assert.strictEqual(r.status, 201, `first-party publish refused: ${r.text}`);
+ // And a first-party loop is still caught.
+ let last;
+ for (let i = 0; i < 9; i++) {
+ last = await request(h.base, 'POST', '/api/v1/events', {
+ token: media, body: envelope('media', { event_type: 'media.recording.started', trace_id: trace }),
+ });
+ }
+ assert.strictEqual(last.status, 409); assert.strictEqual(last.body.code, 'events.loop_detected');
+ } finally {
+ await h.stop();
+ }
+});
+
+// ── Slow endpoints ──
+function fakeLookup(host, opts, cb) {
+ if (typeof opts === 'function') { cb = opts; opts = {}; }
+ return opts.all ? cb(null, [{ address: '127.0.0.1', family: 4 }]) : cb(null, '127.0.0.1', 4);
+}
+
+/** A raw TCP server that answers slowly: 'headers' drips header lines, 'body' drips the body. */
+async function dripServer(mode) {
+ const sockets = new Set();
+ const server = net.createServer((sock) => {
+ sockets.add(sock);
+ sock.on('error', () => {});
+ sock.once('data', () => {
+ if (mode === 'headers') sock.write('HTTP/1.1 200 OK\r\n');
+ else sock.write('HTTP/1.1 200 OK\r\nContent-Length: 100000\r\n\r\n');
+ const timer = setInterval(() => { if (!sock.destroyed) sock.write(mode === 'headers' ? 'X-Drip: 1\r\n' : 'x'); }, 50);
+ sock.on('close', () => { clearInterval(timer); sockets.delete(sock); });
+ });
+ });
+ await new Promise(r => server.listen(0, '127.0.0.1', r));
+ return {
+ port: server.address().port,
+ close: () => new Promise(r => { for (const s of sockets) s.destroy(); server.close(() => r()); }),
+ };
+}
+
+t('egress: a slow endpoint cannot hold a delivery past its timeout', async () => {
+ for (const mode of ['headers', 'body']) {
+ const srv = await dripServer(mode);
+ try {
+ const post = createGuardedPost({
+ lookup: fakeLookup,
+ requestImpl: (o, cb) => nodeHttp.request({ ...o, port: srv.port }, cb),
+ isAllowed: (ip) => ip === '127.0.0.1' || isPublicAddress(ip),
+ });
+ const started = Date.now();
+ const outcome = await Promise.race([
+ post('https://hooks.example.com/x', { headers: {}, body: '{}', timeoutMs: 300 }).then(v => ({ v }), e => ({ e })),
+ sleep(2000).then(() => ({ hung: true })),
+ ]);
+ assert.ok(!outcome.hung, `${mode}: the delivery was still in flight after 2 s (timeout 300 ms)`);
+ assert.ok(Date.now() - started < 1500, mode);
+ } finally {
+ await srv.close();
+ }
+ }
+});
+
+t('worker: app deliveries never take every in-flight slot', async () => {
+ const pending = [];
+ const appPost = () => new Promise((resolve) => pending.push(resolve)); // an app endpoint that never answers
+ const h = await boot({
+ appPost, dnsLookup: (host, opts, cb) => cb(null, [{ address: '93.184.216.34', family: 4 }]),
+ env: { EVENTS_MAX_INFLIGHT: '2', EVENTS_APP_SANDBOX_PUBLISH_PER_MINUTE: '0', EVENTS_APP_SANDBOX_MAX_SUBSCRIPTIONS: '0' },
+ });
+ const firstParty = await subscriber();
+ try {
+ for (const p of ['a', 'b', 'c']) {
+ const r = await request(h.base, 'POST', '/api/v1/subscriptions', { token: tok(), body: { topic_pattern: `app.${key}.*`, endpoint: `https://hooks.example.com/${p}` } });
+ assert.strictEqual(r.status, 201, r.text);
+ }
+ const s = await request(h.base, 'POST', '/api/v1/subscriptions', { token: serviceToken('chat', ['events.subscription.manage']), body: { topic_pattern: 'live.*', endpoint: firstParty.url } });
+ assert.strictEqual(s.status, 201, s.text);
+ // Critical app events are queued before the first-party one.
+ for (let i = 0; i < 3; i++) {
+ const r = await request(h.base, 'POST', '/api/v1/events', { token: tok(), body: appEvent({ priority: 'critical' }) });
+ assert.strictEqual(r.status, 201, r.text);
+ }
+ const r = await request(h.base, 'POST', '/api/v1/events', { token: serviceToken('live', ['events.event.publish']), body: envelope('live') });
+ assert.strictEqual(r.status, 201);
+ h.worker.dispatch();
+ for (let i = 0; i < 40 && !firstParty.calls.length; i++) await sleep(25);
+ assert.strictEqual(firstParty.calls.length, 1, 'the first-party delivery got a slot');
+ assert.ok(pending.length >= 1, 'app deliveries still run');
+ } finally {
+ for (const resolve of pending) resolve({ status: 204 });
+ await firstParty.close();
+ await h.stop();
+ }
+});
+
+t.run();