openvibers/openvibe.tips Commits
Threat review (docs/threat-review.md) and the gaps it closed
Changes
@@@ -62,12 +62,16 @@ TIPS_EVENTS_SECRET=#TIPS_OVERLAY_TTL_MS=600000#TIPS_OVERLAY_HEARTBEAT_MS=25000#TIPS_OVERLAY_REPLAY_LIMIT=50++# Open event streams per overlay token (more answer 429)++#TIPS_OVERLAY_MAX_STREAMS=10# ── Limits ──────────────────────────────────────────────────#TIPS_MAX_BITS=10000000#TIPS_MESSAGE_CHARS=300#TIPS_TTS_HARD_CAP=1200#TIPS_MEDIA_HOSTS=youtube.com,www.youtube.com,m.youtube.com,music.youtube.com,youtu.be++# Unpaid checkouts one supporter may hold within a day (more answer 429)++#TIPS_MAX_PENDING_CHECKOUTS=20# ── Background jobs: effects worker, due transfers, overlay window, outbox relay ──#TIPS_JOBS=on@@@ -258,6 +258,21 @@ goals, overlay links shown once, alert settings, simulation, totals, recent tipsSigned-in forms carry an HMAC anti-forgery token and a per-render nonce. Shared chrome: Network`navbar.js`, `openvibe-shared` app icon, SSR footer and `<noscript>` navigation.++## Security++++[docs/threat-review.md](docs/threat-review.md) is the written threat review: overlays (tokenised URLs),++paid-message abuse, TTS, amount spoofing, replay and privacy, with each finding's status and the test++that shows it. The fixes it made:++- a Billing donation settles an interaction only when amount, creator and supporter match;++- a replayed `POST /overlay-tokens` answer carries no secret, and none is stored;++- open streams per overlay token (`TIPS_OVERLAY_MAX_STREAMS`) and unpaid checkouts per supporter++ (`TIPS_MAX_PENDING_CHECKOUTS`) are bounded;++- the creator's own name is refused as a supporter name;++- invisible and direction-override characters are dropped, and TTS text carries no markup.++++The regressions are in `test/security.test.js`. It is an internal review; an independent one is still due++before launch.++## Import and reconciliation`scripts/import-live.js` reads a **copy** of Live's database (read-only) after Billing's own import, and@@@ -322,8 +337,8 @@ exist here (plan §12.12):Events (Billing is in shadow and has sent no events; the production database is empty; the Liveimport has not been run);5. capability and event registration against OpenVibe.Contracts — ✔ v0.15.0, payload schemas in v0.30.2 (pinned);--6. a migration/seed strategy ✔, a security/threat review (not done beyond the tests' refusals), and-- sitemap/robots ✔ (no feed);++6. a migration/seed strategy ✔, a written threat review ✔ ([docs/threat-review.md](docs/threat-review.md),++ internal; an independent review is still due), and sitemap/robots ✔ (no feed);7. acceptance tests proving the advertised functionality — ✔ (table above).The launch release removes the domain from `OpenVibe.Sites/sites.json`, switches routing and registers@@@ -7,6 +7,6 @@"adr": "ADR-012","authoritative": false,"deployment": "Deployed internally, not launched: openvibe-ovh /opt/openvibe.tips (release 435f32b), unit openvibe-tips, 127.0.0.1:4610 loopback only; https://openvibe.tips still serves the OpenVibe.Sites placeholder. Principal tips; Events subscription billing.transaction.* -> /internal/events; TIPS_CHAT_ADAPTER=none. Pins openvibe-contracts v0.15.0, openvibe-sdk v0.4.0, openvibe-shared v1.2.1.",-- "notes": "Runtime, API, SSR pages, overlays, Billing settlement consumer, events outbox, Live import and reconciliation are built and tested against stubs. Capabilities (17 tips.*) and the service manifest are registered in OpenVibe.Contracts v0.15.0; the six tips.* event payload schemas are in v0.30.2, which the repository pins since 2026-09-23 (production still runs v0.15.0). Production database is empty (0 profiles, interactions, goals, overlay tokens); the Live import (scripts/import-live.js) has never been run; 0 billing events have ever arrived because Billing runs in shadow. Live's /internal/tips/deliveries route is deployed (Live f11f809) but never called. The restore drill passed on the host 2026-09-23 05:23 UTC. Built since (not deployed yet): /metrics (loopback only) and the Billing/Events checks in /api/ready. Built since (not deployed yet): supporter privacy (anonymous, hidden amount, private message) across API, overlays, chat jobs, pages and events; the creator's public goal and supporters page settings; a supporter's export and erasure (tips.interaction.erased, schema proposed in docs/events-proposal/, not yet in openvibe-contracts). Moderation (not deployed yet): the creator's word filter (mask or hold for review) before anything is shown or read, hide/show by the creator, moderators (invitation links) or services, tips.interaction.moderated (schema and the tips.interaction.moderate capability proposed in docs/, not yet in openvibe-contracts). Not done: an independent security review; the openvibe.tips launch. Live stays the place tips happen until the Billing cutover (blocked on the owner re-pointing the PowerChat webhook) and the launch rule in README.md holds.",++ "notes": "Runtime, API, SSR pages, overlays, Billing settlement consumer, events outbox, Live import and reconciliation are built and tested against stubs. Capabilities (17 tips.*) and the service manifest are registered in OpenVibe.Contracts v0.15.0; the six tips.* event payload schemas are in v0.30.2, which the repository pins since 2026-09-23 (production still runs v0.15.0). Production database is empty (0 profiles, interactions, goals, overlay tokens); the Live import (scripts/import-live.js) has never been run; 0 billing events have ever arrived because Billing runs in shadow. Live's /internal/tips/deliveries route is deployed (Live f11f809) but never called. The restore drill passed on the host 2026-09-23 05:23 UTC. Built since (not deployed yet): /metrics (loopback only) and the Billing/Events checks in /api/ready. Built since (not deployed yet): supporter privacy (anonymous, hidden amount, private message) across API, overlays, chat jobs, pages and events; the creator's public goal and supporters page settings; a supporter's export and erasure (tips.interaction.erased, schema proposed in docs/events-proposal/, not yet in openvibe-contracts). Moderation (not deployed yet): the creator's word filter (mask or hold for review) before anything is shown or read, hide/show by the creator, moderators (invitation links) or services, tips.interaction.moderated (schema and the tips.interaction.moderate capability proposed in docs/, not yet in openvibe-contracts). A written internal threat review (docs/threat-review.md) with its fixes and test/security.test.js. Not done: an independent security review; the openvibe.tips launch. Live stays the place tips happen until the Billing cutover (blocked on the owner re-pointing the PowerChat webhook) and the launch rule in README.md holds.","updated": "2026-09-23"}nnew file mode 100644@@@ -0,0 +1,211 @@++# OpenVibe.Tips threat review++++**Date:** 2026-09-23. **Code reviewed:** `main` at the privacy and moderation commits (`2e79f74`, `c428a28`)++and the fixes that came with this review. **Author:** the OpenVibers team, with an AI coding agent.++This is a written internal review, not an independent audit. The roadmap's gate still asks for an++outside review before launch.++++**Method:** we read the code paths end to end (`server/`, `public/js/overlay.js`,++`deploy/nginx/openvibe.tips.conf`), and Live's `/internal/tips/deliveries` where Tips depends on it.++Every claim below is backed by a test in `test/` or names the line that enforces it. Nothing was run++against production. Production runs Tips on loopback only, with an empty database, so none of these++issues was ever reachable from the internet.++++## Scope and trust boundaries++++| Party | Holds | Can reach Tips through |++|---|---|---|++| Anyone | nothing | public pages, `GET /api/v1` public reads, `/overlay/<token>` (only with a token) |++| Signed-in supporter | a Network user JWT (`ov_token` cookie on the pages, Bearer on the API) | tip form and API for their own tips, receipts, export, erase |++| Creator | the same, for their own profile | dashboard, goals, overlay tokens, filter, moderators, moderation |++| Moderator | a Network user JWT plus a `tip_moderators` row | `/moderate/<handle>`, hide/restore API for that creator only |++| OBS / an overlay client | an overlay token in the URL | `/overlay/<token>`, `/events` (SSE), `/state` |++| First-party service | a Network service token with `tips.*` capabilities | `/api/v1` routes, one capability each |++| OpenVibe.Billing (via Events) | the webhook signing secret | `POST /internal/events` on loopback |++| OpenVibe.Live | Tips' service token (`live.tips_delivery.write`) | Tips calls Live; Live never calls Tips |++++**What we protect:**++++- Tips' records must stay consistent with Billing's books. Tips never moves money.++- What reaches a creator's stream: overlays, chat lines and text-to-speech.++- Overlay tokens.++- Supporters' identities and choices.++++## Findings++++Status: **fixed** in this review, **holds** (checked and already enforced), **residual** (accepted and++documented), **elsewhere** (the fix belongs to another repository).++++| # | Area | Threat | Status | Evidence |++|---|---|---|---|---|++| 1 | Amount spoofing | A Billing donation that names a pending Tips interaction settled it whatever its amount, sender or recipient. A 1-bit transfer from any service holding `billing.transfer.create` could "pay" a 10,000-bit paid message. | **fixed** | `onBillingSettled` settles only on an exact match of amount, creator and supporter; anything else is recorded as its own donation, so totals still reconcile. `test/security.test.js` |++| 2 | Overlays | The overlay token's secret was stored in plain text in `api_idempotency`, because the `POST /overlay-tokens` answer was kept for replays. That undid hashing at rest. | **fixed** | The stored answer has `secret: null`; a replay returns the token without its secret. `test/security.test.js` |++| 3 | Overlays | A leaked overlay link could open unlimited SSE streams, exhausting sockets and memory. | **fixed** | At most `TIPS_OVERLAY_MAX_STREAMS` (10) streams per token; more get a 429. `test/security.test.js` |++| 4 | Paid-message abuse | Unpaid checkouts were unbounded: each creates a row and a Billing intent. | **fixed** | At most `TIPS_MAX_PENDING_CHECKOUTS` (20) unpaid checkouts per supporter per day (429). `test/security.test.js` |++| 5 | Paid-message abuse | A supporter could tip under the creator's own name or handle (impersonation on stream). | **fixed** | Refused with 422 `tips.name_taken`, after NFKC folding and invisible-character removal. `test/security.test.js` |++| 6 | Paid-message abuse | Zero-width and direction-override characters were kept. They can hide a word from a filter or turn a line around on stream. | **fixed** | Dropped from names and texts at input (`util.js` `INVISIBLE`) and again at display (`filter.js clean`). `test/security.test.js`, `test/moderation.test.js` |++| 7 | TTS | TTS text could carry markup that a speech engine might read as SSML. | **fixed** | `<` and `>` are removed from TTS text, next to the existing removal of control characters and links. Live's engine takes plain text today, so this is defence in depth. `test/security.test.js` |++| 8 | Paid-message abuse, TTS | No way to keep a word off stream, and no way to take a paid message back. | **fixed** | Creator word filter (mask or hold) and hide/restore by the creator and their moderators, with the money untouched. `test/moderation.test.js` |++| 9 | Privacy | Supporters could not be anonymous, hide the amount or keep a message private, and could not export or erase their data. | **fixed** | Privacy controls, export and erasure. `test/privacy.test.js` |++| 10 | Privacy | Stored API answers, which carry messages and subjects, were kept forever. | **fixed** | Pruned after 7 days, and a supporter's erasure removes theirs at once. `api/idempotency.js pruneAnswers` |++| 11 | Overlays | Moderator invitation links, like overlay links, carry a secret in the path. | **holds** | Shown once, hashed, single use, expire after 7 days; `no-store`, `no-referrer`; nginx does not log `/moderate/invite/`. `test/moderation.test.js` |++| 12 | Replay | Webhook, API, form, overlay and event replays. | **holds** | See [Replay](#replay). `test/settlement.test.js`, `test/overlays.test.js`, `test/api.test.js`, `test/pages.test.js` |++| 13 | Privacy | A hidden amount still moves a public goal bar by that amount. | **residual** | Stated on the tip form. The goal update of such a tip names nobody. |++| 14 | Privacy | Internal events carry the amount, and the supporter's subject when the tip is not anonymous. | **residual** | The events are `internal` visibility. Goals and reconciliation need the amount. Erasure redacts them in Events. |++| 15 | Privacy, chat | Live's delivery route turns a hidden amount (`null`) into a donation event of 0 Vibes, and Live's chat renders it as "donated 0 Vibes". Live also remembers delivery ids in memory only. | **elsewhere** (Live) | The job carries `privacy.hide_amount`. Live should render a line with no amount. |++| 16 | Overlays | Overlay tokens never expire. | **residual** | The creator can revoke a token, and revocation closes open streams at once. Every token records when it was last used. |++| 17 | Rate limits | Tips has no rate limiting of its own. | **residual** | The nginx reference config limits `/api/`, `/auth/` and the form posts. Money limits paid actions, and fixes 3 and 4 bound the rest. |++| 18 | Paid-message abuse | Supporter names are still free text: only an exact copy of the creator's name is refused. Donor names that Billing relays from PowerChat are not checked. | **residual** | The word filter and moderation cover names. |++++## Overlays (tokenised URLs)++++- **Token strength and storage.** A token is `tovl_` followed by 32 random bytes (256 bits). Tips stores++ only its SHA-256 and shows the secret once. It is never a cookie: OBS holds it in the URL and nothing++ else. A creator can have at most 25 active tokens. Each token carries scopes (`alerts`, `goals`) and++ optionally a config (`domain/overlays.js`).++- **Leak paths.**++ - Referer: overlay responses send `Referrer-Policy: no-referrer`, and the page adds `<meta name="referrer">`. The creator's sound and image URLs therefore never see the token.++ - Logs: nginx has `access_log off` for `/overlay/`. Tips' metrics use route templates and skip the SSE route. Tips never logs a request path.++ - Caches and search engines: overlay responses are `no-store` and `X-Robots-Tag: noindex`, and robots.txt disallows `/overlay/`.++ - Stored API answers: fixed (#2).++ - What remains: the URL sits in OBS scene files and on screen when the creator shares their screen. The dashboard warns about this. The fix is revocation, which is immediate.++- **Revocation** marks the row and closes every open stream of that token in the same call. After that,++ `/events` and `/state` answer 404 (`test/overlays.test.js`).++- **What a token reads.** Only public views: an alert payload is `publicView()`, the same shape the++ public sees. That means privacy choices, the word filter and moderation all apply. `/state` lists the++ last 20 alerts that are not hidden, plus active goals. Nothing else about the creator is readable, and++ `/state` has no CORS header, so other sites' scripts cannot read it.++- **Rendering.** `overlay.js` builds every node with `textContent`. Image and sound URLs are https URLs++ that only the creator sets. The page keeps the site CSP: `default-src 'self'`, `img-src`/`media-src`++ https, and `frame-ancestors 'self'`.++- **Floods.** Fixed (#3). Tips holds no per-connection state beyond the socket and a heartbeat timer.++- **Moderation reaches overlays.** Hiding an alert sends `retract` to connected overlays, which drop it++ from the queue or clear it from the screen and stop its voice. Replays and `/state` skip it from then on++ (`test/moderation.test.js`).++++## Paid-message abuse++++- **Bounds.**++ - Messages: 300 characters.++ - TTS: the creator's limit, at most 1,200 characters.++ - Media requests: https links on an allowlist of YouTube hosts only, at most 500 characters.++ - The creator sets minimum amounts per kind. Money limits the volume, and fix #4 bounds the unpaid rest.++- **Content control, all before anything is shown or read.**++ - The creator's word filter (`filter.js`) matches whole words and phrases in any letter case. It applies++ after NFKC folding (full-width letters) and after removing invisible characters.++ - `mask` stars the words out; `hold` holds the message for review.++ - Links show as `[link]` by default.++ - Moderators hide a paid message later: queued chat, TTS and media deliveries are cancelled, and the++ overlay alert is retracted. The effects worker re-reads each effect before delivering it, so a hide++ during a batch is honoured.++- **Injection.**++ - Pages escape everything with `esc()`, and JSON-LD escapes `<`.++ - The overlay uses `textContent`.++ - Chat lines are plain text that Tips builds. Live renders them with its own escaping, which is Live's++ responsibility.++- **Impersonation and Unicode tricks:** fixed (#5 and #6). Long runs of combining marks are cut at display.++- **Media requests** cannot be private and go through moderation like paid messages. The URL is shown++ as-is because the video plays on stream anyway.++++## TTS++++- Tips never calls a speech provider. It hands text to Live's `synthesizeAndBroadcastTTS`, and to++ OpenVibe.Chat when an adapter exists.++- The text passes through `cleanTts`, which removes control and invisible characters, turns links into++ "link" and strips markup (#7). The creator's filter then drops blocked words from it rather than++ starring them. Voices come from an allowlist.++- The overlay's optional browser voice (`speak_message`) reads the same filtered public text.++- A held message is never read until it is approved. A hide cancels a TTS that is still queued, and++ `retract` stops an overlay voice that is already speaking.++- A TTS failure never touches the payment: the effect retries or fails on its own++ (`test/delivery.test.js`).++++## Amount spoofing++++- **Tips never sets a price or moves money.** Amounts are integer bits, 1 to 10,000,000, validated by++ `positiveInt`.++- **Credit tips.** Billing transfers exactly `interaction.amount` from the supporter's credit, and refuses++ when funds are short.++- **Checkout tips.** The Billing intent is for exactly that amount. The purchase only funds the supporter,++ and the same transfer then gives the money.++- **Keyed transfers.** Transfers use the key `tips:transfer:<id>` and Tips reads the settled answer from++ its own call, so a client can never make Tips credit more than Billing moved.++- **Donations that name an interaction** must match it exactly (#1).++- **EXTERNAL tips.** Their amounts come from Billing's signed `billing.receipt.external` or from a service++ holding `tips.interaction.record`. They stay out of Billing totals.++- **Goals** count contributions at settlement only, never tests. A reversal lowers them by what Billing++ took back.++- **Simulations** are flagged `test` everywhere and never counted (`test/overlays.test.js`).++++## Replay++++- **Billing webhook.**++ - HMAC signature v2 over `<t>.<raw body>`, with a ±300 s window. v1-only deliveries are refused.++ - The signing secrets rotate as a list, and `source` must be `billing`.++ - `/internal` refuses anything forwarded by nginx.++ - The openvibe-sdk inbox claims `(consumer, event_id)` in the same transaction as the change.++ `billing_txn_id` is UNIQUE, so the same transaction under a new event id is still one interaction++ (`test/settlement.test.js`).++- **API.** An `Idempotency-Key` is required on every write, scoped to the caller and hashed with the++ request. A replay returns the stored answer, without secrets (#2). A reused key with a different body++ gets 422 (`test/api.test.js`).++- **Forms.** An HMAC anti-forgery token (subject and day), an Origin check, `SameSite=Lax` cookies and a++ per-render nonce that makes a double submit one tip (`test/pages.test.js`).++- **Overlay replay** with `Last-Event-ID` writes bytes to a socket and nothing else: no charge, no goal++ count, no event (`test/overlays.test.js`).++- **Moderation actions** are idempotent: hiding twice publishes once. Invitation links work once.++++## Privacy++++- **Supporter choices** (`privacy.js`), stored per interaction:++ - `anonymous`: nobody but the supporter sees their name or subject. That includes the creator,++ moderators, services and events.++ - `hide_amount`: no amount on overlays, chat lines or public pages, and off the leaderboard.++ - `private_message`: the message goes to the creator only.++- **One public shape.** `publicView()` is the only thing that leaves Tips for the public++ (`test/privacy.test.js`).++- **Creator choices.** What the public goal and supporters pages show. The supporters page is off by++ default. The leaderboard counts only supporters who left both their name and their amount public, so a++ rank never reveals a hidden amount.++- **Export and erasure.** `GET /me/export` returns everything Tips holds about the supporter's tips.++ `POST /me/erase` removes:++ - the subject, name and messages;++ - TTS text and media links;++ - checkout references;++ - stored answers;++ - overlay payloads;++ - unsent events.++++ It keeps the money record, so Billing and the creators' totals still reconcile. It emits++ `tips.interaction.erased` with `redacts`, and Events then turns Tips' earlier events about that++ interaction into tombstones. Billing keeps its own payment records under its own policy, and copies++ that other products already made (a Live chat line) stay theirs to delete.++- **Retention.**++ - Stored API answers: 7 days.++ - Sent outbox rows: pruned after 7 days (openvibe-sdk).++ - Overlay deliveries: kept, holding public views only.++ - Moderation log: kept for the creator's accountability; it names the moderator.++- **Moderators** see what was written, but never private messages, hidden amounts or payments.++ `tips.interaction.moderated` carries no supporter, message, note or moderator.++- **Pages.** Receipts, the dashboard, the moderation pages and invitation pages are `noindex` and++ disallowed in robots.txt.++++## Contract additions this depends on++++| Addition | Why | Where it is drafted |++|---|---|---|++| Event `tips.interaction.moderated` v1 | moderation outcomes | `docs/events-proposal/tips.interaction.moderated.v1.json` |++| Event `tips.interaction.erased` v1 | a supporter's erasure, which carries `redacts` | `docs/events-proposal/tips.interaction.erased.v1.json` |++| Capability `tips.interaction.moderate` | services that moderate for a creator | `docs/capabilities-proposal/tips.interaction.moderate.json` |++++OpenVibe.Events accepts both event types today, because it checks the `tips.` prefix and not a catalog.++Consumers cannot validate their payloads against openvibe-contracts until the schemas are added. The++tests validate them against the drafts.++++## Follow-ups++++1. **Live:** render a tip whose amount is hidden (`privacy.hide_amount`, `interaction.amount: null`)++ without an amount, and persist the delivery idempotency key (#15).++2. **Contracts:** add the three items above, then register them in `manifests/services/tips.json`.++3. **Before launch:** get an independent review of this document and the code. Consider optional overlay++ token expiry (#16).@@@ -39,8 +39,11 @@ function idempotent(db, now) {const json = res.json.bind(res);res.json = (body) => {if (res.statusCode >= 200 && res.statusCode < 300) {++ // A handler whose answer carries a secret (an overlay token) sets res.locals.storedBody: the++ // replay gets that instead, so no secret is kept in plain text here.++ const stored = res.locals.storedBody !== undefined ? res.locals.storedBody : body;db.prepare('INSERT OR IGNORE INTO api_idempotency (key, request_hash, method, path, status, response, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)')-- .run(scoped, hash, req.method, `${req.baseUrl}${req.path}`, res.statusCode, JSON.stringify(body), new Date(now()).toISOString());++ .run(scoped, hash, req.method, `${req.baseUrl}${req.path}`, res.statusCode, JSON.stringify(stored), new Date(now()).toISOString());}return json(body);};@@@ -286,8 +286,9 @@ function v1Router({ domain, apiAuth }) {allow(req, CAP.tokenCreate, profile.creator_subject);const by = req.principal.kind === 'service' ? req.principal.sub : req.principal.subject;const out = overlays.createToken(profile.creator_subject, { scopes: b.scopes, label: b.label, configId: b.config_id, createdBy: by });-- // Shown once: only its hash is stored. An idempotent replay of this call returns it again to-- // the same caller with the same key, which is the point of the key.++ // Shown once: only its hash is stored, and the stored answer for an Idempotency-Key replay has no++ // secret either (a caller who lost the answer revokes the token and creates another).++ res.locals.storedBody = { token: out.token, secret: null, overlay_url: null, events_url: null, note: 'the secret is shown once; revoke this token and create another' };res.status(201).json({ token: out.token, secret: out.secret, overlay_url: out.overlay_url, events_url: out.events_url });}));r.post('/overlay-tokens/:id/revoke', ...write((req, res) => {@@@ -89,6 +89,8 @@ function loadConfig(env = process.env) {ttlMs: int(env.TIPS_OVERLAY_TTL_MS, 10 * 60 * 1000),heartbeatMs: int(env.TIPS_OVERLAY_HEARTBEAT_MS, 25_000),replayLimit: int(env.TIPS_OVERLAY_REPLAY_LIMIT, 50),++ // Open event streams per overlay token: an OBS scene or two, not a leaked link's flood.++ maxStreamsPerToken: int(env.TIPS_OVERLAY_MAX_STREAMS, 10),},limits: {@@@ -96,6 +98,7 @@ function loadConfig(env = process.env) {messageChars: int(env.TIPS_MESSAGE_CHARS, 300), // Live's donation message limitttsHardCap: int(env.TIPS_TTS_HARD_CAP, 1200), // Live's hard TTS length capgoalTitleChars: 120, // Live's goal title limit++ pendingCheckouts: int(env.TIPS_MAX_PENDING_CHECKOUTS, 20), // unpaid checkouts per supporter per day},jobs: {@@@ -38,7 +38,7 @@* export their tips and erase their data from them (erase(): the money record stays, the person* goes; tips.interaction.erased redacts the earlier events in OpenVibe.Events).*/--const { fail, iso, prefixedId, json, positiveInt, text, userSubject, entityRef, displayName } = require('../util');++const { fail, iso, prefixedId, json, positiveInt, text, userSubject, entityRef, displayName, INVISIBLE } = require('../util');const { VOICES } = require('./profiles');const { parsePrivacy, publicName, shownName, privacyOf, isAnonymous } = require('./privacy');const { BillingCallError } = require('../billing-client');@@@ -61,8 +61,18 @@ function createInteractions(ctx) {const byBillingTxn = (txnId) => db.prepare('SELECT * FROM tip_interactions WHERE billing_txn_id = ?').get(txnId) || null;// ── Input rules (the creator's settings) ─────────────────++ // Plain words only: no control or invisible characters, no links, no markup a speech engine could++ // take as SSML.function cleanTts(s) {-- return String(s || '').replace(/[\u0000-\u001f]/g, ' ').replace(/https?:\/\/\S+/gi, 'link').replace(/\s+/g, ' ').trim();++ return String(s || '').replace(INVISIBLE, '').replace(/[\u0000-\u001f]/g, ' ').replace(/https?:\/\/\S+/gi, 'link').replace(/[<>]/g, ' ')++ .replace(/\s+/g, ' ').trim();++ }++++ /** A supporter may not show up under the creator's own name or handle (impersonation on stream). */++ function checkName(profile, name) {++ const fold = (v) => String(v || '').normalize('NFKC').replace(/^@/, '').trim().toLowerCase();++ const n = fold(name);++ if (n && (n === fold(profile.display_name) || n === fold(profile.handle))) fail(422, 'tips.name_taken', `"${name}" is ${profile.display_name}'s own name; choose another to show`);}function mediaUrl(v) {@@@ -150,11 +160,18 @@ function createInteractions(ctx) {}const target = entityRef(input.target);const privacy = parsePrivacy(input.privacy, v.kind);++ if (!privacy.anonymous) checkName(profile, displayName(supporterName));return ctx.tx(() => {if (idempotencyKey) {const prev = db.prepare('SELECT * FROM tip_interactions WHERE idempotency_key = ?').get(idempotencyKey);if (prev) return { interaction: prev, replay: true };}++ if (funding === 'checkout') {++ // Each unpaid checkout is a Billing intent and a row here: bounded per supporter.++ const since = iso(ctx.now() - 24 * 3600 * 1000);++ const open = db.prepare("SELECT COUNT(*) AS n FROM tip_interactions WHERE supporter_subject = ? AND funding = 'checkout' AND payment_state = 'pending' AND created_at >= ?").get(from, since).n;++ if (open >= config.limits.pendingCheckouts) fail(429, 'tips.too_many_pending', `you have ${open} unpaid checkouts; finish or wait for them before starting another`);++ }const i = insert({id: prefixedId('tint', ctx.now()), creator_subject: profile.creator_subject, supporter_subject: from, supporter_name: displayName(supporterName) || 'Someone',kind: v.kind, amount: v.amount, message: v.message, request: v.request, funding, settlement: 'billing', payment_state: 'pending',@@@ -326,8 +343,13 @@ function createInteractions(ctx) {const target = meta.target;if (target && target.service === 'tips' && target.type === 'interaction') {const i = get(target.id);-- if (i) { settleByTransaction(i, txn); return 'settled'; }-- return 'ignored:unknown_interaction';++ if (!i) return 'ignored:unknown_interaction';++ // Only the transfer Tips asked for settles the interaction: same amount, same creator, same++ // supporter. Another service's donation that merely names it (a 1-bit transfer claiming a++ // 10,000-bit paid message) is recorded as what it is, a donation of its own amount.++ const matches = Number(meta.amount_bits) === i.amount && p.to_subject === i.creator_subject && (!i.supporter_subject || p.from_subject === i.supporter_subject);++ if (matches) { settleByTransaction(i, txn); return 'settled'; }++ ctx.log.warn(`[Tips] donation ${txn.id} names interaction ${i.id} but does not match it (amount, creator or supporter); recorded on its own`);}// A donation Tips did not start: record it once, by its transaction id.if (!p.to_subject) return 'ignored:no_recipient';@@@ -329,6 +329,7 @@ function createOverlays(ctx) {}function connected(creator) { return (clients.get(creator) || new Set()).size; }++ function streamsFor(token) { let n = 0; for (const c of clients.get(token.creator_subject) || []) if (c.tokenId === token.id) n++; return n; }function closeAll() { for (const set of clients.values()) for (const c of [...set]) detach(c); }function recentDeliveries(creator, limit = 50) {@@@ -338,7 +339,7 @@ function createOverlays(ctx) {return {SCOPES, createToken, getToken, listTokens, authenticate, revokeToken, presentToken,getConfig, listConfigs, createConfig, updateConfig, presentConfig,-- addAlert, refreshInteraction, retract, unretract, addGoalDelivery, sweepFailed, attach, detach, closeToken, notify, connected, closeAll, recentDeliveries,++ addAlert, refreshInteraction, retract, unretract, addGoalDelivery, sweepFailed, attach, detach, closeToken, notify, connected, streamsFor, closeAll, recentDeliveries,};}@@@ -28,10 +28,14 @@ function positiveInt(v, field, max) {return n;}--/** Optional trimmed text: null when empty, refused when longer than max. */++// Zero-width, bidi embedding/override/isolate marks, word joiner, BOM, soft hyphen: they hide words++// from the creator's filter and turn text around on stream (docs/threat-review.md).++const INVISIBLE = /[\u00AD\u200B-\u200F\u202A-\u202E\u2060-\u2064\u2066-\u2069\uFEFF]/g;++++/** Optional trimmed text: null when empty, refused when longer than max. Invisible characters are dropped. */function text(v, field, max) {if (v == null) return null;-- const s = String(v).replace(/\r\n?/g, '\n').trim();++ const s = String(v).replace(INVISIBLE, '').replace(/\r\n?/g, '\n').trim();if (!s) return null;if (s.length > max) fail(422, 'tips.text_too_long', `${field} must be at most ${max} characters`);return s;@@@ -55,8 +59,8 @@ function entityRef(v, field = 'target') {/** The display name a supporter chose, kept short, without markup characters. */function displayName(v) {if (v == null) return null;-- const s = String(v).replace(/[<>\u0000-\u001f]/g, '').trim().slice(0, 80);++ const s = String(v).replace(INVISIBLE, '').replace(/[<>\u0000-\u001f]/g, '').trim().slice(0, 80);return s || null;}--module.exports = { TipsError, fail, prefixedId, iso, sha256, json, positiveInt, text, userSubject, entityRef, displayName };++module.exports = { TipsError, fail, prefixedId, iso, sha256, json, positiveInt, text, userSubject, entityRef, displayName, INVISIBLE };@@@ -245,12 +245,16 @@ function createWebRoutes({ domain, config, layout, userAuth }) {r.get('/overlay/:token/events', (req, res) => {const t = tokenOr404(req, res);if (!t) return;++ if (overlays.streamsFor(t) >= config.overlays.maxStreamsPerToken) {++ res.status(429).set({ ...overlayHeaders, 'Retry-After': '30' }).type('text/plain').send('Too many open streams for this overlay link.');++ return;++ }res.status(200).set({ ...overlayHeaders, 'Content-Type': 'text/event-stream; charset=utf-8', Connection: 'keep-alive', 'X-Accel-Buffering': 'no' });res.flushHeaders();res.write('retry: 3000\n\n');-- const config = t.config_id ? overlays.getConfig(t.config_id) : null;++ const overlayConfig = t.config_id ? overlays.getConfig(t.config_id) : null;const lastEventId = req.get('last-event-id') != null ? req.get('last-event-id') : req.query.last_event_id;-- overlays.attach(t, res, { lastEventId: lastEventId != null && lastEventId !== '' ? lastEventId : undefined, config });++ overlays.attach(t, res, { lastEventId: lastEventId != null && lastEventId !== '' ? lastEventId : undefined, config: overlayConfig });});r.get('/overlay/:token/state', (req, res) => {const t = tokenOr404(req, res);nnew file mode 100644@@@ -0,0 +1,108 @@++'use strict';++// Regressions for docs/threat-review.md: amount spoofing through a donation that merely names an++// interaction, overlay stream floods, unpaid-checkout floods, impersonating the creator, invisible and++// markup characters in paid messages, and overlay secrets kept in stored API answers.++const assert = require('assert');++const { boot, check, done } = require('./helpers/app');++++(async () => {++ const t = await boot({ env: { TIPS_MAX_PENDING_CHECKOUTS: '3', TIPS_OVERLAY_MAX_STREAMS: '3' } });++ const { domain, billing } = t;++ const alex = await t.creator('alex', { settings: { tts_enabled: true } });++ const viewer = t.network.newUser('viewer');++ const other = t.network.newUser('other');++ billing.fund(viewer.subject, 10_000);++ billing.fund(other.subject, 10_000);++++ await check('amount spoofing: a donation that names an interaction but does not match it never settles it', async () => {++ const r = await t.call('POST', '/api/v1/paid-messages', { user: viewer, body: { creator: 'alex', amount: 5000, message: 'big one', pay_with: 'checkout', provider: 'stripe' } });++ const id = r.json.interaction.id;++ assert.strictEqual(r.json.interaction.payment.state, 'pending');++ const target = { service: 'tips', type: 'interaction', id };++ // 1 bit from the right supporter; the right amount from someone else; the right amount to someone else.++ const cases = [++ billing.foreignDonation({ from: viewer.subject, to: alex.subject, amount: 1, target }),++ billing.foreignDonation({ from: other.subject, to: alex.subject, amount: 5000, target }),++ billing.foreignDonation({ from: viewer.subject, to: other.subject, amount: 5000, target }),++ ];++ for (const ev of cases) {++ const d = await t.deliver(ev);++ assert.strictEqual(d.status, 200);++ assert.strictEqual(d.json.outcome, 'recorded', 'recorded on its own');++ }++ const i = domain.interactions.get(id);++ assert.strictEqual(i.payment_state, 'pending', 'the paid message is still unpaid');++ assert.strictEqual(t.db.prepare('SELECT COUNT(*) AS n FROM paid_messages WHERE interaction_id = ?').get(id).n, 0);++ const spoof = domain.interactions.byBillingTxn(cases[0].payload.transaction_id);++ assert.strictEqual(spoof.amount, 1);++ assert.strictEqual(spoof.origin, 'billing');++ assert.strictEqual(domain.interactions.totals(alex.subject).settled_via_billing, billing.payable.get(alex.subject), 'still reconciles to Billing');++ });++++ await check('overlay stream floods: a token opens a bounded number of streams at once', async () => {++ const tok = await t.call('POST', '/api/v1/overlay-tokens', { user: alex, body: {} });++ const open = [];++ for (let k = 0; k < 3; k++) { const s = await t.sse(`/overlay/${tok.json.secret}/events`); assert.strictEqual(s.statusCode, 200); open.push(s); }++ const extra = await fetch(`${t.base}/overlay/${tok.json.secret}/events`);++ assert.strictEqual(extra.status, 429);++ assert.strictEqual(extra.headers.get('referrer-policy'), 'no-referrer');++ open.pop().close();++ await new Promise((ok) => setTimeout(ok, 50));++ const again = await t.sse(`/overlay/${tok.json.secret}/events`);++ assert.strictEqual(again.statusCode, 200, 'a closed stream frees its place');++ again.close();++ open.forEach((s) => s.close());++ });++++ await check('unpaid-checkout floods: a supporter holds a bounded number of unpaid checkouts', async () => {++ const buyer = t.network.newUser('buyer');++ for (let k = 0; k < 3; k++) {++ const r = await t.call('POST', '/api/v1/checkout', { user: buyer, body: { creator: 'alex', amount: 100 + k, pay_with: 'checkout', provider: 'stripe' } });++ assert.strictEqual(r.status, 201, r.text);++ }++ const over = await t.call('POST', '/api/v1/checkout', { user: buyer, body: { creator: 'alex', amount: 200, pay_with: 'checkout', provider: 'stripe' } });++ assert.strictEqual(over.status, 429);++ assert.strictEqual(over.json.code, 'tips.too_many_pending');++ billing.fund(buyer.subject, 100);++ assert.strictEqual((await t.call('POST', '/api/v1/checkout', { user: buyer, body: { creator: 'alex', amount: 50 } })).status, 201, 'paying from credit is not limited');++ });++++ await check('impersonation: nobody tips under the creator\'s own name or handle', async () => {++ for (const name of ['Alex', 'alex', '@alex', 'ALEX', 'alex']) {++ const r = await t.call('POST', '/api/v1/checkout', { user: viewer, body: { creator: 'alex', amount: 5, supporter_name: name } });++ assert.strictEqual(r.status, 422, `${JSON.stringify(name)} → ${r.status}`);++ assert.strictEqual(r.json.code, 'tips.name_taken');++ }++ const anon = await t.call('POST', '/api/v1/checkout', { user: viewer, body: { creator: 'alex', amount: 5, supporter_name: 'Alex', privacy: { anonymous: true } } });++ assert.strictEqual(anon.status, 201, 'the name is never shown when anonymous');++ });++++ await check('invisible and direction-override characters are dropped; TTS text carries no markup', async () => {++ const r = await t.call('POST', '/api/v1/tts-requests', { user: viewer, body: {++ creator: 'alex', amount: 150, supporter_name: 'Viewer', message: 'hi there',++ tts: { text: '<speak><break time="9s"/>hello world</speak>' },++ } });++ assert.strictEqual(r.status, 201, r.text);++ const i = domain.interactions.get(r.json.interaction.id);++ assert.strictEqual(i.supporter_name, 'Viewer');++ assert.strictEqual(i.message, 'hi there');++ await domain.effects.drain();++ const job = t.adapters.test.jobs.find((j) => j.interaction.id === i.id && j.effect === 'tts');++ assert.ok(!/[<>]/.test(job.tts.text), job.tts.text);++ assert.ok(!/[]/.test(JSON.stringify(job)));++ });++++ await check('an overlay secret is shown once: an Idempotency-Key replay does not return it, the database never holds it', async () => {++ const first = await t.call('POST', '/api/v1/overlay-tokens', { user: alex, body: { label: 'replay' }, key: 'overlay-key-0001' });++ assert.match(first.json.secret, /^tovl_/);++ const replay = await t.call('POST', '/api/v1/overlay-tokens', { user: alex, body: { label: 'replay' }, key: 'overlay-key-0001' });++ assert.strictEqual(replay.headers.get('idempotent-replayed'), 'true');++ assert.strictEqual(replay.json.token.id, first.json.token.id);++ assert.strictEqual(replay.json.secret, null);++ const everything = JSON.stringify(t.db.prepare('SELECT * FROM api_idempotency').all()) + JSON.stringify(t.db.prepare('SELECT * FROM overlay_tokens').all());++ assert.ok(!everything.includes(first.json.secret));++ });++++ await t.close();++ done();++})().catch((e) => { console.error(e); process.exit(1); });
@@ -62,12 +62,16 @@ TIPS_EVENTS_SECRET=
@@ -62,12 +62,16 @@ TIPS_EVENTS_SECRET=
#TIPS_OVERLAY_TTL_MS=600000
#TIPS_OVERLAY_TTL_MS=600000
#TIPS_OVERLAY_HEARTBEAT_MS=25000
#TIPS_OVERLAY_HEARTBEAT_MS=25000
#TIPS_OVERLAY_REPLAY_LIMIT=50
#TIPS_OVERLAY_REPLAY_LIMIT=50
+# Open event streams per overlay token (more answer 429)
+#TIPS_OVERLAY_MAX_STREAMS=10
# ── Limits ──────────────────────────────────────────────────
# ── Limits ──────────────────────────────────────────────────
#TIPS_MAX_BITS=10000000
#TIPS_MAX_BITS=10000000
#TIPS_MESSAGE_CHARS=300
#TIPS_MESSAGE_CHARS=300
#TIPS_TTS_HARD_CAP=1200
#TIPS_TTS_HARD_CAP=1200
#TIPS_MEDIA_HOSTS=youtube.com,www.youtube.com,m.youtube.com,music.youtube.com,youtu.be
#TIPS_MEDIA_HOSTS=youtube.com,www.youtube.com,m.youtube.com,music.youtube.com,youtu.be
+# Unpaid checkouts one supporter may hold within a day (more answer 429)
+#TIPS_MAX_PENDING_CHECKOUTS=20
# ── Background jobs: effects worker, due transfers, overlay window, outbox relay ──
# ── Background jobs: effects worker, due transfers, overlay window, outbox relay ──
#TIPS_JOBS=on
#TIPS_JOBS=on
@@ -258,6 +258,21 @@ goals, overlay links shown once, alert settings, simulation, totals, recent tips
@@ -258,6 +258,21 @@ goals, overlay links shown once, alert settings, simulation, totals, recent tips
Signed-in forms carry an HMAC anti-forgery token and a per-render nonce. Shared chrome: Network
Signed-in forms carry an HMAC anti-forgery token and a per-render nonce. Shared chrome: Network
`navbar.js`, `openvibe-shared` app icon, SSR footer and `<noscript>` navigation.
`navbar.js`, `openvibe-shared` app icon, SSR footer and `<noscript>` navigation.
+## Security
+
+[docs/threat-review.md](docs/threat-review.md) is the written threat review: overlays (tokenised URLs),
+paid-message abuse, TTS, amount spoofing, replay and privacy, with each finding's status and the test
+that shows it. The fixes it made:
+- a Billing donation settles an interaction only when amount, creator and supporter match;
+- a replayed `POST /overlay-tokens` answer carries no secret, and none is stored;
+- open streams per overlay token (`TIPS_OVERLAY_MAX_STREAMS`) and unpaid checkouts per supporter
+ (`TIPS_MAX_PENDING_CHECKOUTS`) are bounded;
+- the creator's own name is refused as a supporter name;
+- invisible and direction-override characters are dropped, and TTS text carries no markup.
+
+The regressions are in `test/security.test.js`. It is an internal review; an independent one is still due
+before launch.
+
## Import and reconciliation
## Import and reconciliation
`scripts/import-live.js` reads a **copy** of Live's database (read-only) after Billing's own import, and
`scripts/import-live.js` reads a **copy** of Live's database (read-only) after Billing's own import, and
@@ -322,8 +337,8 @@ exist here (plan §12.12):
@@ -322,8 +337,8 @@ exist here (plan §12.12):
Events (Billing is in shadow and has sent no events; the production database is empty; the Live
Events (Billing is in shadow and has sent no events; the production database is empty; the Live
import has not been run);
import has not been run);
5. capability and event registration against OpenVibe.Contracts — ✔ v0.15.0, payload schemas in v0.30.2 (pinned);
5. capability and event registration against OpenVibe.Contracts — ✔ v0.15.0, payload schemas in v0.30.2 (pinned);
-6. a migration/seed strategy ✔, a security/threat review (not done beyond the tests' refusals), and
- sitemap/robots ✔ (no feed);
+6. a migration/seed strategy ✔, a written threat review ✔ ([docs/threat-review.md](docs/threat-review.md),
+ internal; an independent review is still due), and sitemap/robots ✔ (no feed);
7. acceptance tests proving the advertised functionality — ✔ (table above).
7. acceptance tests proving the advertised functionality — ✔ (table above).
The launch release removes the domain from `OpenVibe.Sites/sites.json`, switches routing and registers
The launch release removes the domain from `OpenVibe.Sites/sites.json`, switches routing and registers
@@ -7,6 +7,6 @@
@@ -7,6 +7,6 @@
"adr": "ADR-012",
"adr": "ADR-012",
"authoritative": false,
"authoritative": false,
"deployment": "Deployed internally, not launched: openvibe-ovh /opt/openvibe.tips (release 435f32b), unit openvibe-tips, 127.0.0.1:4610 loopback only; https://openvibe.tips still serves the OpenVibe.Sites placeholder. Principal tips; Events subscription billing.transaction.* -> /internal/events; TIPS_CHAT_ADAPTER=none. Pins openvibe-contracts v0.15.0, openvibe-sdk v0.4.0, openvibe-shared v1.2.1.",
"deployment": "Deployed internally, not launched: openvibe-ovh /opt/openvibe.tips (release 435f32b), unit openvibe-tips, 127.0.0.1:4610 loopback only; https://openvibe.tips still serves the OpenVibe.Sites placeholder. Principal tips; Events subscription billing.transaction.* -> /internal/events; TIPS_CHAT_ADAPTER=none. Pins openvibe-contracts v0.15.0, openvibe-sdk v0.4.0, openvibe-shared v1.2.1.",
- "notes": "Runtime, API, SSR pages, overlays, Billing settlement consumer, events outbox, Live import and reconciliation are built and tested against stubs. Capabilities (17 tips.*) and the service manifest are registered in OpenVibe.Contracts v0.15.0; the six tips.* event payload schemas are in v0.30.2, which the repository pins since 2026-09-23 (production still runs v0.15.0). Production database is empty (0 profiles, interactions, goals, overlay tokens); the Live import (scripts/import-live.js) has never been run; 0 billing events have ever arrived because Billing runs in shadow. Live's /internal/tips/deliveries route is deployed (Live f11f809) but never called. The restore drill passed on the host 2026-09-23 05:23 UTC. Built since (not deployed yet): /metrics (loopback only) and the Billing/Events checks in /api/ready. Built since (not deployed yet): supporter privacy (anonymous, hidden amount, private message) across API, overlays, chat jobs, pages and events; the creator's public goal and supporters page settings; a supporter's export and erasure (tips.interaction.erased, schema proposed in docs/events-proposal/, not yet in openvibe-contracts). Moderation (not deployed yet): the creator's word filter (mask or hold for review) before anything is shown or read, hide/show by the creator, moderators (invitation links) or services, tips.interaction.moderated (schema and the tips.interaction.moderate capability proposed in docs/, not yet in openvibe-contracts). Not done: an independent security review; the openvibe.tips launch. Live stays the place tips happen until the Billing cutover (blocked on the owner re-pointing the PowerChat webhook) and the launch rule in README.md holds.",
+ "notes": "Runtime, API, SSR pages, overlays, Billing settlement consumer, events outbox, Live import and reconciliation are built and tested against stubs. Capabilities (17 tips.*) and the service manifest are registered in OpenVibe.Contracts v0.15.0; the six tips.* event payload schemas are in v0.30.2, which the repository pins since 2026-09-23 (production still runs v0.15.0). Production database is empty (0 profiles, interactions, goals, overlay tokens); the Live import (scripts/import-live.js) has never been run; 0 billing events have ever arrived because Billing runs in shadow. Live's /internal/tips/deliveries route is deployed (Live f11f809) but never called. The restore drill passed on the host 2026-09-23 05:23 UTC. Built since (not deployed yet): /metrics (loopback only) and the Billing/Events checks in /api/ready. Built since (not deployed yet): supporter privacy (anonymous, hidden amount, private message) across API, overlays, chat jobs, pages and events; the creator's public goal and supporters page settings; a supporter's export and erasure (tips.interaction.erased, schema proposed in docs/events-proposal/, not yet in openvibe-contracts). Moderation (not deployed yet): the creator's word filter (mask or hold for review) before anything is shown or read, hide/show by the creator, moderators (invitation links) or services, tips.interaction.moderated (schema and the tips.interaction.moderate capability proposed in docs/, not yet in openvibe-contracts). A written internal threat review (docs/threat-review.md) with its fixes and test/security.test.js. Not done: an independent security review; the openvibe.tips launch. Live stays the place tips happen until the Billing cutover (blocked on the owner re-pointing the PowerChat webhook) and the launch rule in README.md holds.",
"updated": "2026-09-23"
"updated": "2026-09-23"
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,211 @@
@@ -0,0 +1,211 @@
+# OpenVibe.Tips threat review
+
+**Date:** 2026-09-23. **Code reviewed:** `main` at the privacy and moderation commits (`2e79f74`, `c428a28`)
+and the fixes that came with this review. **Author:** the OpenVibers team, with an AI coding agent.
+This is a written internal review, not an independent audit. The roadmap's gate still asks for an
+outside review before launch.
+
+**Method:** we read the code paths end to end (`server/`, `public/js/overlay.js`,
+`deploy/nginx/openvibe.tips.conf`), and Live's `/internal/tips/deliveries` where Tips depends on it.
+Every claim below is backed by a test in `test/` or names the line that enforces it. Nothing was run
+against production. Production runs Tips on loopback only, with an empty database, so none of these
+issues was ever reachable from the internet.
+
+## Scope and trust boundaries
+
+| Party | Holds | Can reach Tips through |
+|---|---|---|
+| Anyone | nothing | public pages, `GET /api/v1` public reads, `/overlay/<token>` (only with a token) |
+| Signed-in supporter | a Network user JWT (`ov_token` cookie on the pages, Bearer on the API) | tip form and API for their own tips, receipts, export, erase |
+| Creator | the same, for their own profile | dashboard, goals, overlay tokens, filter, moderators, moderation |
+| Moderator | a Network user JWT plus a `tip_moderators` row | `/moderate/<handle>`, hide/restore API for that creator only |
+| OBS / an overlay client | an overlay token in the URL | `/overlay/<token>`, `/events` (SSE), `/state` |
+| First-party service | a Network service token with `tips.*` capabilities | `/api/v1` routes, one capability each |
+| OpenVibe.Billing (via Events) | the webhook signing secret | `POST /internal/events` on loopback |
+| OpenVibe.Live | Tips' service token (`live.tips_delivery.write`) | Tips calls Live; Live never calls Tips |
+
+**What we protect:**
+
+- Tips' records must stay consistent with Billing's books. Tips never moves money.
+- What reaches a creator's stream: overlays, chat lines and text-to-speech.
+- Overlay tokens.
+- Supporters' identities and choices.
+
+## Findings
+
+Status: **fixed** in this review, **holds** (checked and already enforced), **residual** (accepted and
+documented), **elsewhere** (the fix belongs to another repository).
+
+| # | Area | Threat | Status | Evidence |
+|---|---|---|---|---|
+| 1 | Amount spoofing | A Billing donation that names a pending Tips interaction settled it whatever its amount, sender or recipient. A 1-bit transfer from any service holding `billing.transfer.create` could "pay" a 10,000-bit paid message. | **fixed** | `onBillingSettled` settles only on an exact match of amount, creator and supporter; anything else is recorded as its own donation, so totals still reconcile. `test/security.test.js` |
+| 2 | Overlays | The overlay token's secret was stored in plain text in `api_idempotency`, because the `POST /overlay-tokens` answer was kept for replays. That undid hashing at rest. | **fixed** | The stored answer has `secret: null`; a replay returns the token without its secret. `test/security.test.js` |
+| 3 | Overlays | A leaked overlay link could open unlimited SSE streams, exhausting sockets and memory. | **fixed** | At most `TIPS_OVERLAY_MAX_STREAMS` (10) streams per token; more get a 429. `test/security.test.js` |
+| 4 | Paid-message abuse | Unpaid checkouts were unbounded: each creates a row and a Billing intent. | **fixed** | At most `TIPS_MAX_PENDING_CHECKOUTS` (20) unpaid checkouts per supporter per day (429). `test/security.test.js` |
+| 5 | Paid-message abuse | A supporter could tip under the creator's own name or handle (impersonation on stream). | **fixed** | Refused with 422 `tips.name_taken`, after NFKC folding and invisible-character removal. `test/security.test.js` |
+| 6 | Paid-message abuse | Zero-width and direction-override characters were kept. They can hide a word from a filter or turn a line around on stream. | **fixed** | Dropped from names and texts at input (`util.js` `INVISIBLE`) and again at display (`filter.js clean`). `test/security.test.js`, `test/moderation.test.js` |
+| 7 | TTS | TTS text could carry markup that a speech engine might read as SSML. | **fixed** | `<` and `>` are removed from TTS text, next to the existing removal of control characters and links. Live's engine takes plain text today, so this is defence in depth. `test/security.test.js` |
+| 8 | Paid-message abuse, TTS | No way to keep a word off stream, and no way to take a paid message back. | **fixed** | Creator word filter (mask or hold) and hide/restore by the creator and their moderators, with the money untouched. `test/moderation.test.js` |
+| 9 | Privacy | Supporters could not be anonymous, hide the amount or keep a message private, and could not export or erase their data. | **fixed** | Privacy controls, export and erasure. `test/privacy.test.js` |
+| 10 | Privacy | Stored API answers, which carry messages and subjects, were kept forever. | **fixed** | Pruned after 7 days, and a supporter's erasure removes theirs at once. `api/idempotency.js pruneAnswers` |
+| 11 | Overlays | Moderator invitation links, like overlay links, carry a secret in the path. | **holds** | Shown once, hashed, single use, expire after 7 days; `no-store`, `no-referrer`; nginx does not log `/moderate/invite/`. `test/moderation.test.js` |
+| 12 | Replay | Webhook, API, form, overlay and event replays. | **holds** | See [Replay](#replay). `test/settlement.test.js`, `test/overlays.test.js`, `test/api.test.js`, `test/pages.test.js` |
+| 13 | Privacy | A hidden amount still moves a public goal bar by that amount. | **residual** | Stated on the tip form. The goal update of such a tip names nobody. |
+| 14 | Privacy | Internal events carry the amount, and the supporter's subject when the tip is not anonymous. | **residual** | The events are `internal` visibility. Goals and reconciliation need the amount. Erasure redacts them in Events. |
+| 15 | Privacy, chat | Live's delivery route turns a hidden amount (`null`) into a donation event of 0 Vibes, and Live's chat renders it as "donated 0 Vibes". Live also remembers delivery ids in memory only. | **elsewhere** (Live) | The job carries `privacy.hide_amount`. Live should render a line with no amount. |
+| 16 | Overlays | Overlay tokens never expire. | **residual** | The creator can revoke a token, and revocation closes open streams at once. Every token records when it was last used. |
+| 17 | Rate limits | Tips has no rate limiting of its own. | **residual** | The nginx reference config limits `/api/`, `/auth/` and the form posts. Money limits paid actions, and fixes 3 and 4 bound the rest. |
+| 18 | Paid-message abuse | Supporter names are still free text: only an exact copy of the creator's name is refused. Donor names that Billing relays from PowerChat are not checked. | **residual** | The word filter and moderation cover names. |
+
+## Overlays (tokenised URLs)
+
+- **Token strength and storage.** A token is `tovl_` followed by 32 random bytes (256 bits). Tips stores
+ only its SHA-256 and shows the secret once. It is never a cookie: OBS holds it in the URL and nothing
+ else. A creator can have at most 25 active tokens. Each token carries scopes (`alerts`, `goals`) and
+ optionally a config (`domain/overlays.js`).
+- **Leak paths.**
+ - Referer: overlay responses send `Referrer-Policy: no-referrer`, and the page adds `<meta name="referrer">`. The creator's sound and image URLs therefore never see the token.
+ - Logs: nginx has `access_log off` for `/overlay/`. Tips' metrics use route templates and skip the SSE route. Tips never logs a request path.
+ - Caches and search engines: overlay responses are `no-store` and `X-Robots-Tag: noindex`, and robots.txt disallows `/overlay/`.
+ - Stored API answers: fixed (#2).
+ - What remains: the URL sits in OBS scene files and on screen when the creator shares their screen. The dashboard warns about this. The fix is revocation, which is immediate.
+- **Revocation** marks the row and closes every open stream of that token in the same call. After that,
+ `/events` and `/state` answer 404 (`test/overlays.test.js`).
+- **What a token reads.** Only public views: an alert payload is `publicView()`, the same shape the
+ public sees. That means privacy choices, the word filter and moderation all apply. `/state` lists the
+ last 20 alerts that are not hidden, plus active goals. Nothing else about the creator is readable, and
+ `/state` has no CORS header, so other sites' scripts cannot read it.
+- **Rendering.** `overlay.js` builds every node with `textContent`. Image and sound URLs are https URLs
+ that only the creator sets. The page keeps the site CSP: `default-src 'self'`, `img-src`/`media-src`
+ https, and `frame-ancestors 'self'`.
+- **Floods.** Fixed (#3). Tips holds no per-connection state beyond the socket and a heartbeat timer.
+- **Moderation reaches overlays.** Hiding an alert sends `retract` to connected overlays, which drop it
+ from the queue or clear it from the screen and stop its voice. Replays and `/state` skip it from then on
+ (`test/moderation.test.js`).
+
+## Paid-message abuse
+
+- **Bounds.**
+ - Messages: 300 characters.
+ - TTS: the creator's limit, at most 1,200 characters.
+ - Media requests: https links on an allowlist of YouTube hosts only, at most 500 characters.
+ - The creator sets minimum amounts per kind. Money limits the volume, and fix #4 bounds the unpaid rest.
+- **Content control, all before anything is shown or read.**
+ - The creator's word filter (`filter.js`) matches whole words and phrases in any letter case. It applies
+ after NFKC folding (full-width letters) and after removing invisible characters.
+ - `mask` stars the words out; `hold` holds the message for review.
+ - Links show as `[link]` by default.
+ - Moderators hide a paid message later: queued chat, TTS and media deliveries are cancelled, and the
+ overlay alert is retracted. The effects worker re-reads each effect before delivering it, so a hide
+ during a batch is honoured.
+- **Injection.**
+ - Pages escape everything with `esc()`, and JSON-LD escapes `<`.
+ - The overlay uses `textContent`.
+ - Chat lines are plain text that Tips builds. Live renders them with its own escaping, which is Live's
+ responsibility.
+- **Impersonation and Unicode tricks:** fixed (#5 and #6). Long runs of combining marks are cut at display.
+- **Media requests** cannot be private and go through moderation like paid messages. The URL is shown
+ as-is because the video plays on stream anyway.
+
+## TTS
+
+- Tips never calls a speech provider. It hands text to Live's `synthesizeAndBroadcastTTS`, and to
+ OpenVibe.Chat when an adapter exists.
+- The text passes through `cleanTts`, which removes control and invisible characters, turns links into
+ "link" and strips markup (#7). The creator's filter then drops blocked words from it rather than
+ starring them. Voices come from an allowlist.
+- The overlay's optional browser voice (`speak_message`) reads the same filtered public text.
+- A held message is never read until it is approved. A hide cancels a TTS that is still queued, and
+ `retract` stops an overlay voice that is already speaking.
+- A TTS failure never touches the payment: the effect retries or fails on its own
+ (`test/delivery.test.js`).
+
+## Amount spoofing
+
+- **Tips never sets a price or moves money.** Amounts are integer bits, 1 to 10,000,000, validated by
+ `positiveInt`.
+- **Credit tips.** Billing transfers exactly `interaction.amount` from the supporter's credit, and refuses
+ when funds are short.
+- **Checkout tips.** The Billing intent is for exactly that amount. The purchase only funds the supporter,
+ and the same transfer then gives the money.
+- **Keyed transfers.** Transfers use the key `tips:transfer:<id>` and Tips reads the settled answer from
+ its own call, so a client can never make Tips credit more than Billing moved.
+- **Donations that name an interaction** must match it exactly (#1).
+- **EXTERNAL tips.** Their amounts come from Billing's signed `billing.receipt.external` or from a service
+ holding `tips.interaction.record`. They stay out of Billing totals.
+- **Goals** count contributions at settlement only, never tests. A reversal lowers them by what Billing
+ took back.
+- **Simulations** are flagged `test` everywhere and never counted (`test/overlays.test.js`).
+
+## Replay
+
+- **Billing webhook.**
+ - HMAC signature v2 over `<t>.<raw body>`, with a ±300 s window. v1-only deliveries are refused.
+ - The signing secrets rotate as a list, and `source` must be `billing`.
+ - `/internal` refuses anything forwarded by nginx.
+ - The openvibe-sdk inbox claims `(consumer, event_id)` in the same transaction as the change.
+ `billing_txn_id` is UNIQUE, so the same transaction under a new event id is still one interaction
+ (`test/settlement.test.js`).
+- **API.** An `Idempotency-Key` is required on every write, scoped to the caller and hashed with the
+ request. A replay returns the stored answer, without secrets (#2). A reused key with a different body
+ gets 422 (`test/api.test.js`).
+- **Forms.** An HMAC anti-forgery token (subject and day), an Origin check, `SameSite=Lax` cookies and a
+ per-render nonce that makes a double submit one tip (`test/pages.test.js`).
+- **Overlay replay** with `Last-Event-ID` writes bytes to a socket and nothing else: no charge, no goal
+ count, no event (`test/overlays.test.js`).
+- **Moderation actions** are idempotent: hiding twice publishes once. Invitation links work once.
+
+## Privacy
+
+- **Supporter choices** (`privacy.js`), stored per interaction:
+ - `anonymous`: nobody but the supporter sees their name or subject. That includes the creator,
+ moderators, services and events.
+ - `hide_amount`: no amount on overlays, chat lines or public pages, and off the leaderboard.
+ - `private_message`: the message goes to the creator only.
+- **One public shape.** `publicView()` is the only thing that leaves Tips for the public
+ (`test/privacy.test.js`).
+- **Creator choices.** What the public goal and supporters pages show. The supporters page is off by
+ default. The leaderboard counts only supporters who left both their name and their amount public, so a
+ rank never reveals a hidden amount.
+- **Export and erasure.** `GET /me/export` returns everything Tips holds about the supporter's tips.
+ `POST /me/erase` removes:
+ - the subject, name and messages;
+ - TTS text and media links;
+ - checkout references;
+ - stored answers;
+ - overlay payloads;
+ - unsent events.
+
+ It keeps the money record, so Billing and the creators' totals still reconcile. It emits
+ `tips.interaction.erased` with `redacts`, and Events then turns Tips' earlier events about that
+ interaction into tombstones. Billing keeps its own payment records under its own policy, and copies
+ that other products already made (a Live chat line) stay theirs to delete.
+- **Retention.**
+ - Stored API answers: 7 days.
+ - Sent outbox rows: pruned after 7 days (openvibe-sdk).
+ - Overlay deliveries: kept, holding public views only.
+ - Moderation log: kept for the creator's accountability; it names the moderator.
+- **Moderators** see what was written, but never private messages, hidden amounts or payments.
+ `tips.interaction.moderated` carries no supporter, message, note or moderator.
+- **Pages.** Receipts, the dashboard, the moderation pages and invitation pages are `noindex` and
+ disallowed in robots.txt.
+
+## Contract additions this depends on
+
+| Addition | Why | Where it is drafted |
+|---|---|---|
+| Event `tips.interaction.moderated` v1 | moderation outcomes | `docs/events-proposal/tips.interaction.moderated.v1.json` |
+| Event `tips.interaction.erased` v1 | a supporter's erasure, which carries `redacts` | `docs/events-proposal/tips.interaction.erased.v1.json` |
+| Capability `tips.interaction.moderate` | services that moderate for a creator | `docs/capabilities-proposal/tips.interaction.moderate.json` |
+
+OpenVibe.Events accepts both event types today, because it checks the `tips.` prefix and not a catalog.
+Consumers cannot validate their payloads against openvibe-contracts until the schemas are added. The
+tests validate them against the drafts.
+
+## Follow-ups
+
+1. **Live:** render a tip whose amount is hidden (`privacy.hide_amount`, `interaction.amount: null`)
+ without an amount, and persist the delivery idempotency key (#15).
+2. **Contracts:** add the three items above, then register them in `manifests/services/tips.json`.
+3. **Before launch:** get an independent review of this document and the code. Consider optional overlay
+ token expiry (#16).
@@ -39,8 +39,11 @@ function idempotent(db, now) {
@@ -39,8 +39,11 @@ function idempotent(db, now) {
const json = res.json.bind(res);
const json = res.json.bind(res);
res.json = (body) => {
res.json = (body) => {
if (res.statusCode >= 200 && res.statusCode < 300) {
if (res.statusCode >= 200 && res.statusCode < 300) {
+ // A handler whose answer carries a secret (an overlay token) sets res.locals.storedBody: the
+ // replay gets that instead, so no secret is kept in plain text here.
+ const stored = res.locals.storedBody !== undefined ? res.locals.storedBody : body;
db.prepare('INSERT OR IGNORE INTO api_idempotency (key, request_hash, method, path, status, response, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)')
db.prepare('INSERT OR IGNORE INTO api_idempotency (key, request_hash, method, path, status, response, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)')
- .run(scoped, hash, req.method, `${req.baseUrl}${req.path}`, res.statusCode, JSON.stringify(body), new Date(now()).toISOString());
+ .run(scoped, hash, req.method, `${req.baseUrl}${req.path}`, res.statusCode, JSON.stringify(stored), new Date(now()).toISOString());
}
}
return json(body);
return json(body);
};
};
@@ -286,8 +286,9 @@ function v1Router({ domain, apiAuth }) {
@@ -286,8 +286,9 @@ function v1Router({ domain, apiAuth }) {
allow(req, CAP.tokenCreate, profile.creator_subject);
allow(req, CAP.tokenCreate, profile.creator_subject);
const by = req.principal.kind === 'service' ? req.principal.sub : req.principal.subject;
const by = req.principal.kind === 'service' ? req.principal.sub : req.principal.subject;
const out = overlays.createToken(profile.creator_subject, { scopes: b.scopes, label: b.label, configId: b.config_id, createdBy: by });
const out = overlays.createToken(profile.creator_subject, { scopes: b.scopes, label: b.label, configId: b.config_id, createdBy: by });
- // Shown once: only its hash is stored. An idempotent replay of this call returns it again to
- // the same caller with the same key, which is the point of the key.
+ // Shown once: only its hash is stored, and the stored answer for an Idempotency-Key replay has no
+ // secret either (a caller who lost the answer revokes the token and creates another).
+ res.locals.storedBody = { token: out.token, secret: null, overlay_url: null, events_url: null, note: 'the secret is shown once; revoke this token and create another' };
res.status(201).json({ token: out.token, secret: out.secret, overlay_url: out.overlay_url, events_url: out.events_url });
res.status(201).json({ token: out.token, secret: out.secret, overlay_url: out.overlay_url, events_url: out.events_url });
}));
}));
r.post('/overlay-tokens/:id/revoke', ...write((req, res) => {
r.post('/overlay-tokens/:id/revoke', ...write((req, res) => {
@@ -89,6 +89,8 @@ function loadConfig(env = process.env) {
@@ -89,6 +89,8 @@ function loadConfig(env = process.env) {
ttlMs: int(env.TIPS_OVERLAY_TTL_MS, 10 * 60 * 1000),
ttlMs: int(env.TIPS_OVERLAY_TTL_MS, 10 * 60 * 1000),
heartbeatMs: int(env.TIPS_OVERLAY_HEARTBEAT_MS, 25_000),
heartbeatMs: int(env.TIPS_OVERLAY_HEARTBEAT_MS, 25_000),
replayLimit: int(env.TIPS_OVERLAY_REPLAY_LIMIT, 50),
replayLimit: int(env.TIPS_OVERLAY_REPLAY_LIMIT, 50),
+ // Open event streams per overlay token: an OBS scene or two, not a leaked link's flood.
+ maxStreamsPerToken: int(env.TIPS_OVERLAY_MAX_STREAMS, 10),
},
},
limits: {
limits: {
@@ -96,6 +98,7 @@ function loadConfig(env = process.env) {
@@ -96,6 +98,7 @@ function loadConfig(env = process.env) {
messageChars: int(env.TIPS_MESSAGE_CHARS, 300), // Live's donation message limit
messageChars: int(env.TIPS_MESSAGE_CHARS, 300), // Live's donation message limit
ttsHardCap: int(env.TIPS_TTS_HARD_CAP, 1200), // Live's hard TTS length cap
ttsHardCap: int(env.TIPS_TTS_HARD_CAP, 1200), // Live's hard TTS length cap
goalTitleChars: 120, // Live's goal title limit
goalTitleChars: 120, // Live's goal title limit
+ pendingCheckouts: int(env.TIPS_MAX_PENDING_CHECKOUTS, 20), // unpaid checkouts per supporter per day
},
},
jobs: {
jobs: {
@@ -38,7 +38,7 @@
@@ -38,7 +38,7 @@
* export their tips and erase their data from them (erase(): the money record stays, the person
* export their tips and erase their data from them (erase(): the money record stays, the person
* goes; tips.interaction.erased redacts the earlier events in OpenVibe.Events).
* goes; tips.interaction.erased redacts the earlier events in OpenVibe.Events).
*/
*/
-const { fail, iso, prefixedId, json, positiveInt, text, userSubject, entityRef, displayName } = require('../util');
+const { fail, iso, prefixedId, json, positiveInt, text, userSubject, entityRef, displayName, INVISIBLE } = require('../util');
const { VOICES } = require('./profiles');
const { VOICES } = require('./profiles');
const { parsePrivacy, publicName, shownName, privacyOf, isAnonymous } = require('./privacy');
const { parsePrivacy, publicName, shownName, privacyOf, isAnonymous } = require('./privacy');
const { BillingCallError } = require('../billing-client');
const { BillingCallError } = require('../billing-client');
@@ -61,8 +61,18 @@ function createInteractions(ctx) {
@@ -61,8 +61,18 @@ function createInteractions(ctx) {
const byBillingTxn = (txnId) => db.prepare('SELECT * FROM tip_interactions WHERE billing_txn_id = ?').get(txnId) || null;
const byBillingTxn = (txnId) => db.prepare('SELECT * FROM tip_interactions WHERE billing_txn_id = ?').get(txnId) || null;
// ── Input rules (the creator's settings) ─────────────────
// ── Input rules (the creator's settings) ─────────────────
+ // Plain words only: no control or invisible characters, no links, no markup a speech engine could
+ // take as SSML.
function cleanTts(s) {
function cleanTts(s) {
- return String(s || '').replace(/[\u0000-\u001f]/g, ' ').replace(/https?:\/\/\S+/gi, 'link').replace(/\s+/g, ' ').trim();
+ return String(s || '').replace(INVISIBLE, '').replace(/[\u0000-\u001f]/g, ' ').replace(/https?:\/\/\S+/gi, 'link').replace(/[<>]/g, ' ')
+ .replace(/\s+/g, ' ').trim();
+ }
+
+ /** A supporter may not show up under the creator's own name or handle (impersonation on stream). */
+ function checkName(profile, name) {
+ const fold = (v) => String(v || '').normalize('NFKC').replace(/^@/, '').trim().toLowerCase();
+ const n = fold(name);
+ if (n && (n === fold(profile.display_name) || n === fold(profile.handle))) fail(422, 'tips.name_taken', `"${name}" is ${profile.display_name}'s own name; choose another to show`);
}
}
function mediaUrl(v) {
function mediaUrl(v) {
@@ -150,11 +160,18 @@ function createInteractions(ctx) {
@@ -150,11 +160,18 @@ function createInteractions(ctx) {
}
}
const target = entityRef(input.target);
const target = entityRef(input.target);
const privacy = parsePrivacy(input.privacy, v.kind);
const privacy = parsePrivacy(input.privacy, v.kind);
+ if (!privacy.anonymous) checkName(profile, displayName(supporterName));
return ctx.tx(() => {
return ctx.tx(() => {
if (idempotencyKey) {
if (idempotencyKey) {
const prev = db.prepare('SELECT * FROM tip_interactions WHERE idempotency_key = ?').get(idempotencyKey);
const prev = db.prepare('SELECT * FROM tip_interactions WHERE idempotency_key = ?').get(idempotencyKey);
if (prev) return { interaction: prev, replay: true };
if (prev) return { interaction: prev, replay: true };
}
}
+ if (funding === 'checkout') {
+ // Each unpaid checkout is a Billing intent and a row here: bounded per supporter.
+ const since = iso(ctx.now() - 24 * 3600 * 1000);
+ const open = db.prepare("SELECT COUNT(*) AS n FROM tip_interactions WHERE supporter_subject = ? AND funding = 'checkout' AND payment_state = 'pending' AND created_at >= ?").get(from, since).n;
+ if (open >= config.limits.pendingCheckouts) fail(429, 'tips.too_many_pending', `you have ${open} unpaid checkouts; finish or wait for them before starting another`);
+ }
const i = insert({
const i = insert({
id: prefixedId('tint', ctx.now()), creator_subject: profile.creator_subject, supporter_subject: from, supporter_name: displayName(supporterName) || 'Someone',
id: prefixedId('tint', ctx.now()), creator_subject: profile.creator_subject, supporter_subject: from, supporter_name: displayName(supporterName) || 'Someone',
kind: v.kind, amount: v.amount, message: v.message, request: v.request, funding, settlement: 'billing', payment_state: 'pending',
kind: v.kind, amount: v.amount, message: v.message, request: v.request, funding, settlement: 'billing', payment_state: 'pending',
@@ -326,8 +343,13 @@ function createInteractions(ctx) {
@@ -326,8 +343,13 @@ function createInteractions(ctx) {
const target = meta.target;
const target = meta.target;
if (target && target.service === 'tips' && target.type === 'interaction') {
if (target && target.service === 'tips' && target.type === 'interaction') {
const i = get(target.id);
const i = get(target.id);
- if (i) { settleByTransaction(i, txn); return 'settled'; }
- return 'ignored:unknown_interaction';
+ if (!i) return 'ignored:unknown_interaction';
+ // Only the transfer Tips asked for settles the interaction: same amount, same creator, same
+ // supporter. Another service's donation that merely names it (a 1-bit transfer claiming a
+ // 10,000-bit paid message) is recorded as what it is, a donation of its own amount.
+ const matches = Number(meta.amount_bits) === i.amount && p.to_subject === i.creator_subject && (!i.supporter_subject || p.from_subject === i.supporter_subject);
+ if (matches) { settleByTransaction(i, txn); return 'settled'; }
+ ctx.log.warn(`[Tips] donation ${txn.id} names interaction ${i.id} but does not match it (amount, creator or supporter); recorded on its own`);
}
}
// A donation Tips did not start: record it once, by its transaction id.
// A donation Tips did not start: record it once, by its transaction id.
if (!p.to_subject) return 'ignored:no_recipient';
if (!p.to_subject) return 'ignored:no_recipient';
@@ -329,6 +329,7 @@ function createOverlays(ctx) {
@@ -329,6 +329,7 @@ function createOverlays(ctx) {
}
}
function connected(creator) { return (clients.get(creator) || new Set()).size; }
function connected(creator) { return (clients.get(creator) || new Set()).size; }
+ function streamsFor(token) { let n = 0; for (const c of clients.get(token.creator_subject) || []) if (c.tokenId === token.id) n++; return n; }
function closeAll() { for (const set of clients.values()) for (const c of [...set]) detach(c); }
function closeAll() { for (const set of clients.values()) for (const c of [...set]) detach(c); }
function recentDeliveries(creator, limit = 50) {
function recentDeliveries(creator, limit = 50) {
@@ -338,7 +339,7 @@ function createOverlays(ctx) {
@@ -338,7 +339,7 @@ function createOverlays(ctx) {
return {
return {
SCOPES, createToken, getToken, listTokens, authenticate, revokeToken, presentToken,
SCOPES, createToken, getToken, listTokens, authenticate, revokeToken, presentToken,
getConfig, listConfigs, createConfig, updateConfig, presentConfig,
getConfig, listConfigs, createConfig, updateConfig, presentConfig,
- addAlert, refreshInteraction, retract, unretract, addGoalDelivery, sweepFailed, attach, detach, closeToken, notify, connected, closeAll, recentDeliveries,
+ addAlert, refreshInteraction, retract, unretract, addGoalDelivery, sweepFailed, attach, detach, closeToken, notify, connected, streamsFor, closeAll, recentDeliveries,
};
};
}
}
@@ -28,10 +28,14 @@ function positiveInt(v, field, max) {
@@ -28,10 +28,14 @@ function positiveInt(v, field, max) {
return n;
return n;
}
}
-/** Optional trimmed text: null when empty, refused when longer than max. */
+// Zero-width, bidi embedding/override/isolate marks, word joiner, BOM, soft hyphen: they hide words
+// from the creator's filter and turn text around on stream (docs/threat-review.md).
+const INVISIBLE = /[\u00AD\u200B-\u200F\u202A-\u202E\u2060-\u2064\u2066-\u2069\uFEFF]/g;
+
+/** Optional trimmed text: null when empty, refused when longer than max. Invisible characters are dropped. */
function text(v, field, max) {
function text(v, field, max) {
if (v == null) return null;
if (v == null) return null;
- const s = String(v).replace(/\r\n?/g, '\n').trim();
+ const s = String(v).replace(INVISIBLE, '').replace(/\r\n?/g, '\n').trim();
if (!s) return null;
if (!s) return null;
if (s.length > max) fail(422, 'tips.text_too_long', `${field} must be at most ${max} characters`);
if (s.length > max) fail(422, 'tips.text_too_long', `${field} must be at most ${max} characters`);
return s;
return s;
@@ -55,8 +59,8 @@ function entityRef(v, field = 'target') {
@@ -55,8 +59,8 @@ function entityRef(v, field = 'target') {
/** The display name a supporter chose, kept short, without markup characters. */
/** The display name a supporter chose, kept short, without markup characters. */
function displayName(v) {
function displayName(v) {
if (v == null) return null;
if (v == null) return null;
- const s = String(v).replace(/[<>\u0000-\u001f]/g, '').trim().slice(0, 80);
+ const s = String(v).replace(INVISIBLE, '').replace(/[<>\u0000-\u001f]/g, '').trim().slice(0, 80);
return s || null;
return s || null;
}
}
-module.exports = { TipsError, fail, prefixedId, iso, sha256, json, positiveInt, text, userSubject, entityRef, displayName };
+module.exports = { TipsError, fail, prefixedId, iso, sha256, json, positiveInt, text, userSubject, entityRef, displayName, INVISIBLE };
@@ -245,12 +245,16 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
@@ -245,12 +245,16 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
r.get('/overlay/:token/events', (req, res) => {
r.get('/overlay/:token/events', (req, res) => {
const t = tokenOr404(req, res);
const t = tokenOr404(req, res);
if (!t) return;
if (!t) return;
+ if (overlays.streamsFor(t) >= config.overlays.maxStreamsPerToken) {
+ res.status(429).set({ ...overlayHeaders, 'Retry-After': '30' }).type('text/plain').send('Too many open streams for this overlay link.');
+ return;
+ }
res.status(200).set({ ...overlayHeaders, 'Content-Type': 'text/event-stream; charset=utf-8', Connection: 'keep-alive', 'X-Accel-Buffering': 'no' });
res.status(200).set({ ...overlayHeaders, 'Content-Type': 'text/event-stream; charset=utf-8', Connection: 'keep-alive', 'X-Accel-Buffering': 'no' });
res.flushHeaders();
res.flushHeaders();
res.write('retry: 3000\n\n');
res.write('retry: 3000\n\n');
- const config = t.config_id ? overlays.getConfig(t.config_id) : null;
+ const overlayConfig = t.config_id ? overlays.getConfig(t.config_id) : null;
const lastEventId = req.get('last-event-id') != null ? req.get('last-event-id') : req.query.last_event_id;
const lastEventId = req.get('last-event-id') != null ? req.get('last-event-id') : req.query.last_event_id;
- overlays.attach(t, res, { lastEventId: lastEventId != null && lastEventId !== '' ? lastEventId : undefined, config });
+ overlays.attach(t, res, { lastEventId: lastEventId != null && lastEventId !== '' ? lastEventId : undefined, config: overlayConfig });
});
});
r.get('/overlay/:token/state', (req, res) => {
r.get('/overlay/:token/state', (req, res) => {
const t = tokenOr404(req, res);
const t = tokenOr404(req, res);
new file mode 100644
new file mode 100644
@@ -0,0 +1,108 @@
@@ -0,0 +1,108 @@
+'use strict';
+// Regressions for docs/threat-review.md: amount spoofing through a donation that merely names an
+// interaction, overlay stream floods, unpaid-checkout floods, impersonating the creator, invisible and
+// markup characters in paid messages, and overlay secrets kept in stored API answers.
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/app');
+
+(async () => {
+ const t = await boot({ env: { TIPS_MAX_PENDING_CHECKOUTS: '3', TIPS_OVERLAY_MAX_STREAMS: '3' } });
+ const { domain, billing } = t;
+ const alex = await t.creator('alex', { settings: { tts_enabled: true } });
+ const viewer = t.network.newUser('viewer');
+ const other = t.network.newUser('other');
+ billing.fund(viewer.subject, 10_000);
+ billing.fund(other.subject, 10_000);
+
+ await check('amount spoofing: a donation that names an interaction but does not match it never settles it', async () => {
+ const r = await t.call('POST', '/api/v1/paid-messages', { user: viewer, body: { creator: 'alex', amount: 5000, message: 'big one', pay_with: 'checkout', provider: 'stripe' } });
+ const id = r.json.interaction.id;
+ assert.strictEqual(r.json.interaction.payment.state, 'pending');
+ const target = { service: 'tips', type: 'interaction', id };
+ // 1 bit from the right supporter; the right amount from someone else; the right amount to someone else.
+ const cases = [
+ billing.foreignDonation({ from: viewer.subject, to: alex.subject, amount: 1, target }),
+ billing.foreignDonation({ from: other.subject, to: alex.subject, amount: 5000, target }),
+ billing.foreignDonation({ from: viewer.subject, to: other.subject, amount: 5000, target }),
+ ];
+ for (const ev of cases) {
+ const d = await t.deliver(ev);
+ assert.strictEqual(d.status, 200);
+ assert.strictEqual(d.json.outcome, 'recorded', 'recorded on its own');
+ }
+ const i = domain.interactions.get(id);
+ assert.strictEqual(i.payment_state, 'pending', 'the paid message is still unpaid');
+ assert.strictEqual(t.db.prepare('SELECT COUNT(*) AS n FROM paid_messages WHERE interaction_id = ?').get(id).n, 0);
+ const spoof = domain.interactions.byBillingTxn(cases[0].payload.transaction_id);
+ assert.strictEqual(spoof.amount, 1);
+ assert.strictEqual(spoof.origin, 'billing');
+ assert.strictEqual(domain.interactions.totals(alex.subject).settled_via_billing, billing.payable.get(alex.subject), 'still reconciles to Billing');
+ });
+
+ await check('overlay stream floods: a token opens a bounded number of streams at once', async () => {
+ const tok = await t.call('POST', '/api/v1/overlay-tokens', { user: alex, body: {} });
+ const open = [];
+ for (let k = 0; k < 3; k++) { const s = await t.sse(`/overlay/${tok.json.secret}/events`); assert.strictEqual(s.statusCode, 200); open.push(s); }
+ const extra = await fetch(`${t.base}/overlay/${tok.json.secret}/events`);
+ assert.strictEqual(extra.status, 429);
+ assert.strictEqual(extra.headers.get('referrer-policy'), 'no-referrer');
+ open.pop().close();
+ await new Promise((ok) => setTimeout(ok, 50));
+ const again = await t.sse(`/overlay/${tok.json.secret}/events`);
+ assert.strictEqual(again.statusCode, 200, 'a closed stream frees its place');
+ again.close();
+ open.forEach((s) => s.close());
+ });
+
+ await check('unpaid-checkout floods: a supporter holds a bounded number of unpaid checkouts', async () => {
+ const buyer = t.network.newUser('buyer');
+ for (let k = 0; k < 3; k++) {
+ const r = await t.call('POST', '/api/v1/checkout', { user: buyer, body: { creator: 'alex', amount: 100 + k, pay_with: 'checkout', provider: 'stripe' } });
+ assert.strictEqual(r.status, 201, r.text);
+ }
+ const over = await t.call('POST', '/api/v1/checkout', { user: buyer, body: { creator: 'alex', amount: 200, pay_with: 'checkout', provider: 'stripe' } });
+ assert.strictEqual(over.status, 429);
+ assert.strictEqual(over.json.code, 'tips.too_many_pending');
+ billing.fund(buyer.subject, 100);
+ assert.strictEqual((await t.call('POST', '/api/v1/checkout', { user: buyer, body: { creator: 'alex', amount: 50 } })).status, 201, 'paying from credit is not limited');
+ });
+
+ await check('impersonation: nobody tips under the creator\'s own name or handle', async () => {
+ for (const name of ['Alex', 'alex', '@alex', 'ALEX', 'alex']) {
+ const r = await t.call('POST', '/api/v1/checkout', { user: viewer, body: { creator: 'alex', amount: 5, supporter_name: name } });
+ assert.strictEqual(r.status, 422, `${JSON.stringify(name)} → ${r.status}`);
+ assert.strictEqual(r.json.code, 'tips.name_taken');
+ }
+ const anon = await t.call('POST', '/api/v1/checkout', { user: viewer, body: { creator: 'alex', amount: 5, supporter_name: 'Alex', privacy: { anonymous: true } } });
+ assert.strictEqual(anon.status, 201, 'the name is never shown when anonymous');
+ });
+
+ await check('invisible and direction-override characters are dropped; TTS text carries no markup', async () => {
+ const r = await t.call('POST', '/api/v1/tts-requests', { user: viewer, body: {
+ creator: 'alex', amount: 150, supporter_name: 'Viewer', message: 'hi there',
+ tts: { text: '<speak><break time="9s"/>hello world</speak>' },
+ } });
+ assert.strictEqual(r.status, 201, r.text);
+ const i = domain.interactions.get(r.json.interaction.id);
+ assert.strictEqual(i.supporter_name, 'Viewer');
+ assert.strictEqual(i.message, 'hi there');
+ await domain.effects.drain();
+ const job = t.adapters.test.jobs.find((j) => j.interaction.id === i.id && j.effect === 'tts');
+ assert.ok(!/[<>]/.test(job.tts.text), job.tts.text);
+ assert.ok(!/[]/.test(JSON.stringify(job)));
+ });
+
+ await check('an overlay secret is shown once: an Idempotency-Key replay does not return it, the database never holds it', async () => {
+ const first = await t.call('POST', '/api/v1/overlay-tokens', { user: alex, body: { label: 'replay' }, key: 'overlay-key-0001' });
+ assert.match(first.json.secret, /^tovl_/);
+ const replay = await t.call('POST', '/api/v1/overlay-tokens', { user: alex, body: { label: 'replay' }, key: 'overlay-key-0001' });
+ assert.strictEqual(replay.headers.get('idempotent-replayed'), 'true');
+ assert.strictEqual(replay.json.token.id, first.json.token.id);
+ assert.strictEqual(replay.json.secret, null);
+ const everything = JSON.stringify(t.db.prepare('SELECT * FROM api_idempotency').all()) + JSON.stringify(t.db.prepare('SELECT * FROM overlay_tokens').all());
+ assert.ok(!everything.includes(first.json.secret));
+ });
+
+ await t.close();
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });