openvibers/openvibe.billing Commits

Security: PowerChat site-routed refs only settle when paid to the site account; case-sensitive routing

OP OpenVibers committed 23 Sept 2026 · 627303c06d · 3 files, +101 −2

pcorder:, pcdon: and site-routed pcsub: refs ride in the buyer-editable fallback tip link
(?app_ref=...), so a creator could put one on a tip to their OWN PowerChat account (money
they keep) and Billing would mint spendable credit or cash-out-able creator payable for it.
Those refs now count only when the delivery's receiving account is POWERCHAT_SITE_USERNAME;
from any other account (or with the setting unset) they are held for review, nothing moves.

Express routed /API/v1/... to the service API while nginx denies only the lower-case
/api/v1 on the public host; routing is now case-sensitive.

test/security.test.js proves both.

Changes

ddiff --git a/server/app.js b/server/app.js
iindex 4d4cc77..f0ec385 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -39,6 +39,9 @@ function createApp(opts = {}) {
const app = express();
app.disable('x-powered-by');
++ // nginx matches `location ^~ /api/v1 { deny all; }` case-sensitively; Express would otherwise
++ // route /API/v1/… to the same API, walking past the public-host deny.
++ app.set('case sensitive routing', true);
app.set('trust proxy', config.trustProxy);
app.use(http.middleware());
app.use((req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); });
ddiff --git a/server/providers/powerchat.js b/server/providers/powerchat.js
iindex 2cc00f4..28ed8eb 100644
---- a/server/providers/powerchat.js
++++ b/server/providers/powerchat.js
@@@ -18,6 +18,8 @@
* site-routed tip to the streamer; direct → EXTERNAL tip, no effect.
* pcdon:<streamer>:<donor> site-routed tip for a creator → MONEY into their payable
* (legacy links carry Live user ids; they are resolved to subjects)
++ * (pcorder, site pcsub and pcdon count only when the tip was paid to POWERCHAT_SITE_USERNAME:
++ * the ref is buyer-editable in the fallback tip link; from any other account they are held)
* anything else a tip on a streamer's own PowerChat → EXTERNAL, no Billing effect
* (recorded as an interaction by OpenVibe.Tips).
* Test deliveries (isTest, or source manual_test) move no money and are ignored unless
@@@ -98,8 +100,21 @@ function createPowerchat(cfg, { network } = {}) {
const receiptRef = `powerchat:${paymentId}`;
const ref = String(data.appExternalRef || '');
const meta = { powerchat_event: paymentId, donor_name: data.donorName ? String(data.donorName).slice(0, 80) : null, app_ref: ref || null };
++ const host = String((envelope.streamer && envelope.streamer.username) || '').toLowerCase();
++ // Money only reaches OpenVibe when it was paid to the SITE account. app_ref rides in the
++ // buyer-editable fallback tip link (?app_ref=…), so a creator could put `pcorder:` / `pcdon:` /
++ // a site `pcsub:` ref on a tip to their OWN PowerChat (money they keep) and have Billing mint
++ // credit or cash-out-able payable for it. Site-routed refs from any other account are held.
++ const onSite = !!cfg.siteUsername && host === cfg.siteUsername;
++ const offSite = (what) => ({
++ effect: 'none', review: true,
++ reason: cfg.siteUsername
++ ? `${what} ${ref} was paid to PowerChat account "${host || 'unknown'}", not the site account — nothing credited, held for review`
++ : `${what} ${ref}: POWERCHAT_SITE_USERNAME is not set, so the receiving account cannot be verified — nothing credited, held for review`,
++ });
let m;
if ((m = ref.match(/^pcorder:(.+)$/))) {
++ if (!onSite) return offSite('purchase checkout');
const intent = intents.find(ctx.db, m[1]);
if (!intent || intent.kind !== 'purchase') return { effect: 'none', reason: `purchase checkout ${ref} has no purchase intent` };
if (cents < 1) return { effect: 'none', reason: 'zero-amount purchase' };
@@@ -109,6 +124,7 @@ function createPowerchat(cfg, { network } = {}) {
const intent = intents.find(ctx.db, m[1]);
if (!intent || intent.kind !== 'subscription' || !intent.streamer_subject) return { effect: 'none', reason: `subscription checkout ${ref} has no subscription intent` };
const route = intent.route === 'direct' ? 'direct' : 'site';
++ if (route === 'site' && !onSite) return offSite('site-routed subscription checkout');
if (cents + 1 < intent.amount_cents && intents.settledInLive(intent)) {
return { effect: 'none', reason: `underpaid delivery for ${ref}, which Live already credited before the cutover — review`, review: true };
}
@@@ -126,14 +142,14 @@ function createPowerchat(cfg, { network } = {}) {
};
}
if ((m = ref.match(/^pcdon:([^:]+):([^:]*)$/))) {
++ if (!onSite) return offSite('site-routed tip');
const to = await resolveUser(m[1]);
if (!to) return { effect: 'none', reason: `site-routed tip for unknown streamer ${m[1]} — held for review`, hold: true };
const from = await resolveUser(m[2]);
if (cents < 1) return { effect: 'none', reason: 'zero-amount tip' };
return { effect: 'tip', args: { provider: 'powerchat', receiptRef, paidCents: cents, from, to, test: isTest, idempotencyKey: key, actor, metadata: meta } };
}
-- const host = String((envelope.streamer && envelope.streamer.username) || '').toLowerCase();
-- if (cfg.siteUsername && host === cfg.siteUsername) {
++ if (onSite) {
// Money on the SITE account without one of our refs: Live ignored these. They are
// recorded here and listed by reconciliation instead of vanishing.
return { effect: 'none', reason: 'unattributed tip to the site PowerChat account — review', review: true };
ddiff --git a/test/security.test.js b/test/security.test.js
nnew file mode 100644
iindex 0000000..acd46d1
---- /dev/null
++++ b/test/security.test.js
@@@ -0,0 +1,80 @@
++'use strict';
++/**
++ * Security review regressions (Track S):
++ * - a site-routed PowerChat ref (pcorder / pcdon / site pcsub) on a tip paid to a creator's OWN
++ * PowerChat account moves no money in Billing (the ref is buyer-editable in the fallback link);
++ * - the service API is not reachable under another letter case (nginx denies /api/v1 only).
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/app');
++
++(async () => {
++ const t = await boot();
++ const attacker = t.user(71); // a creator with their own PowerChat account "attackerpc"
++ const victim = t.user(72);
++ const attackerLiveId = 71;
++ console.log('security');
++
++ const intent = async (body) => {
++ const r = await t.call('POST', '/api/v1/intents', { cap: ['billing.intent.create'], body: { provider: 'powerchat', ...body } });
++ assert.strictEqual(r.status, 201, r.text);
++ return r.json.intent;
++ };
++ const tipTo = (host, data) => t.powerchat({ type: 'donation.completed', streamer: { id: 'pcx', username: host }, data: { eventId: `sec-${Math.random().toString(36).slice(2)}`, ...data } });
++
++ await check('pcdon on a tip to the creator\'s own PowerChat credits no payable', async () => {
++ const r = await tipTo('attackerpc', { amountUsdCents: 5000, appExternalRef: `pcdon:${attackerLiveId}:0` });
++ assert.strictEqual(r.status, 200, JSON.stringify(r.json));
++ assert.strictEqual(r.json.result.effect, 'none', JSON.stringify(r.json));
++ assert.strictEqual(r.json.result.review, true);
++ assert.strictEqual((await t.balances(attacker.id)).payable, 0);
++ });
++
++ await check('pcorder on a tip to a non-site account credits no Vibes', async () => {
++ const i = await intent({ kind: 'purchase', subject: attacker, bits: 1000 });
++ const r = await tipTo('attackerpc', { amountUsdCents: 1300, appExternalRef: i.checkout_ref });
++ assert.strictEqual(r.json.result.effect, 'none', JSON.stringify(r.json));
++ assert.strictEqual((await t.balances(attacker.id)).credit, 0);
++ const after = await t.call('GET', `/api/v1/intents/${i.id}`, { cap: ['billing.intent.create'] });
++ assert.notStrictEqual(after.json.intent.status, 'settled');
++ });
++
++ await check('a site-routed pcsub paid to another account grants nothing and credits no share', async () => {
++ const i = await intent({ kind: 'subscription', subject: victim, streamer: attacker, route: 'site' });
++ const r = await tipTo('attackerpc', { amountUsdCents: 549, appExternalRef: i.checkout_ref });
++ assert.strictEqual(r.json.result.effect, 'none', JSON.stringify(r.json));
++ assert.strictEqual((await t.balances(attacker.id)).payable, 0);
++ const e = await t.call('GET', `/api/v1/entitlements/${victim.id}?streamer=${attacker.id}`, { cap: ['billing.entitlement.check'] });
++ assert.strictEqual(e.json.active, false);
++ });
++
++ await check('the same refs paid to the site account still settle', async () => {
++ const r = await tipTo('openvibe', { amountUsdCents: 250, appExternalRef: `pcdon:${attackerLiveId}:0` });
++ assert.strictEqual(r.json.result.effect, 'settled', JSON.stringify(r.json));
++ assert.strictEqual((await t.balances(attacker.id)).payable, 250);
++ t.assertReconciled('after site pcdon');
++ });
++
++ await check('without POWERCHAT_SITE_USERNAME site-routed refs are held, never credited', async () => {
++ const t2 = await boot({ network: t.network, env: { POWERCHAT_SITE_USERNAME: '' } });
++ try {
++ const r = await t2.powerchat({ type: 'donation.completed', streamer: { username: '' }, data: { eventId: 'sec-nosite', amountUsdCents: 900, appExternalRef: `pcdon:${attackerLiveId}:0` } });
++ assert.strictEqual(r.json.result.effect, 'none', JSON.stringify(r.json));
++ assert.strictEqual((await t2.balances(attacker.id)).payable, 0);
++ } finally { await t2.close(); }
++ });
++
++ await check('the service API does not answer under another letter case', async () => {
++ const ok = await t.call('GET', '/api/v1/rates');
++ assert.strictEqual(ok.status, 200);
++ for (const p of ['/API/v1/rates', '/Api/V1/rates', `/API/V1/balances/${victim.id}`]) {
++ const r = await t.call('GET', p);
++ assert.strictEqual(r.status, 404, `${p} answered ${r.status}`);
++ }
++ const w = await t.call('POST', '/API/v1/transfers', { body: { from: victim, to: attacker, amount: 1 } });
++ assert.strictEqual(w.status, 404);
++ });
++
++ await t.close();
++ done();
++})();