# Audit report

> Review Submission28/ only (read Submission28/AUDIT_REQUEST.md first). World source e93e55a, NOT deployed; no real Celestao payment, no real grant and no 鼠道修仙 purchase has ever been made. Scope is the 4 commits since 24a3efa: fixes for audit 447bb70d (part A), two fixed links from Celestao to 鼠道修仙 (part B), and a new 鼠道修仙 cosmetic store paid with World GOLD (part C). Submission27 was never sent. The wallet-lock test fails on purpose until this audit is handled.
>
> 1. Part C money: own a cosmetic without a live paid order, pay twice for one cosmetic (two tabs, two request keys, the generic /api/gold/quote, retries, replays), keep a cosmetic after a refund, or be refunded twice. Can SQUEEK_ONE_TIME_SQL in the GOLD debit batch be bypassed, or block a payment it should not (other products, other wallets)?
> 2. Part C identity: authorization code or grant theft, redirect and state handling, PKCE, the grant after World logout or a wallet switch, cross-wallet reads, CORS. Can the read-only grant reach any route that changes state other than its own logout?
> 3. Part C checkout: CSRF on /api/squeek/checkout/quote, the request-key format, a key reused for another cosmetic, an expired order, a contract wallet.
> 4. Part A: the failed-run rename in vault.prepare against any interleaving of prepare, delivery, refund, lapse, retire, prune_failed and the operator tool; the 409/422 mapping; the grant form limits; the backup sentence against code.
> 5. Part B: does either link carry any user, chart or session data to the other site?
>
> Known items are in AUDIT_REQUEST.md (part C disclosures) and Submission26/AUDIT_REQUEST.md sections 1 to 9. Report severity, reproduction and fix. Not in scope: chart engines, report wording, the 鼠道修仙 game itself, 3D/rendering, media, AIMarket, Genesis.

| | |
|---|---|
| Repository | https://github.com/tungweb3/imd-ember-world-review.git |
| Commit | `06e391da9ab0bc46a9f55ddb84f89258a42c90c1` |
| Job | `64b1ce06-3b4d-43df-b315-83655ec13b96` |
| Judged | 2026-11-07 12:30 UTC |
| Findings | 5 low |

Four agents audited the code as it is at `06e391d`, each in one area (math, permissions, economics, control flow),
and a judge reproduced, merged and ranked what they found, then read the code once more itself. Nothing in the repository was changed or deployed.

## Findings

### 1. Low: GET /api/squeek/quote returns a checkoutUrl without the request key (and lang), so the World checkout page rejects it as an invalid purchase link

`Submission28/source/server/squeek-store.ts:216`

```
   checkoutUrl:cfg.issuer+'/squeek-checkout/?sku='+encodeURIComponent(it.sku)},cors);
```

The read-only grant's quote route is the only place the World API tells the game where to send a player to pay. It builds cfg.issuer+'/squeek-checkout/?sku='+sku and nothing else. The checkout page parses its URL with squeekCheckoutParams (Submission28/source/src/world/squeekEntry.ts lines 15-18), which sets valid only when the query has exactly one 'request' parameter matching SQUEEK_REQUEST_KEY (/^[A-Za-z0-9_-]{22,64}$/) and exactly one 'sku'. With no 'request' parameter requestKey is '' and valid is false, so SqueekCheckout.tsx line 49 renders the alert 'Invalid purchase link. Return to Squeek Ascension.' and never offers a create-order button. AUDIT_REQUEST.md part C.2 itself documents the page shape as /squeek-checkout/?sku=...&request=.... The URL also carries no lang, so an English player lands on the Chinese page (zh is the default when lang is not 'en'). No GOLD is at risk, but the purchase link the API hands out is a dead end, and an adapter that trusts it can never sell anything. Reported by the math specialist; reproduced against squeekEntry.ts. Fix: either omit checkoutUrl (the game builds the link with its own request key, which the server then validates), or accept an optional requestKey on the quote route, validate it with SQUEEK_REQUEST_KEY, and return cfg.issuer+'/squeek-checkout/?sku='+sku+'&request='+key+'&lang='+lang. Add a test that squeekCheckoutParams(new URL(quote.checkoutUrl).search).valid is true.

**Reproduction**

With a valid grant G: GET https://imdember.com/api/squeek/quote?sku=sky.sakura.v1 with 'Authorization: Bearer G' and 'Origin: https://squeekascension.celestao.com'. Actual response field: checkoutUrl='https://imdember.com/squeek-checkout/?sku=sky.sakura.v1'. Feeding that query to the page parser: squeekCheckoutParams('?sku=sky.sakura.v1') returns {sku:'sky.sakura.v1',requestKey:'',zh:true,valid:false} (checked with node: SQUEEK_REQUEST_KEY.test('') is false and getAll('request').length is 0). Expected: a URL the page accepts and that can create the 200 GOLD order. Actual: the page shows the 'Invalid purchase link' alert and no create-order button. The existing test only asserts squeekCheckoutParams on a hand-built URL with a request key (tests/squeek-store.test.mjs line 161), never on the server's checkoutUrl.

### 2. Low: Squeek token and Bearer routes are rate-limited by a key the caller chooses, and the grant lookup runs before the limiter, so one client gets unlimited D1 work

`Submission28/source/server/squeek-store.ts:200`

```
  if(!await deps.allow('squeek:api:'+(await sha256(tokenValue)).slice(0,32)))return reply(429,{error:'RATE_LIMITED'},cors);
```

POST /api/squeek/token is a public endpoint (no client secret, no cookie) and the Bearer routes accept any 43-character token. Their only budget is keyed on the caller's own value: line 120 uses 'squeek:token:'+sha256(code) and line 200 uses 'squeek:api:'+sha256(token). worker/app.ts limiter() (lines 139-145) passes a supplied key to binding.limit({key}) verbatim and adds no client IP, so every request with a fresh random code or token lands in a fresh, never-exhausted bucket. The authorize, catalog and checkout routes key on deps.client (the client IP); these two do not. On the token route each attempt then runs the two-statement D1 write batch at lines 122-131 (UPDATE squeek_auth_codes, INSERT ... SELECT into squeek_tokens). On the Bearer routes readSqueekGrant (a JOIN of squeek_tokens and sessions, line 86-87) runs at line 199 before allow() at line 200, so even a token over its limit costs a D1 read. Codes, verifiers and tokens are 256-bit, so this does not let anyone obtain a grant; it removes the limiter's protection against D1 load and cost from one client. Reported independently by the permissions and flow specialists; merged. Fix: check a per-client bucket first ('squeek:token:'+deps.client before the batch, 'squeek:api:'+deps.client before readSqueekGrant), keep the per-code and per-token keys as a second limit if wanted, and move readSqueekGrant below both allow() calls.

**Reproduction**

From one IP send 10,000 requests POST https://imdember.com/api/squeek/token with Origin https://squeekascension.celestao.com, content-type application/json and body {"grant_type":"authorization_code","client_id":"squeek-ascension","redirect_uri":"https://squeekascension.celestao.com/","code":<new random [A-Za-z0-9_-]{43} each time>,"code_verifier":"vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv"}. Expected: 429 RATE_LIMITED once a per-client budget is spent, before any database write. Actual: every request passes allow('squeek:token:<new hash>') because the key is new, runs db.batch, and answers 401 INVALID_GRANT. Same with GET /api/squeek/snapshot and 'Authorization: Bearer <new random 43 chars>' per request: readSqueekGrant runs its SELECT, allow('squeek:api:<new hash>') succeeds, reply is 401 AUTH_REQUIRED, never 429. In the test harness this is visible by passing deps.allow=key=>{calls.push(key);return true} and checking that neither key contains the client value (deps.client), and, for a Bearer route, that the squeek_tokens SELECT runs before allow is called.

### 3. Low: The game's "pseudonym" is an unkeyed SHA-256 of the public wallet address and is returned beside a wallet hint, so anyone can turn subject_id back into the full address

`Submission28/source/server/squeek-store.ts:47`

```
export async function squeekSubject(address:string){return 'sq_'+(await sha256('squeek-subject-v1:'+address.toLowerCase())).slice(0,40);}
```

AUDIT_REQUEST.md part C.1 and the comment above squeekSubject say the game sees a stable pseudonym and a shortened wallet, not the full address. The subject is 'sq_'+sha256('squeek-subject-v1:'+lowercase address)[0:40]: a deterministic function of a public identifier under a fixed, published prefix, with no server secret. Wallet addresses are public and the set of World or GOLD wallets is enumerable from chain events and the economy contracts, so anyone holding a subject_id (the game, its logs or storage, a leaderboard or shared receipt; receipts carry subjectId too) can hash a candidate list and recover the address. walletHint (line 48: first 6 and last 4 characters) is returned next to it in /snapshot and confirms the match at once. The stated separation therefore does not hold; funds are not affected. Three specialists reported this (permissions, flow, math); merged. The Celestao vault already does this correctly: vault.py owner() uses HMAC with a server-held key. Fix: derive the subject with HMAC-SHA256 under a Worker secret, for example 'sq_'+HMAC(SQUEEK_SUBJECT_KEY,'squeek-subject-v1:'+address)[0:40], never rotated; drop walletHint or document that the game is trusted with the address. Nothing is deployed, so changing the derivation strands no existing ids.

**Reproduction**

node -e "const c=require('crypto');console.log('sq_'+c.createHash('sha256').update('squeek-subject-v1:0x1111111111111111111111111111111111111111').digest('hex').slice(0,40))" prints sq_e2083d592e16e353e42caff5af6947ede8103b46, which is exactly squeekSubject('0x1111111111111111111111111111111111111111') and the subject_id the token endpoint and /snapshot return for that wallet (lines 134, 137). For 0x00000000219ab540356cBB839Cbe05303d7705Fa the same command prints sq_1bad7a614249e0af0a6d0fa2e7527c3c0457976b and /snapshot returns walletHint '0x0000…05Fa'. Given a subject_id from the game and a public list of addresses, one hash per candidate (filtered first by the hint) recovers the wallet with no World secret. Expected: an id that cannot be computed without a server-held key. Actual: anyone can compute and invert it offline.

### 4. Low: /api/squeek/authorize mints an authorization code on any cross-site top-level navigation, with attacker-chosen PKCE and state and no user action; a failed exchange does not consume the code

`Submission28/source/server/squeek-store.ts:114`

```
 const target=new URL(cfg.redirect);target.searchParams.set('world_code',code);target.searchParams.set('world_state',q.state);return reply(303,null,{Location:target.href});
```

The World session cookie is SameSite=Lax (source/server/auth.ts line 603), so a top-level GET navigation from any site carries it. authorize() checks the Origin header only when present (navigations send none), never checks Sec-Fetch-Site, and shows the consent page /squeek-connect/ only to a signed-out visitor (line 106). For a signed-in visitor it inserts a code bound to the visitor's wallet with whatever code_challenge and state the URL carried and answers 303 to the game with world_code in the query (line 114). A third party therefore holds the matching verifier for a code issued for the victim. PKCE stops injection into the real game (its own verifier does not match), so the remaining barrier is the secrecy of that query string on the game's page, which is not in this package and was not tested. In addition, the token UPDATE at lines 123-125 puts code_challenge=?4 in its WHERE clause, so an exchange with a wrong verifier leaves consumed_at NULL and the code stays redeemable for the rest of its 60 s; RFC 6749 section 4.1.2 expects a code to be single-attempt. If the code leaks within that window (page logs, history-synced analytics, Referer to a subresource), the third party redeems it at the public token endpoint (no client secret, server-side call) and reads the victim's GOLD balance, cosmetics, orders and wallet hint for up to 60 minutes. Read-only impact, hence low. Reported by the permissions specialist; reproduced against the code and the cookie attribute. Fix: issue a code only when Sec-Fetch-Site is same-origin or none; otherwise 303 to /squeek-connect/?next=... so the user's Continue click (a same-origin navigation) is the consent. Also consume the code (set consumed_at, or delete the row) on any failed exchange.

**Reproduction**

State: user signed in to imdember.com as wallet W. On https://evil.example the user clicks a link to https://imdember.com/api/squeek/authorize?client_id=squeek-ascension&redirect_uri=https%3A%2F%2Fsqueekascension.celestao.com%2F&response_type=code&code_challenge_method=S256&code_challenge=<S256(V) for an attacker verifier V>&state=<attacker 22+ chars>&lang=en. Expected: no code without a same-origin confirmation by the user. Actual: an immediate 303 to https://squeekascension.celestao.com/?world_code=<code for W>&world_state=<attacker state>. In the test harness: call authorize with cookie TOKEN and headers sec-fetch-site:cross-site, sec-fetch-mode:navigate; status is 303 with world_code set. Then POST /api/squeek/token with that code and a wrong code_verifier: 401; POST again with V: 200 and a Bearer grant for W (the first failure did not consume the code). Expected per RFC 6749: the second exchange fails too.

### 5. Low: Squeek cosmetics are also quoted and sold through the generic /api/gold/quote and the GOLD panel, which skip the squeek sales gate, the ownership pre-check and the sq_ request-id contract

`Submission28/source/server/world-gold.ts:69`

```
 const p=(await goldProducts(db)).find(p=>p.product===args.product);if(!p?.enabled)fail('PRODUCT_UNAVAILABLE');
```

After the one-time squeek_release_actions insert the 11 cosmetics are ordinary enabled 'service' rows in world_gold_products, and with SQUEEK_SALES_ENABLED=1 worker/app.ts line 161 registers createSqueekDelivery(), so world-gold-api.ts serviceable(product) includes every squeek-* product. goldQuote (world-gold.ts lines 58-99) only requires the product's latest version to be enabled, serviceReady and the 'service' control; it never calls salesOpen() (squeek-store.ts lines 91-96, which requires all 11 enabled at 200 GOLD on their latest version) and never runs the squeekEntitlements ALREADY_OWNED pre-check. WorldGoldPanel.tsx line 141 renders every enabled service product with a 'Create order' button, so the cosmetics appear there, including ones the wallet already owns, and the panel's orders get request ids without the 'sq_' prefix, so receipt.requestKey is null and the game's /order?requestKey lookup can never find them (they do appear in /orders and in entitlements). The GOLD debit's SQUEEK_ONE_TIME_SQL still refuses a second charge for an owned cosmetic (verified: PAY-02 test), so no double charge follows; the EOA requirement also holds on this path (world-gold-api.ts line 57). What breaks is the gate: when an administrator disables or reprices one cosmetic, the squeek checkout and catalog report SALES_CLOSED / salesEnabled:false for all 11 while the GOLD panel keeps selling the other 10 (or the repriced one at its new price), and an already-owned cosmetic reaches a pending order and fails only at the signature step with ALREADY_OWNED, a code goldPurchaseError has no text for. Reported by the economics specialist; reproduced against goldQuote, serviceable() and the panel filter. Fix: refuse squeek-* products in goldQuote unless the request id starts with 'sq_' (so only the squeek checkout, which runs salesOpen and the ownership pre-check, can create them), or add the salesOpen condition as SQL in the quote batch; and filter squeek-* products out of the panel's service list or link them to /squeek-checkout/ the way fortune products link to Celestao.

**Reproduction**

State: migration 0025 applied, release action inserted, SQUEEK_SALES_ENABLED=1, 'service' control enabled. (1) Admin disables squeek-sky-meteors-v1 (or publishes version 2 at 300 GOLD). Expected: no cosmetic can be bought, as POST /api/squeek/checkout/quote now answers 503 SALES_CLOSED and GET /api/squeek/catalog says salesEnabled:false. Actual: POST /api/gold/quote with {expectedAddress:W,requestId:'abcdefghijklmnop1234',product:'squeek-sky-sakura-v1'} (same-origin, cookie session) returns an orderId at 200 GOLD; the economy challenge and pay routes accept it because serviceProducts includes the product and SQUEEK_ONE_TIME_SQL passes for an unowned cosmetic; the squeek provider delivers it and /snapshot lists it as owned, while /order?requestKey=... returns 404 for it and the receipt in /orders has requestKey null. (2) Wallet W owns aura.frostmoon_sword.v1. In the GOLD panel the product still shows 'Create order'; the generic quote creates a pending order, and only createEconomyGoldChallenge refuses with ALREADY_OWNED (409), which the panel renders as the bare code.

---

Judge's submission `844f5d5a09ce27d64307b8156ced366bda0b0277b2143e0b120c9d470d37bb7e`, accepted on the IdentityMD network. Acceptance means the report met the job's checks;
it is not a guarantee that the code has no other defects.
