openvibers/openvibe.tips Commits

Threat review (docs/threat-review.md) and the gaps it closed

OP OpenVibers committed 23 Sept 2026 · fdb1657ba2 · 12 files, +393 −17

A written internal review of overlays (tokenised URLs), paid-message abuse, TTS, amount spoofing, replay and privacy: each finding's status (fixed, holds, residual, elsewhere) with the test or line that shows it, the contract additions Tips depends on, and follow-ups (Live renders a hidden amount as "donated 0 Vibes"; an independent review before launch). Fixed here:
- amount spoofing: a Billing donation that names a Tips interaction settles it only when amount, creator and supporter match; anything else is recorded as its own donation, so totals still reconcile;
- a POST /overlay-tokens answer is stored for Idempotency-Key replays without its secret (the secret was kept in plain text in api_idempotency);
- at most TIPS_OVERLAY_MAX_STREAMS (10) open streams per overlay token, at most TIPS_MAX_PENDING_CHECKOUTS (20) unpaid checkouts per supporter per day (429);
- the creator's own name or handle is refused as a supporter name (impersonation on stream);
- zero-width and direction-override characters are dropped from names and texts at input; TTS text loses < and >;
- the overlay events route names its overlay config apart from the app config, which the stream cap reads.
test/security.test.js holds the regressions.

Changes

ddiff --git a/.env.example b/.env.example
iindex 6754e3d..0ad4771 100644
---- a/.env.example
++++ b/.env.example
@@@ -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
ddiff --git a/README.md b/README.md
iindex 2eb26a4..7b96a07 100644
---- a/README.md
++++ b/README.md
@@@ -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
`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 Live
import 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
ddiff --git a/STATUS.json b/STATUS.json
iindex 223859f..3a68d84 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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"
}
ddiff --git a/docs/threat-review.md b/docs/threat-review.md
nnew file mode 100644
iindex 0000000..2247724
---- /dev/null
++++ b/docs/threat-review.md
@@@ -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).
ddiff --git a/server/api/idempotency.js b/server/api/idempotency.js
iindex b2d1162..4aa171b 100644
---- a/server/api/idempotency.js
++++ b/server/api/idempotency.js
@@@ -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);
};
ddiff --git a/server/api/v1.js b/server/api/v1.js
iindex 3a6017e..28205fd 100644
---- a/server/api/v1.js
++++ b/server/api/v1.js
@@@ -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) => {
ddiff --git a/server/config.js b/server/config.js
iindex 613b00a..d21a5b8 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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 limit
ttsHardCap: int(env.TIPS_TTS_HARD_CAP, 1200), // Live's hard TTS length cap
goalTitleChars: 120, // Live's goal title limit
++ pendingCheckouts: int(env.TIPS_MAX_PENDING_CHECKOUTS, 20), // unpaid checkouts per supporter per day
},
jobs: {
ddiff --git a/server/domain/interactions.js b/server/domain/interactions.js
iindex c178d1c..09853b0 100644
---- a/server/domain/interactions.js
++++ b/server/domain/interactions.js
@@@ -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';
ddiff --git a/server/domain/overlays.js b/server/domain/overlays.js
iindex edac2b8..295b494 100644
---- a/server/domain/overlays.js
++++ b/server/domain/overlays.js
@@@ -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,
};
}
ddiff --git a/server/util.js b/server/util.js
iindex d05dcc2..c018b58 100644
---- a/server/util.js
++++ b/server/util.js
@@@ -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 };
ddiff --git a/server/web/routes.js b/server/web/routes.js
iindex be6ac54..f7a72dc 100644
---- a/server/web/routes.js
++++ b/server/web/routes.js
@@@ -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);
ddiff --git a/test/security.test.js b/test/security.test.js
nnew file mode 100644
iindex 0000000..8c91c7d
---- /dev/null
++++ b/test/security.test.js
@@@ -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', 'al​ex']) {
++ 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: 'Vie‮wer', 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); });