Skip to content

feat(apps): signed launches, strict mode, verifiable payments and install counts; fix roaming CSRF - #1848

Open
qpprt wants to merge 5 commits into
OpenVK:masterfrom
qpprt:feat/apps-auth
Open

qpprt wants to merge 5 commits into
OpenVK:masterfrom
qpprt:feat/apps-auth

Conversation

@qpprt

@qpprt qpprt commented Oct 10, 2026 •

Copy link
Copy Markdown

Why

Apps today can't be built safely on OpenVK:

  • An app's backend can't tell who the user is. The only source is VKAPI.getUser() in the page, which the page itself controls, so any backend that trusts it can be fooled.
  • Payments can't be checked. pay() returns a signature made with the instance secret, which apps don't have. There is no order id, so a lost response can't be retried safely. The balance is changed with a read-modify-write, so parallel payments can overspend. NaN/INF amounts aren't rejected.
  • The bridge replies to '*'. If the frame navigates to another page, that page receives the user's info and payment results.
  • App texts become HTML. The app's title, the item description and the amount are inserted into OpenVK's own payment dialog without escaping: script injection on the OpenVK page.
  • Application::getOrigin() always appends :443, so it never equals a browser origin.

Separately, the API's roaming mode (used by the app bridge to call API methods as the logged-in user) only checks Referer, and only when one is sent. A page on another site can drop the Referer and make the user's browser call /method/...?auth_mechanism=roaming: CSRF on the whole API.

What changes

1. fix(api): roaming accepts only same-origin requests

Requests with Sec-Fetch-Site: cross-site or same-site, or with Sec-Fetch-Mode: navigate, are refused. Browsers set these headers themselves and pages can't change them. Non-browser clients don't use roaming, and the existing Referer check stays as is.

2. feat(apps): strict mode, a secret per app, signed launches and payments

  • Secret key per app. It's made automatically the first time it's needed. It's shown on the edit page (hidden until "Show") and can be regenerated.
  • Strict mode, the default for new apps; existing apps keep working as before until the developer turns it on.
    • Signed launches: the frame URL gets ovk_app_id, ovk_user_id, ovk_ts, ovk_launch_id, ovk_type=launch, ovk_sign. The signature is HMAC-SHA256 with the app's secret, the same scheme VK Mini Apps use, so the app's backend can trust the user id.
    • Origin-locked bridge: it only accepts messages from, and only replies to, the app's exact origin.
    • Address rules: the app's address must be https:// and must not contain ovk_* parameters.
  • Payments with orders: VKAPI.buyOrder(price, title, orderId) returns a signed receipt (ovk_order_id, ovk_payment_id, ovk_amount, ...) the backend can verify.
    • Idempotent: paying the same order again returns the same receipt with duplicate: true and doesn't charge twice. A unique (app, order_id) key makes this safe under concurrency.
    • New error codes: 552 invalid amount, 553 invalid order id, 554 order conflict, 41 not enough votes.
  • Transactional transfers: both payment methods log the payment and move the votes in one transaction. The payment row is inserted first, then the user's balance is locked (SELECT ... FOR UPDATE) and both balances are updated atomically. NaN/INF amounts are refused.
  • The bridge in other modes: without strict mode it replies to the sender's origin instead of '*'.
  • GameAPI.js: adds getLaunchParams() and buyOrder(); the bridge listener now checks the reply's origin.
  • getOrigin(): returns a real origin (lowercase, default port omitted).
  • Migration 00070-apps-secrets-and-payments.sql: apps.secret, apps.strict, and the app_payments table.

3. fix(apps): escape app-supplied text in bridge dialogs

Covers the payment dialog (app title, description, amount) and error messages.

4. feat(apps): install counts, catalog sorted by installs

  • Counter column: apps.installs, kept next to app_users. Install and uninstall change the row and the counter in one transaction, and only if the row really changed. INSERT IGNORE also fixes opening an app in two tabs at once, which could insert the same row twice and fail.
  • Migration 00071-apps-installs.sql fills the counter from app_users and adds an index, so sorting by installs is an index read instead of a COUNT(*) per app.
  • Catalog (Search → Applications): defaults to "By installs" and shows the count under each app; "By creation date" is still there. The edit page's "used by N people" reads the counter.

5. feat(apps): pass the part of the link after # to the app

/app12#room_5 opens the app with #room_5 on its own address, like vk.com/app123#... did, so apps can link to a room, a level or an invite. The value is whatever the user's link says, so it isn't signed or limited; apps read it from location.hash as user input. The frame's address is now set from the page script, exactly once, because a strict-mode launch link works only once.

Compatibility

  • Existing apps: nothing changes until the developer enables strict mode. buy() and the old pay() still work, and are now transactional.
  • Migrations are additive: new columns with defaults, a new table, an index. On a copy with 700 apps and 2.2M installs, 00071 runs in 0.9 s and fills 700/700 counts correctly. A catalog page then takes 0.04 ms; counting on the fly takes ~660 ms.

Testing

  • Lint: php-cs-fixer --dry-run (whole repo), chandler-latte-lint (211 + 4 templates) and PHPStan (CI config) are clean.
  • Upgrade from current master's database: clean.
  • End to end on two instances, master vs this branch, with a debug app (94 checks):
    • legacy apps unchanged;
    • strict-mode launches: forged and replayed ones are rejected;
    • the bridge ignores other origins;
    • receipts: verified, duplicates not charged twice, conflicts and errors;
    • secret rotation;
    • the roaming CSRF: it reproduces on master and is refused here.
  • The repo's screenshot suite (tests/), on the last commit (4c9e12a3): 82 passed, 1 skipped.
  • Links with #: /app2#room_x lands in that room on a full page load, through OpenVK's in-site navigation, and from a link in a wall post; a plain /app2 is unchanged. The 94-check suite was rerun on this commit, including legacy apps: all pass.
  • Install counter under concurrency: after an uninstall, 10 simultaneous opens give exactly one install.
  • Live dev instance: three demo games run on it in strict mode: realtime with purchases through buyOrder, multiplayer, and friends leaderboards. They have their own browser tests.

Try it

https://dev.ovk.app runs this branch.

  1. Click "Тестовый аккаунт" (test account) under the login form: an account with 100 votes, no registration. That button belongs to the dev instance and is not part of this PR.
  2. Open Applications: the catalog is sorted by installs.
  3. Open a game, e.g. Пиксели: the frame URL carries the signed ovk_* parameters; the shop's "Турбо" goes through buyOrder and the confirmation dialog.
  4. In Крокодил, enter a room and click "Позвать" (invite): the link (/app2#room_…) opens the same room for a friend.
  5. Apps → My apps → Create: see strict mode and the secret key on the edit page.

Docs

A developer docs page for apps is ready for OpenVK/docs (openvk_engine/game_api.md, which is currently "todo") and will be submitted there separately. They cover creating an app, strict mode, launch parameters, checking signatures (PHP, Node and Python examples, tested against real launches), the bridge methods, the order/receipt flow and error codes, and links with #.

Not in this PR

Follow-ups that are noted but kept out to keep this focused:

  • withdrawCoins() uses a read-modify-write like the old pay() did;
  • some old $reject("...") calls pass no error code;
  • origins with internationalized domain names aren't normalized;
  • Chandler's session cookie should be SameSite=Lax, a separate Chandler PR.

Screenshots

App edit page: strict mode and secret key
pr-1-edit-app

Payment confirmation (buyOrder) from a game
pr-2-payment-dialog

Catalog sorted by installs
pr-3-catalog

Both authenticate with the session cookie alone. The roaming check passes
when Referer is missing, so any website could call
/method/*?auth_mechanism=roaming on behalf of the visitor by stripping it,
and /rpc (which also runs Apps.pay) had no check at all.

Sec-Fetch-Site and Sec-Fetch-Mode are set by the browser and can't be
changed by a page. OpenVK's own pages call both with fetch() from the same
origin, so cross-site and same-site requests and navigations are refused:
a link opened from an email or another app is sent with Sec-Fetch-Site:
none, and a link inside a post is same-origin. Clients that don't send
these headers aren't browsers and aren't affected.
@qpprt qpprt changed the title Apps: signed launches, strict mode, verifiable payments and install counts; fix roaming CSRF feat(apps): signed launches, strict mode, verifiable payments and install counts; fix roaming CSRF Oct 10, 2026
qpprt added 4 commits October 10, 2026 15:52
- per-app secret (lazy), shown on the edit page with a regenerate button
- strict mode (default for new apps): signed ovk_* launch params in the
  iframe URL and a bridge locked to the app origin
- getOrigin() omits default ports so it matches event.origin
- bridge replies go to event.origin instead of '*'
- Apps.pay is now transactional and logged to app_payments
- new Apps.payOrder / VKAPI.buyOrder: idempotent by order id, returns a
  signed receipt
- VKAPI.getLaunchParams() in GameAPI.js
- Apps.withdrawFunds empties the balance under a row lock in one
  transaction, so a payment arriving meanwhile isn't lost
- Apps.* rejects pass an error code (they surfaced as "Invalid params")
- strict mode refuses addresses with ovk_* query keys, also percent-encoded
- order ids are compared exactly (ascii_bin): Order-A and order-a are
  different orders, and a trailing newline is refused
- a withdrawal locks the owner's balance before the app's, in the same
  order as payments, so an owner paying their own app can't deadlock it
PaymentRequest description and outSum come from the app iframe and were
inserted into MessageBox as HTML, so any app could run scripts on OpenVK pages.
apps.installs is a counter kept next to app_users: install and
uninstall change the row and the counter in one transaction, and only
if the row really changed. INSERT IGNORE also fixes opening an app in
two tabs at once, which could insert the same row twice and fail.

The migration fills the counter for existing apps from app_users and
adds an index, so the catalog sorted by installs is an index read
instead of a COUNT(*) per app. With 700 apps and 2.2M installs the
migration takes about a second and the catalog page under 1 ms
(counting on the fly: ~660 ms).

The catalog (search, apps section) now defaults to sorting by installs
and shows the count under each app; creation date is still available.
/app12#room_5 opens the app with #room_5 on its own address, like
vk.com/app123#... did: apps can link to a room, a level or an invite.
The value is whatever the user's link says, so it isn't signed or
limited; apps read it from location.hash as user input.

The frame's address is now set from the page script, once: a strict
mode launch link works only once, so the frame must not load twice.
@myslivets

Copy link
Copy Markdown
Collaborator

wow ogo

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants