`docs/reviews/` 此前一直是**未跟踪**状态 —— 审查报告只在磁盘上, 不进版本库 ⇒ 换机器、换会话、给别人看时全部拿不到, 而它们正是本轮五个修复(hap 出库 / SSE 写锁 / 换身份清数据 / 鸿蒙门禁三态 / HMS 配额)的**来源**。 push-and-gui-review.md 推送链 + Electron GUI(HMS 配额那两条) electron-gui-review.md 换身份不清数据 harmony-client-review.md 鸿蒙:门禁 fail-open / MailStore 快照共用 / clear() 零调用方 harmony-pages-review.md 页面层 harmony-state-review.md 状态层 fix-report-2026-09-26.md 上述修复的实施记录 其中 `harmony-client-review.md` §三.1 记的那条值得单独留意: 该报告自己声明「ArkTS 语言规范层面零违规,本文所有问题都是**逻辑缺陷**」—— 本次提交的三处鸿蒙改动也只动逻辑(门禁条件、logout 清理), 不碰语法层。
12 KiB
CRITICAL (security / data leak / data loss)
-
stores/mailStore.ts/stores/sessionStore.ts/stores/contactStore.tsare never cleared on account switch — cross-account mail disclosure.AccountSwitcher.tsx:64-67— the entire account-switch side effect is:const pick = async (id: string) => { setOpen(false); await setActive(id); await fetchInbox('all'); // ← only the inbox is refetched };setActive(stores/accountStore.ts:190-201) callssyncAuth()which flips theapi/configsingleton (API_BASE+ bearer) to the new account. ButuseMailStore.sent,useMailStore.currentMail,useSessionStore.sessions,useSessionStore.currentSession,useSessionStore.currentSessionMails,useContactStore.contactsand.archivedContactsare never reset, and every subsequent single-account request (api.getSent(),api.getMail(),api.getSessionDetail(),api.archiveContact()) now carries the new account's credentials. Concretely: open Sent while on account A → switch to B → the Sent list still shows A's mail, andMailView/ThreadViewrender and forward A's bodies while the user believes they are in B. Archiving or approving from that stale view mutates B.MailView.tsx:67(if (currentSession && currentSessionMails.length > 0)) will still show A's whole session under account B. Nothing inAccountSwitcher,accountStore, orApp.tsxcallsclearSession()/clearCurrentMail()on switch (verified by grep: onlyMailList.pick/ContactPanel.open/PermissionList.pickdo). -
stores/appearanceSync.ts:40—lastUploadedImageis a module-level singleton never keyed by account; an account's wallpaper is treated as already-uploaded under another account.let lastUploadedImage = '';It is assigned in
pull()(:86) and read inpush()(:110). The wallpaper upload is guarded byif (ok && bg.kind === 'image' && bg.imageDataUrl && bg.imageDataUrl !== lastUploadedImage). Sequence: account A sets wallpaper X (uploaded,lastUploadedImage = X) → switch to account B whose server record issaved:false→pull()takes the "server has no record" branch and callspush()with B's local background cache → the guard is satisfied by A's marker, so B's wallpaper is never uploaded to B's server record andstatusis set to'synced'. The wallpaper also survives on any device that syncs B. The file's own header comment enumerates three invariants and checks the account-scoped localStorage key, but this one account-blind module variable defeats invariant 2 for image uploads specifically.
HIGH (real bug hit in production)
-
components/MailView.tsx:746-758—PermissionPanel.submitswallows the error entirely; an approval/rejection that failed leaves the UI claiming it was processed.} catch (err) { console.error(err); } finally { setBusy(false); }setDecided(...)runs beforeawait fetchInbox, but a network/401/409 thrown byapi.decidePermissionskips it and lands in theconsole.errorbranch — nosetStaleWarning, no error UI, anddecidedstays'', so the panel looks untouched and the user can re-click forever while the Agent waits. Every sibling call site in the same file (ForwardBar.submit:401-404,ReplyBar.send:1046-1051,BudgetEditor.commit:252) surfaces the message; this one is the odd branch and it is the only one that gates a security decision. -
components/MailView.tsx:390-404and:404-408—ForwardBar.submithas a double-submit hole combined with a failed refresh leaving the panel open.const submit = async () => { if (!to.trim() || busy) return; setBusy(true); ... await api.forwardMail(...); await Promise.all([fetchInbox('all'), fetchSent(), fetchSessions(), fetchContacts()]); onClose();The
busyguard only closes the window after React commitssetBusy(true). A fast double-click (or Enter-style rapid activation) issues twoforwardMailPOSTs — the second one is a duplicate forward. Worse, if any of the four follow-upfetch*calls rejects, thePromise.allrejects into thecatch,onClose()never runs, the forward did succeed, and the user sees an error and will press Forward again. The refreshes are the wrong thing to gate the close on. -
components/CalendarView.tsx:263-265— ICS object URL revoked before the click download has consumed it.a.click(); URL.revokeObjectURL(url);revokeObjectURLis called synchronously immediately aftera.click()and with no anchor appended to the document. Firefox/WebKit start the download asynchronously; revoking in the same task invalidates the blob and the export silently produces a 0-byte or failed download. Needs thesetTimeout(revoke, 0)(or a tick later) pattern used elsewhere in the repo.
MEDIUM (correctness or robustness gap)
-
components/CalendarView.tsx:101-106— stale-while-revalidate race inload(): a slower earlier month can overwrite a newer one.useEffect(() => { setLoading(true); load(); }, [range.from, range.to]);load()closes overrangeand unconditionally doessetEvents(r.events ?? [])with no request-generation guard. Rapidly tapping 下一页/上一页 (or a swipe) issues overlappinglistCalendarEvents; the responses can settle out of order and the grid shows a different month's events than the title says, with no error.ThreadView.tsx:66-85gets this right (gen.current+myGen);CalendarViewhas no equivalent. -
components/ComposePage.tsx:160-163—setTimeoutafter unmount and aftercancelCompose().setTimeout(() => { setOkMsg(null); cancelCompose(); }, 900);No ref, no cleanup on unmount. Within the 900 ms the user can hit 取消/返回/清空/切换视图, unmounting the component; React logs an update-after-unmount on the setState and the pending timer keeps a closure over the whole compose page alive. Combined with the "清空" button (
:206-217) which is not disabled whilesending, this is reachable. -
components/AdminUsersPage.tsx:93-96andcomponents/KeyPanel.tsx:37-40— same uncleared-timeout pattern (flash()and the clipboard "copied" reset), both set state on an unmountedAdminUsersPage/NewKeyBannerafter navigation or account switch. -
components/MailView.tsx:820-846— approval chips do not visually block while the request is in flight.disabled={busy} className={... disabled:opacity-40 ...}disabledon<button>does block clicks, so this is not a real double-submit — butsetBusy(true)is only committed on the next render, and the panel keeps rendering the full chip set plus (below) the answer field, so a fast double activation within one frame can still fire twodecidePermissioncalls.PermissionPanelhas no synchronous in-flight ref;ThreadView.loadMoreuses exactly that pattern (busy.current) for the same class of bug. -
components/BackgroundPicker.tsx:120-122— preset thumbnails always paintvar(--bg-image)on top of the preset gradient.className={`bg-preset-${p.id} absolute inset-0 block`} style={{ backgroundImage: 'var(--bg-image)', backgroundSize: 'cover' }}When a custom image background is active,
--bg-imageis set (backgroundStore.ts:262), so all six preset thumbnails render the image and none of them show their own gradient — the picker then shows six identical tiles while the user is choosing a preset. Should be omitted whenkind === 'image', or the thumbnail span should be omitted. -
components/Attachments.tsx:96-100— attachment list built with a staleitemsclosure when uploads overlap.handleFilesreadsitemsfrom the render closure and callsonChange([...items, ...added]); the picker is only blocked byuploading !== nullon the button (:112), not on the<input type="file">(:105-112), and thedisabledprop passed byComposePage(:320) issendingonly. Rapid re-picks can drop an earlier selection. Same shape inReplyBar.
FALSE POSITIVES YOU RULED OUT
- No
dangerouslySetInnerHTML/innerHTML/insertAdjacentHTMLanywhere inclient/electron/src/(grep returned zero hits). All three Markdown render sites (MailView.tsx:146,:690,ComposePage.tsx:305) use<Markdown remarkPlugins={[remarkGfm]}>with norehype-raw, so raw HTML stays escaped anddefaultUrlTransformneutralisesjavascript:/data:— exactly the invarianttest/markdown-xss.test.mjsguards. Not a finding. - No attacker-controlled
href/srcscheme. The only dynamichrefisAttachments.tsx:22api.attachmentURL(a.attachment_id), built frombase()+ a server-issuedattachment_id+encodeURIComponent'd token; the only dynamicsrcisBackgroundPicker.tsx:155with a locally producedimage:/JPEG data URL.withToken(api/config.ts:118-122) percent-encodes the token. api/sse.tsEventSource URL iswithToken(\${API_BASE}/events/stream`);API_BASEcomes from the user's own account gateway (normalizeGatewayinlib/accounts.ts:55-67only prependshttp(s)://). Self-inflicted, not remote-supplied — I deliberately did not report the plaintext-http://` gateway default.MonthGrid/WeekGridusekey={i}(CalendarView.tsx:534,:609) — I checked and this is safe: the parent iskey={${anchor.getTime()}-${scale}}(:390), so the entire grid remounts on every navigation and index identity never crosses a data change. Not a stale-row bug.ContactPanelContactRownests two buttons (the row<button onClick={onOpen}>at:230and the.revealaction buttons at:243-258) — valid HTML, not nested-interactive, and the actions are not descendants of the row button, so no click swallowing. Not reported.MailList/PermissionListuseonClickon<button>throughout (interactive elements, correct);useAccountStore(activeAccount)inAccountList.tsx:28is a stable module-level selector, not an inline closure, so it is not a zustand v5 infinite-render trap.LoginPage.tsx:43-55setIntervalis cleared both in the effect cleanup and inside thesetCountdownupdater — no leak.AddressInput.tsx:195-199removesscrollwith matching{ capture: true }— listener removal is correct.backgroundStore.loadImage(backgroundStore.ts:379-393) revokes the object URL in bothonloadandonerror— no blob leak.- The
LEGACY_BACKUP_KEYpermanent residue and the legacy→per-account migration ordering inreadStoredare explicitly documented decisions (the file says "别再问这键谁写的") — not a finding. PermissionList.tsx:57-71expanded ?? autoOpenfreeze — thependingKeyeffect deliberately re-opens newly arrived pending sessions; the comment explains why. Deliberate, not reported.App.tsx:105-109resetUI()on logout clears the view state only. The underlying data stores are not cleared — I am not reporting that as a separate leak, because with no accounts configured theAccountSwitcherrendersnulland the store contents are unreachable without re-selecting the same gateway, so it is a cleanliness issue rather than a fresh disclosure on its own. (The real disclosure is the account-switch path above.)
Areas not reached
components/icons.tsx, ModelScopePanel.tsx, KeyPanel.tsx (beyond the snippet above), QuotaPanel.tsx (beyond line 120), ModelScopeTab tail, AccountPage.tsx lines 90-200, Sidebar.tsx, NarrowOnly.tsx, BackButton.tsx, PermissionChip.tsx, ConnectionIndicator.tsx, lib/lunar.ts (327 lines), lib/addressSuggest.ts body, types/index.ts. client/electron/test/CRITERIA.md was read only as far as the sections on structural criteria; I did not audit the test suite itself.