openvibers/openvibe.live Commits

Security: a PowerChat checkout ref (pcorder/pcsub/pcdon) only counts on the account its checkout routes to — a streamer tipping their own PowerChat with a crafted ref minted cashout Vibes; FedCM assertions and service/app tokens are refused as Live sessions

OP OpenVibers committed 23 Sept 2026 · ce601e1434 · 5 files, +102 −6


Changes

ddiff --git a/server/auth/auth.js b/server/auth/auth.js
iindex 4a7e9080..b822aeec 100644
---- a/server/auth/auth.js
++++ b/server/auth/auth.js
@@@ -45,13 +45,21 @@ loadNetworkPublicKey();
* Verify a openvibe.network RS256 JWT token.
* Returns decoded payload or null.
*/
++// A user session token carries neither `typ` (FedCM assertions: 5 minutes, minted for one origin, to be
++// exchanged at Network, never a session) nor `actor_type` (service/app principal tokens). Both are
++// signed with the same Network key, so the signature alone does not make a token a login.
++function isUserSessionClaims(decoded) {
++ return !!decoded && typeof decoded === 'object' && decoded.typ === undefined && decoded.actor_type === undefined;
++}
++
function verifyToken(token) {
if (!openvibeToolsPublicKey) return null;
try {
-- return jwt.verify(token, openvibeToolsPublicKey, {
++ const decoded = jwt.verify(token, openvibeToolsPublicKey, {
algorithms: ['RS256'],
issuer: getNetworkIssuer(),
});
++ return isUserSessionClaims(decoded) ? decoded : null;
} catch {
return null;
}
@@@ -68,6 +76,7 @@ function verifyTokenWithReason(token) {
algorithms: ['RS256'],
issuer: getNetworkIssuer(),
});
++ if (!isUserSessionClaims(decoded)) return { ok: false, reason: 'not_a_session_token' };
return { ok: true, decoded };
} catch (err) {
return { ok: false, reason: err.name, message: err.message };
ddiff --git a/server/integrations/powerchat-checkout.js b/server/integrations/powerchat-checkout.js
iindex 8ff5d652..03e82e12 100644
---- a/server/integrations/powerchat-checkout.js
++++ b/server/integrations/powerchat-checkout.js
@@@ -300,12 +300,22 @@ function buildDonateLink(streamerUserId, donorUserId, { goalId = null } = {}) {
// matches one of our prefixes. Returns true when the event was CONSUMED (the normal
// donation pipeline for the receiving account must then be skipped), false to fall
// through to plain donation handling.
--function handleAttributedDonation(receivingUserId, data) {
++// The checkout reference rides in the tip link and the buyer can edit it, so a reference only
++// counts when the money landed where that kind of checkout sends it: purchases, site-routed
++// donations and site-routed subscriptions on the SITE tips account, a direct subscription on
++// the subscribed streamer's own account. Anything else (a streamer tipping their own PowerChat
++// with `pcdon:<self>`, a viewer tipping themselves with `pcsub:<x>`) is an ordinary tip to the
++// receiving account and falls through to normal handling.
++function handleAttributedDonation(receivingUserId, data, { viaSiteAccount = receivingUserId == null } = {}) {
const ref = String(data.appExternalRef || '');
const usdCents = Math.max(0, Math.round(Number(data.amountUsdCents || 0)));
try {
// Buy Vibes ---------------------------------------------------------
let m = ref.match(/^pcorder:(\d+)$/);
++ if (m && !viaSiteAccount) {
++ console.warn(`[PowerChat] ${ref} paid to a streamer account, not the site account — not a purchase`);
++ return false;
++ }
if (m) {
const order = db.getPaymentOrderById(Number(m[1]));
if (!order || order.kind !== 'bucks') return true; // ours but unusable — never double-handle
@@@ -333,6 +343,13 @@ function handleAttributedDonation(receivingUserId, data) {
// discounted sub. Direct mode = the receiving account IS the streamer, so
// falling through does the right thing; site mode routes explicitly.
const mode = String(order.provider_ref || 'site').split(':')[0];
++ // Direct: the tip must land on the subscribed streamer's own account (which may also be
++ // the site tips account). Site-routed: it must land on the site tips account.
++ const paidWhereExpected = mode === 'direct' ? (receivingUserId != null && Number(receivingUserId) === Number(order.streamer_id)) : viaSiteAccount;
++ if (!paidWhereExpected) {
++ console.warn(`[PowerChat] ${ref} (${mode}) paid to the wrong account — not a subscription`);
++ return false;
++ }
const autoRenew = /:renew$/.test(String(order.provider_ref || '')) ? 1 : 0;
const feeCents = Number((String(order.provider_ref || '').match(/:fee=(\d+)/) || [])[1] || 0);
if (usdCents + 1 < (order.amount_cents || 0)) {
@@@ -351,6 +368,10 @@ function handleAttributedDonation(receivingUserId, data) {
}
// Site-routed donation ----------------------------------------------
m = ref.match(/^pcdon:(\d+):(\d+)$/);
++ if (m && !viaSiteAccount) {
++ console.warn(`[PowerChat] ${ref} paid to a streamer account, not the site account — not a site-routed donation`);
++ return false;
++ }
if (m) {
_creditSiteRoutedDonation(Number(m[1]), Number(m[2]) || null, data);
return true;
ddiff --git a/server/integrations/powerchat-reconcile.js b/server/integrations/powerchat-reconcile.js
iindex 48ab5db6..dc47fcea 100644
---- a/server/integrations/powerchat-reconcile.js
++++ b/server/integrations/powerchat-reconcile.js
@@@ -52,12 +52,12 @@ function _hostFor(order) {
const mode = String(order.provider_ref || 'site').split(':')[0];
if (order.kind === 'subscription' && mode === 'direct' && order.streamer_id) {
const conn = db.getPowerchatConnection(order.streamer_id);
-- return conn && conn.powerchat_username ? { username: conn.powerchat_username, conn, receivingUserId: order.streamer_id } : null;
++ return conn && conn.powerchat_username ? { username: conn.powerchat_username, conn, receivingUserId: order.streamer_id, viaSiteAccount: false } : null;
}
const site = checkout.getSiteAccount();
if (!site) return null;
const conn = db.getPowerchatConnectionByUsername(site.username);
-- return { username: site.username, conn: conn || null, receivingUserId: conn ? conn.user_id : null };
++ return { username: site.username, conn: conn || null, receivingUserId: conn ? conn.user_id : null, viaSiteAccount: true };
}
function _refFor(order) {
return order.kind === 'subscription' ? `pcsub:${order.id}` : `pcorder:${order.id}`;
@@@ -150,7 +150,7 @@ async function reconcileOnce() {
appExternalRef: ref, isTest, reconciled: true,
};
let consumed = false;
-- try { consumed = checkout.handleAttributedDonation(host.receivingUserId, data); }
++ try { consumed = checkout.handleAttributedDonation(host.receivingUserId, data, { viaSiteAccount: host.viaSiteAccount }); }
catch (e) { summary.errors++; console.warn(`[PowerChat] reconcile: fulfillment for ${ref} failed: ${e.message}`); }
orders.delete(ref);
let after = null;
ddiff --git a/server/integrations/powerchat-webhook.js b/server/integrations/powerchat-webhook.js
iindex f0431c27..92e716d4 100644
---- a/server/integrations/powerchat-webhook.js
++++ b/server/integrations/powerchat-webhook.js
@@@ -232,7 +232,10 @@ function processEvent(envelope) {
// checkout module; when it consumes the event, the receiving account's
// normal donation pipeline must NOT also run.
if (data.appExternalRef && /^(pcorder|pcsub|pcdon):/.test(String(data.appExternalRef))) {
-- if (require('./powerchat-checkout').handleAttributedDonation(userId, data)) break;
++ const siteName = String(db.getSetting('powerchat_site_tip_username') || '').trim().toLowerCase();
++ const receiving = String((envelope.streamer && envelope.streamer.username) || '').toLowerCase();
++ const viaSiteAccount = !!siteName && receiving === siteName;
++ if (require('./powerchat-checkout').handleAttributedDonation(userId, data, { viaSiteAccount })) break;
}
_handleDonation(userId, data);
break;
ddiff --git a/test/powerchat-ref-authority.test.js b/test/powerchat-ref-authority.test.js
nnew file mode 100644
iindex 00000000..42cd4966
---- /dev/null
++++ b/test/powerchat-ref-authority.test.js
@@@ -0,0 +1,63 @@
++'use strict';
++// A PowerChat checkout reference (pcorder/pcsub/pcdon) rides in the tip link, so the buyer can edit it.
++// It must only count when the money landed where that checkout sends it: the SITE tips account for
++// purchases, site-routed donations and site-routed subscriptions; the subscribed streamer's own
++// account for a direct subscription. A streamer tipping their own PowerChat with `pcdon:<self>`, or a
++// viewer tipping their own account with `pcsub:<x>`, must fall through to ordinary tip handling.
++const assert = require('assert');
++const path = require('path');
++function stub(modPath, exportsObj) { const full = require.resolve(modPath); require.cache[full] = { id: full, filename: full, loaded: true, exports: exportsObj }; }
++
++const orders = new Map();
++const credited = { cashout: [], bucks: [], subs: [] };
++const seed = () => {
++ orders.clear();
++ orders.set(41, { id: 41, kind: 'bucks', amount_cents: 500, bucks: 500, user_id: 7, status: 'pending' });
++ orders.set(42, { id: 42, kind: 'subscription', amount_cents: 500, user_id: 8, streamer_id: 3, provider_ref: 'direct', status: 'pending' });
++ orders.set(43, { id: 43, kind: 'subscription', amount_cents: 500, user_id: 9, streamer_id: 3, provider_ref: 'site', status: 'pending' });
++ credited.cashout.length = 0; credited.bucks.length = 0; credited.subs.length = 0;
++};
++stub(path.join(__dirname, '../server/db/database'), {
++ getSetting: (k) => (k === 'powerchat_site_tip_username' ? 'siteacct' : null),
++ getPaymentOrderById: (id) => orders.get(id) || null,
++ updatePaymentOrder: (id, f) => Object.assign(orders.get(id), f),
++ getUserById: (id) => ({ id, username: 'u' + id }),
++ addVibesCashout: (uid, cents) => credited.cashout.push([uid, cents]),
++ createTransaction: () => {},
++ getPowerchatConnectionByUsername: () => null,
++ getPowerchatConnection: () => null,
++});
++stub(path.join(__dirname, '../server/monetization/payments'), {
++ bucksForUsd: (usd) => Math.round(usd * 100),
++ fulfillBucksOrder: (o) => { credited.bucks.push(o.id); orders.get(o.id).status = 'credited'; },
++ fulfillSubscriptionOrder: (o) => { credited.subs.push(o.id); orders.get(o.id).status = 'credited'; },
++});
++stub(path.join(__dirname, '../server/integrations/powerchat-webhook'), { creditDonationPipeline: () => {} });
++stub(path.join(__dirname, '../server/utils/notify'), { pushNotification: () => {} });
++const checkout = require('../server/integrations/powerchat-checkout');
++const log = console.warn; console.warn = () => {};
++const tip = (ref, cents = 500) => ({ appExternalRef: ref, amountUsdCents: cents });
++
++seed();
++// Exploit: streamer 3 tips their OWN PowerChat with a site-routed donation ref naming themselves.
++assert.strictEqual(checkout.handleAttributedDonation(3, tip('pcdon:3:0'), { viaSiteAccount: false }), false, 'pcdon on a streamer account is an ordinary tip');
++assert.deepStrictEqual(credited.cashout, [], 'no cashout Vibes minted');
++// Exploit: a purchase ref on a streamer account credits no Vibes.
++assert.strictEqual(checkout.handleAttributedDonation(3, tip('pcorder:41'), { viaSiteAccount: false }), false);
++assert.deepStrictEqual(credited.bucks, []); assert.strictEqual(orders.get(41).status, 'pending');
++// Exploit: viewer 9 tips their own account (user 9) with a direct sub ref for streamer 3.
++assert.strictEqual(checkout.handleAttributedDonation(9, tip('pcsub:42'), { viaSiteAccount: false }), false, 'direct sub must be paid to the subscribed streamer');
++// A site-routed sub ref paid to a streamer account is not a subscription either.
++assert.strictEqual(checkout.handleAttributedDonation(3, tip('pcsub:43'), { viaSiteAccount: false }), false);
++assert.deepStrictEqual(credited.subs, []);
++
++// Legitimate paths still work.
++assert.strictEqual(checkout.handleAttributedDonation(null, tip('pcorder:41')), true, 'purchase on the site account');
++assert.deepStrictEqual(credited.bucks, [41]);
++assert.strictEqual(checkout.handleAttributedDonation(3, tip('pcsub:42'), { viaSiteAccount: false }), true, 'direct sub paid to streamer 3');
++assert.strictEqual(checkout.handleAttributedDonation(1, tip('pcsub:43'), { viaSiteAccount: true }), true, 'site sub on the site account (even when the site account is a connected user)');
++assert.deepStrictEqual(credited.subs, [42, 43]);
++assert.strictEqual(checkout.handleAttributedDonation(null, tip('pcdon:3:7')), true, 'site-routed donation on the site account');
++assert.deepStrictEqual(credited.cashout, [[3, 500]]);
++console.warn = log;
++console.log('✅ PowerChat checkout refs count only on the account the checkout routes to');