openvibers/openvibe.billing Commits
Security: PowerChat site-routed refs only settle when paid to the site account; case-sensitive routing
Changes
@@@ -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(); });@@@ -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 };nnew file mode 100644@@@ -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();++})();
@@ -39,6 +39,9 @@ function createApp(opts = {}) {
@@ -39,6 +39,9 @@ function createApp(opts = {}) {
const app = express();
const app = express();
app.disable('x-powered-by');
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.set('trust proxy', config.trustProxy);
app.use(http.middleware());
app.use(http.middleware());
app.use((req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); });
app.use((req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); });
@@ -18,6 +18,8 @@
@@ -18,6 +18,8 @@
* site-routed tip to the streamer; direct → EXTERNAL tip, no effect.
* site-routed tip to the streamer; direct → EXTERNAL tip, no effect.
* pcdon:<streamer>:<donor> site-routed tip for a creator → MONEY into their payable
* pcdon:<streamer>:<donor> site-routed tip for a creator → MONEY into their payable
* (legacy links carry Live user ids; they are resolved to subjects)
* (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
* anything else a tip on a streamer's own PowerChat → EXTERNAL, no Billing effect
* (recorded as an interaction by OpenVibe.Tips).
* (recorded as an interaction by OpenVibe.Tips).
* Test deliveries (isTest, or source manual_test) move no money and are ignored unless
* Test deliveries (isTest, or source manual_test) move no money and are ignored unless
@@ -98,8 +100,21 @@ function createPowerchat(cfg, { network } = {}) {
@@ -98,8 +100,21 @@ function createPowerchat(cfg, { network } = {}) {
const receiptRef = `powerchat:${paymentId}`;
const receiptRef = `powerchat:${paymentId}`;
const ref = String(data.appExternalRef || '');
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 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;
let m;
if ((m = ref.match(/^pcorder:(.+)$/))) {
if ((m = ref.match(/^pcorder:(.+)$/))) {
+ if (!onSite) return offSite('purchase checkout');
const intent = intents.find(ctx.db, m[1]);
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 (!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' };
if (cents < 1) return { effect: 'none', reason: 'zero-amount purchase' };
@@ -109,6 +124,7 @@ function createPowerchat(cfg, { network } = {}) {
@@ -109,6 +124,7 @@ function createPowerchat(cfg, { network } = {}) {
const intent = intents.find(ctx.db, m[1]);
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` };
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';
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)) {
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 };
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 } = {}) {
@@ -126,14 +142,14 @@ function createPowerchat(cfg, { network } = {}) {
};
};
}
}
if ((m = ref.match(/^pcdon:([^:]+):([^:]*)$/))) {
if ((m = ref.match(/^pcdon:([^:]+):([^:]*)$/))) {
+ if (!onSite) return offSite('site-routed tip');
const to = await resolveUser(m[1]);
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 };
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]);
const from = await resolveUser(m[2]);
if (cents < 1) return { effect: 'none', reason: 'zero-amount tip' };
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 } };
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
// Money on the SITE account without one of our refs: Live ignored these. They are
// recorded here and listed by reconciliation instead of vanishing.
// recorded here and listed by reconciliation instead of vanishing.
return { effect: 'none', reason: 'unattributed tip to the site PowerChat account — review', review: true };
return { effect: 'none', reason: 'unattributed tip to the site PowerChat account — review', review: true };
new file mode 100644
new file mode 100644
@@ -0,0 +1,80 @@
@@ -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();
+})();