# Audit report

> Review Submission29/ only (read Submission29/AUDIT_REQUEST.md first). World source 9d88f5c, NOT deployed; no real Celestao payment, no real grant and no 鼠道修仙 purchase has ever been made. Scope is the one commit since e93e55a (Submission28): fixes for the 5 Low findings of audit 64b1ce06 on the 鼠道修仙 GOLD cosmetic store. The wallet-lock test fails on purpose until this audit is handled.
>
> 1. Checkout-only cosmetics (finding 5): any remaining way to create or pay a cosmetic order without salesOpen and the ownership pre-check: another caller of goldQuote, the squeekCheckout flag reached from a client, admin commands, retries of an older request id.
> 2. Consent and codes (finding 4): any way to obtain a code for a signed-in victim without their click on World (Sec-Fetch-Site handling, missing headers, /squeek-connect/ resume link), and any exchange path that leaves a code usable after a failed attempt.
> 3. Rate limits (finding 2): any route under /api/squeek/ that reaches the database before a per-client budget.
> 4. Pseudonym (finding 3): any response that still lets the game recover the wallet address; the HMAC key handling and the 503 without a key.
> 5. Checkout link (finding 1): the quote parameter validation and the checkoutUrl it builds.
>
> Known items are in Submission29/AUDIT_REQUEST.md section 2 and Submission28/AUDIT_REQUEST.md. 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 | `848bb41cc764139da3210eb8228e78abd453c32a` |
| Job | `85163d96-b2c4-4d1f-8216-3a8a088c7789` |
| Judged | 2026-11-07 14:11 UTC |
| Findings | 3 low · 3 info |

Four agents audited the code as it is at `848bb41`, 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: authorize: a signed-in browser that sends no Fetch Metadata gets a 403 JSON dead end and can never complete the 鼠道修仙 login; the disclosure says it 'always goes through the consent page'

`Submission29/source/server/squeek-store.ts:117`

```
  if(request.headers.get('sec-fetch-mode')==='navigate'&&request.headers.get('accept')?.includes('text/html'))return reply(303,null,{Location:'/squeek-connect/?next='+encodeURIComponent(url.pathname+url.search)+'&lang='+q.lang});
```

Finding 4 of 64b1ce06 made code issuance conditional on Sec-Fetch-Site being same-origin or none (line 115). For every other request, including one with no Sec-Fetch-Site at all, the fallback to the /squeek-connect/ consent page is itself keyed on another Fetch Metadata header: line 117 redirects only when Sec-Fetch-Mode is 'navigate'; otherwise line 118 answers 403 {error:'CONSENT_REQUIRED'} as JSON. A browser that implements no Fetch Metadata (Safari and iOS WebKit before 16.4, Firefox before 90, WebViews built on them) sends neither header, so (a) the game's top-level link to /api/squeek/authorize lands on a raw JSON 403 instead of the consent page, and (b) even if the player opens /squeek-connect/ by hand and presses Continue, that same-origin GET again carries no Sec-Fetch-Site, so consented is false and the answer is 403 again. There is no path on which such a client can obtain a code: the login is impossible for that browser class rather than consent-gated. Before 9d88f5c the same signed-in client was issued a code, so this is a regression, and AUDIT_REQUEST.md section 2 ('Browsers that send no fetch metadata always go through the consent page') states the opposite of the code. Test '64b1ce06 #4' only covers site=null together with sec-fetch-mode: navigate, so the fully header-less navigation is untested. No security impact (fails closed). Fix, preserving the one-click design: (1) when Sec-Fetch-Mode is absent, treat a GET whose Accept contains text/html as a navigation and redirect to /squeek-connect/ as line 117 does for navigate; (2) give the consent click a proof that does not depend on Fetch Metadata, e.g. make Continue a same-origin POST (form or fetch with credentials) to a small consent route that checks Origin === issuer (sent on POST by every browser) and the session cookie, then 303s back to authorize with a short-lived single-use consent nonce bound to the session hash that authorize accepts as equivalent to Sec-Fetch-Site: same-origin; add a test with a signed-in cookie and no sec-fetch-* headers asserting a 303 to /squeek-connect/ and a completed login. Alternatively, document in section 2 that clients without Fetch Metadata cannot sign in and have the game show that message.

**Reproduction**

State: a live World session cookie for wallet W (sessions row, EOA/ECDSA), SQUEEK_SUBJECT_KEY set. Input 1 (what Safari 16.3 sends on the game's login link): GET 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=<43 base64url chars>&state=<32 chars>&lang=en with headers Cookie: __Host-imd_session=<token>, Accept: text/html,application/xhtml+xml and NO Sec-Fetch-Site / Sec-Fetch-Mode. Trace: line 110 passes, line 111 spends the budget, line 112 reads the session (object), line 115 site=null so consented=false, line 116 enters the block, line 117 sec-fetch-mode is null so no redirect, line 118 session is not a string so 403 {error:'CONSENT_REQUIRED'} with content-type application/json. Expected per AUDIT_REQUEST.md section 2: 303 Location /squeek-connect/?next=... Input 2 (the Continue click on /squeek-connect/ from the same browser): the identical GET with Referer https://imdember.com/squeek-connect/ and still no Sec-Fetch-* headers: same trace, 403 again, and SELECT count(*) FROM squeek_auth_codes stays 0. In tests/squeek-store.test.mjs: f.call('authorize?'+q,{headers:{cookie:SESSION_COOKIE+'='+TOKEN,accept:'text/html'}}) returns status 403, not 303, and no code row is inserted.

### 2. Low: authorize accepts Sec-Fetch-Site: none as consent, so a link opened from outside the browser (mail/chat/PDF app, OS link handler, native wrapper) mints a code for a signed-in player without any click

`Submission29/source/server/squeek-store.ts:115`

```
 const site=request.headers.get('sec-fetch-site'),consented=site==='same-origin'||site==='none';
```

The consent gate treats Sec-Fetch-Site: none as 'the user's own navigation (a typed or bookmarked URL)' (AUDIT_REQUEST.md section 2). Per the Fetch Metadata specification, browsers send 'none' for every navigation whose initiator is not a web origin: the address bar and bookmarks, but also any URL handed to the browser by another application (a link tapped in a native mail client, Discord/Telegram/Slack desktop or mobile apps, a PDF viewer, a QR scanner, an OS intent, or a native/Electron game build that opens the system browser). The World session cookie is a top-level same-site cookie, so such a GET carries it. A third party who gets a signed-in player to open a prepared authorize link (with the third party's own code_challenge and state) therefore skips /squeek-connect/ entirely: lines 121-125 insert a squeek_auth_codes row for the victim's wallet and 303 the victim to the game with world_code and the attacker's world_state. This is exactly the case the request asks to rule out ('any way to obtain a code for a signed-in victim without their click on World'). Impact is bounded: the code is delivered only to the victim's own browser at the fixed game origin and is PKCE-bound to the attacker's verifier, so the attacker cannot exchange it unless the game forwards the code somewhere the attacker can read; the victim's game tab does receive an authorization response with a state it did not create (the game's handling of a foreign state is outside this package). The party the consent page is meant to protect the player from, the game operator, can also skip it from a native build. The designed flow never needs 'none': the Continue link on /squeek-connect/ is a same-origin navigation. Fix: consented = site==='same-origin' (optionally also require sec-fetch-mode: navigate); a typed or bookmarked authorize URL then simply gets the one-click page like every game login. If consent is reworked into a same-origin POST (see the Fetch-Metadata-less finding), that change covers this one too. Update section 2 accordingly and change the 'none' branch of test '64b1ce06 #4' to expect the consent redirect and codes()==1.

**Reproduction**

State: wallet W has a live World session (cookie __Host-imd_session=T). Input: the player clicks, in a desktop or mobile chat/mail client, 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=<attacker's 43 chars>&state=<attacker's 32 chars>&lang=en. The browser issues GET with Cookie: __Host-imd_session=T, Accept: text/html, Sec-Fetch-Mode: navigate, Sec-Fetch-Dest: document, Sec-Fetch-Site: none. Expected under the stated design ('every login from the game now stops on /squeek-connect/ for one click'): 303 to /squeek-connect/?next=... and no row in squeek_auth_codes. Actual trace: line 115 consented=true, line 116 not entered, lines 120-123 insert the code row bound to the attacker's challenge, line 125 answers 303 https://squeekascension.celestao.com/?world_code=<code>&world_state=<attacker's state>. In tests/squeek-store.test.mjs this is the 'none' iteration of test '64b1ce06 #4': f.call('authorize?'+q,{headers:{cookie:SESSION_COOKIE+'='+TOKEN,'sec-fetch-site':'none'}}) returns 303 with world_code and codes() becomes 2; with consented=site==='same-origin' the same call answers 303 to /squeek-connect/ (when sec-fetch-mode: navigate and Accept text/html are present) or 403, and the count stays 1.

### 3. Low: authorize spends the per-IP budget and reads the session before the consent check, so a cross-site page can exhaust a player's 鼠道修仙 login budget (20/min per IP) with cookieless subresource requests

`Submission29/source/server/squeek-store.ts:111`

```
 if(!await deps.allow!('squeek:authorize:'+(deps.client??'unknown')))return reply(429,{error:'RATE_LIMITED'},{'Retry-After':'60'});
```

Order inside authorize(): query validation (lines 109-110), then the per-client limiter 'squeek:authorize:'+ip (line 111; worker/app.ts line 201 maps it to allow('home',key), AUTH_LIMITER, 20 per minute per key per wrangler.jsonc line 52), then readSession (line 112, one D1 read), and only then the header-only consent decision (lines 115-118). A request that will be refused for lack of consent therefore still spends the victim's IP budget and costs a database read. A GET subresource from any site (<img src=...> or fetch(...,{mode:'no-cors'})) sends no Origin header, so the route's origin check at line 199 passes; the parameters are static and public (client_id, fixed redirect, any 43-char challenge, any 22-128-char state), so validation passes; the budget is spent although the request carries no cookie and is answered 401 at line 118. Twenty such requests per minute from a page the signed-in player (or anyone behind the same NAT) has open leave that IP's own login navigation answered 429 RATE_LIMITED for the next minute, repeatable while the page stays open. The sibling checkout() route does it the other way round: it refuses a non-same-origin Sec-Fetch-Site at line 160 before touching its budget. Impact is limited to the squeek login (other sign-in budgets use different keys) and needs the victim on the attacker's page, hence Low. Fix: evaluate the consent condition first, before deps.allow and before readSession: when sec-fetch-site is present and not same-origin (or 'none' if still accepted), answer the 303 to /squeek-connect/ (navigate) or 403 immediately with no budget spent and no database read; spend the budget and read the session only for requests that can mint a code. The existing tests keep passing ('64b1ce06 #4' asserts only status and code count).

**Reproduction**

State: production limits (AUTH_LIMITER 20/min per key); victim V (cf-connecting-ip 198.51.100.7) is signed in to World. Input: V's browser loads an attacker page containing twenty <img src="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=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA&state=<distinct 32-char state per tag>&lang=zh"> (each request: no cookie, no Origin, sec-fetch-site: cross-site, sec-fetch-mode: no-cors, sec-fetch-dest: image). Trace per request: handleSqueek line 196 origin ok, line 199 no Origin header so ok, authorize line 110 passes, line 111 spends 'squeek:authorize:ip:198.51.100.7', line 112 reads sessions (no cookie, returns a string), line 117 not navigate, line 118 answers 401. After 20 of them V clicks the game's login link (or Continue on /squeek-connect/) within the same minute and line 111 answers 429 RATE_LIMITED with Retry-After 60 instead of the consent page or a code. Unit form against the handler: with deps.allow=async k=>{keys.push(k);return keys.length<=20;} and deps.client='198.51.100.7', call handleSqueek 20 times with headers {'sec-fetch-site':'cross-site','sec-fetch-mode':'no-cors'} and no cookie (each 401), then once with {cookie:SESSION_COOKIE+'='+TOKEN,'sec-fetch-site':'same-origin'}: the 21st call returns 429 instead of 303 with world_code; with the consent check moved before line 111 the 20 unconsented calls spend nothing and the 21st returns 303 with a code.

### 4. Info: A token exchange rejected by body validation (wrong redirect_uri, client_id, grant_type or malformed verifier) does not spend the code, narrower than the stated 'every attempt consumes the code'

`Submission29/source/server/squeek-store.ts:131`

```
 if(!b||b.grant_type!=='authorization_code'||b.client_id!==SQUEEK_CLIENT_ID||b.redirect_uri!==cfg.redirect||typeof b.code!=='string'||!OPAQUE.test(b.code)||typeof b.code_verifier!=='string'||!OPAQUE.test(b.code_verifier))return reply(400,{error:'BAD_REQUEST'},headers);
```

AUDIT_REQUEST.md section 1 row 4 says 'The token UPDATE now consumes the code on every attempt within its 60 s'. Only attempts that pass body validation at line 131 reach the consuming UPDATE at lines 135-145. An attempt carrying the real, live code together with a different redirect_uri, client_id or grant_type, or a code_verifier that is not exactly 43 base64url characters, is answered 400 BAD_REQUEST before the database is touched, and the code stays usable until its expiry for a later well-formed exchange. RFC 6749 only requires denial and revocation on reuse of a code, and a holder of the code without the verifier still cannot obtain a grant, so this is not a security defect: the wrong-verifier case (the one that matters) is spent as claimed. It is recorded so the game's specification and the disclosure are read as the exact behaviour: a wrong verifier spends the code, a malformed request does not. If the broader statement is wanted, run the consuming UPDATE whenever b.code matches OPAQUE regardless of the other fields (bind redirect_uri and the computed challenge, or a sentinel when the verifier is malformed) and return 400/401 after it; add a test: mint, exchange with redirect_uri REDIRECT+'x' (400), then the correct exchange and assert 401 and consumed_at set.

**Reproduction**

State: a code C minted for wallet W (consumed_at NULL, expires_at = now+60000). Step 1: POST https://imdember.com/api/squeek/token, Origin: https://squeekascension.celestao.com, content-type application/json, body {"grant_type":"authorization_code","client_id":"squeek-ascension","redirect_uri":"https://squeekascension.celestao.com/x","code":C,"code_verifier":V} with V the correct 43-char verifier. Trace: line 129 spends the client budget, line 130 parses, line 131 b.redirect_uri!==cfg.redirect so 400 {error:'BAD_REQUEST'}; SELECT consumed_at FROM squeek_auth_codes WHERE code_hash=sha256(C) is still NULL. Step 2 (within 60 s): the same POST with redirect_uri 'https://squeekascension.celestao.com/': 200 with access_token. Expected per the request text: step 1 marks the code consumed (token_hash 'rejected') and step 2 answers 401 INVALID_GRANT. Same with client_id 'celestao' or code_verifier 'short' in step 1. In the test harness: after code=await mint(), exchange({redirect_uri:REDIRECT+'x'}) is 400 and a following exchange() is 200, whereas exchange({code_verifier:'w'.repeat(43)}) burns it.

### 5. Info: A code replayed after a successful exchange is refused but the grant already issued on it is not revoked (RFC 6749 section 4.1.2 SHOULD)

`Submission29/source/server/squeek-store.ts:147`

```
 if(rows[0]?.results.length!==1||(rows[0].results[0] as {token_hash:string}).token_hash!==grantHash||!row)return reply(401,{error:'INVALID_GRANT'},headers);
```

The consuming UPDATE makes every exchange single-use and the second use answers 401 INVALID_GRANT at line 147. RFC 6749 section 4.1.2 adds that when a code is used more than once the server SHOULD revoke all tokens previously issued on it, because a second presentation means the code leaked (the game's landing URL carries world_code for 60 s). As written, after a successful exchange a replay yields 401 and nothing else: the grant issued on the first exchange stays valid for up to an hour. Exploitability is very limited: whoever exchanges first needs the PKCE verifier, which lives only in the game client, so a party holding only the leaked code cannot obtain the grant in either order; the gap only matters if the verifier is also compromised, in which case revocation on reuse is a late signal. Revocation also hands a code observer a one-shot way to log the victim's game out, so this is a design choice to record rather than a defect. If the SHOULD is wanted: in token(), when rows[0] is empty, run UPDATE squeek_tokens SET revoked_at=?now WHERE token_hash=(SELECT token_hash FROM squeek_auth_codes WHERE code_hash=?hash AND token_hash<>'rejected') AND revoked_at IS NULL, still answering 401, and add a test that after exchange, exchange, the first grant's snapshot answers 401.

**Reproduction**

State: a session for W; mint a code through authorize with Sec-Fetch-Site: same-origin. 1) POST /api/squeek/token with the right code_verifier: 200, access_token G. 2) POST /api/squeek/token again with the same code and verifier: 401 INVALID_GRANT (the UPDATE at lines 137-139 matches no row because consumed_at is set; line 147 answers 401). 3) GET /api/squeek/snapshot with Authorization: Bearer G. Expected after a detected code reuse under RFC 6749 4.1.2: 401 AUTH_REQUIRED (G revoked). Actual: 200 with subjectId, balanceGold and entitlements; squeek_tokens.revoked_at for sha256(G) is NULL. In tests/squeek-store.test.mjs: const ok=await exchange(); const grant=await ok.json(); await exchange(); (await f.api(grant.access_token,'snapshot')).status is 200.

### 6. Info: Checkout page offers 'Sign payment' for a pending order of a cosmetic the wallet already owns; the signature request is sent and then refused with ALREADY_OWNED

`Submission29/source/src/world/SqueekCheckout.tsx:55`

```
    {order?<><p className="celestao-gateway-note">{t('原始訂單','Original order')}<br/><code>{order.orderId}</code></p>
```

The render at lines 55-59 tests order first and owned second. When checkout/state returns a pending order for this request key together with owned=true (the same wallet paid another order for the same sku, e.g. two tabs or two game purchase keys, the 'first payment to land wins' case disclosed in Submission28), the page shows the original order and the 'Sign payment of 200 GOLD' button instead of the ALREADY_OWNED notice. Pressing it runs operate(true) at lines 35-41, which ignores latest.owned and requests a GOLD challenge; the server refuses it with ALREADY_OWNED (economy-ledger.ts line 268 in createEconomyGoldChallenge, and SQUEEK_ONE_TIME_SQL inside the debit batch would refuse the payment anyway), so no GOLD is taken. The gap is a flow one: the page asks the player to confirm a payment the server will always refuse while its own data (owned) already says so. Fix: in the pending-order branch, when data.owned is true render the ALREADY_OWNED status (keeping the order id for reference) and no button; in operate(true) throw ALREADY_OWNED before requesting a challenge when latest.owned is true.

**Reproduction**

State: sales open; wallet W has a still-pending unexpired order for sku aura.frostmoon_sword.v1 with request key K2 (created first, e.g. a second tab), then buys the same sku with key K1 (checkout/quote, pay, deliver; entitlements=[frostmoon]). Input: open /squeek-checkout/?sku=aura.frostmoon_sword.v1&request=K2&lang=en signed in as W. checkout/state answers {owned:true, order:{status:'pending',requestKey:K2,...}, ready:true} (squeek-store.ts line 185 computes owned from entitlements and returns the existing K2 order). Expected: the ALREADY_OWNED notice and no payment button. Actual: line 55 enters the order branch and line 56 renders the 'Sign payment of 200 GOLD' button (status is empty for a pending order and expiresAt is in the future); pressing it calls goldChallenge for the K2 order and the server answers 409 ALREADY_OWNED, which the page then shows as an error. Server-side in tests/squeek-store.test.mjs: f.buy(FROST.sku,KEY2); const q=await f.buy(FROST.sku,KEY); await f.pay(q.body.order.orderId); await f.deliver(); then (await f.page('state',{sku:FROST.sku,requestKey:KEY2,expectedAddress:W})).json() has owned:true and order.status 'pending', the state the page turns into a payment button.

---

Judge's submission `98dc123be8625d1a117f79476c9d3e65c8a42add3b3424f0534734de5debd5d7`, 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.
