# 系统推送链 与 GUI 审查报告 审查对象: - **推送链(端到端)**:`client/harmony` 推送侧(`PushService.ets` / `PushContract.ts` / `EntryAbility.ets`) + `server/internal/push/*`(`push.go` / `hms.go` / `config.go`)+ `handler/push.go` + `repo/push_tokens.go` + `notify/mail.go` - **GUI(Electron/React)**:`client/electron/src/`(约 14,100 行,10 个 store + 30 个组件) 基线:`node test/run-all.mjs` → 577 判据 / 565 通过 / 3 红(与本报告无关,见上份报告) Go 侧未能执行 `go test`(本机无 `GOMODCACHE`/`GOPATH`),推送逻辑以**读码 + 对照既有测试**验证。 --- # 第一部分:系统推送 ## 一、先说结论:推送链整体是健康的 设计上有几处**明确比一般实现高明**的地方,先列出来避免后续误改: - **不写死华为**:`Notifier` 接口 + `Factory` 注册表 + `push.json` 配置式接入, `RegisterType` 一个函数就能加厂商。 - **单项配错不拖垮服务**:`config.go:133-159` 逐项 `continue` 并打明确日志, 网关照常启动 —— 推送是锦上添花,不能变成单点故障。 - **推送是可选旁路且零开销**:`shouldDispatch` 抽成**纯函数**(`push.go:115-117`), 且注释诚实记录了变异验证曾经失败的原因(goroutine 竞态让错误理由也能绿)。 - **所有失败静默**:客户端 `PushService` 每一步 catch 都不打扰用户、不阻塞登录。 - **有效 token 自愈**:华为回 `80300007` / `illegal_tokens` 时自动删表(`hms.go:244-254`), 因为测试额度是**项目级**的,死 token 会白吃额度。 - **动态 `IN (...)` 的方言安全性我已验证**:`repo/push_tokens.go` 手拼 `$1,$2…` 占位符, 而 `db.go:7` 明确「SQLite 也支持 $1/$2,无需改写」⇒ 两种方言都安全,**不是 SQL 注入** (占位符位置由 `i+1` 生成,值一律走参数绑定)。 ## 二、推送链的真实缺陷 ### 1.【HIGH】每日额度按 **token 数**扣,且在**实际投递之前**扣 `hms.go:192-194`: ```go if !h.reserveDaily(len(tokens)) { return fmt.Errorf("达到每日推送上限 %d 条(HMS_DAILY_LIMIT)", h.DailyLimit) } tok, err := h.accessToken(ctx) // ← 额度已经扣了 ... // 网络请求在下面,失败也只记日志 ``` 两个问题叠在一起: - **计数单位错**:`reserveDaily(len(tokens))` 记的是 **token 个数**,而注释与变量名 (`dayCount`、`达到每日推送上限 N 条`)都说是「**条**」。华为的测试消息额度是 **按条消息**限的(一次 `messages:send` 一条消息,无论带几个 token)。 ⇒ **3 个设备 1 封邮件就吃掉 3 条额度**,实际发出去 1 条。 多设备自部署用户会以约 1/设备数 的速度提前耗尽 1000 条/天。 - **扣在投递前**:`accessToken` 失败、HTTP 失败、华为返回非成功码 —— 这些**一条都没发出去**, 但额度已经扣掉了。而失败只 `log.Printf`(`push.go:174-176`),用户侧完全无感。 ⇒ 一次网络抖动会静默烧掉配额。 **修法**:`reserveDaily(1)`(按批次计),并把预留挪到**确认拿到 access_token 之后**、 或失败时归还。 > **修复状态(2026-09-28)**:① 已修(`044a664`,`reserveDaily(1)`,有判据 > `TestHMSDailyLimitCountsMessagesNotTokens`)。 > ② **曾被声称已修但没落地** —— `044a664` 的 commit message 承诺「挪到确认能发之后」, > diff 里却只改了传参,**调用位置仍在 `accessToken` 之前**,注释与代码自相矛盾。 > 之所以没被当场发现,是因为当时那批判据**造不出「accessToken 失败」这条路**。 > 现已真正落地(预留移到 `accessToken` 成功之后、发请求之前), > 并补上两格判据: > - `TestHMSAccessTokenFailureDoesNotBurnQuota` —— 钉**内部计数器**(`dayCount == 0`) > - `TestHMSQuotaSurvivesTokenFailureWithLimitOne` —— 钉**用户看得见的行为** > (`DailyLimit=1` 时一次 `accessToken` 失败后第二次仍然要能发出去) > > 两层都要钉:计数器对而行为错是可能的 —— 那会让运维收到「达到每日推送上限」这种 > **误导性文案**,真实原因却是上一次网络抖动。 > 变异验证:把预留挪回 `accessToken` 之前,**两格同时红**。 > 抓住这处不一致的是 pi。 ### 2.【HIGH】HMS 通知载荷缺 `click_action`,锁屏点击可能带不出 `data` `hms.go:206-217`: ```go "message": map[string]any{ "token": tokens, "notification": map[string]any{ "title": ..., "body": ... }, "data": string(data), // ← data 带了,但没有任何 click 行为声明 } ``` 客户端**完全依赖** `data` 里的 `mail_id` 做跳转: `EntryAbility.onCreate` / `onNewWant` → `PushService.routeFromWant` → `routeFromWant(params, ...)` 读 `parameters['data']`(`EntryAbility.ets:84-86`、`120-122`)。 而载荷里**没有 `click_action`、没有 `intent`、没有 `backgroundMode`**。 HMS v1 在「普通通知」形态下,点击行为由 `click_action` 决定(`1`=打开应用、`2`=打开页面、 `3`=触发 Action);**缺省时不同形态/系统版本对 `data` 是否透传到 `want.parameters` 的行为并不一致**。 项目里 `PushContract.ts:9-10` 自己写着「服务端推送是**至多一次、无幂等键**」, `EntryAbility.ets:78-82` 也记录了 2026-09-15 为此专门加过冷启处理 —— 说明这条链此前已经因「跳转目标丢失」返工过一次,而**根因(载荷形态)没有变**。 **这正是「App 弹到前台、停在列表」的另一种表现形态。** **修法**:显式声明点击行为(HMS v1 `click_action` 或按 Action 形态补 `intent`), 并**用真机 token 实测一次**确认 `data` 能进 `want.parameters` —— `hms.go:50` 自己写着「我**无法在本机验证成功路径**(需要一台真机产出的 token)」, 这个未知项至今没有关闭。 ### 3.【MEDIUM】通知标题把**完整邮件主题**写进锁屏 `push.go:36-38` 的设计声明: > 「字段刻意少:推送只负责『通知栏那一行 + 点进去看哪封信』。 > **正文不进通知**,否则锁屏上就会露出邮件内容。」 但 `hms.go:211` 实际是: ```go "title": "新邮件:" + truncate(n.Subject, 40), // ← 主题全文前 40 字 "body": n.From, ``` `Subject` 是**邮件主题本身**,标题里就带「报销 3 万走哪个账户」这类内容。 声明说"正文不进通知",实现却把**主题**放进去了 —— 这在锁屏上是可见的。 (`truncate` 限了 40 字,但 40 字足够泄漏一个机密项目名。) **修法**:要么按声明改成固定文案(如「新邮件」+ 发件人), 要么把 `push.go` 的注释改成与实现一致 —— 现状是**注释在骗人**,这比任一选择都糟。 ### 4.【MEDIUM】客户端写死 `provider: 'hms'`,与"配置式多厂商"自相矛盾 `PushContract.ts:13` + `PushService.ets:348,381` 无条件用 `PROVIDER_HMS`。 服务端明确支持一实例多厂商、且 `name` 可覆盖(`config.go:44-47`)。 但客户端把 `hms` 硬编码,`PushService.ts:360-361` 又只是**把** `resp.providers` 打进日志 就丢掉,从不据此选择。⇒ 服务端配了小米通道,鸿蒙客户端永远不会往它登记。 讽刺的是 `PushContract.ts:203-207` 的注释正在讲"不要硬编码只有 hms 合法"这个原则, 而调用处恰恰硬编码了。 ### 5.【MEDIUM】`NotificationLedger` 只在 want 层去重,SSE 侧无跨通道去重 `PushContract.ts:9-10` 说「重复保护只能落在客户端」,`EntryAbility` 用 `NotificationLedger(50)` 按 `mail_id` 去重(`EntryAbility.ets:42`)。 但这只覆盖「点通知」这一条路径。用户实际会看到**两次**: 1. App 在前台 → SSE `new_mail` → `MainPage.ets:2911` `showToast('新邮件:…')` 2. HMS 通知同时下发 → 通知栏一条 两条路径**各自独立**、无共享去重台账 ⇒ 前台每次都"toast + 通知栏"双份。 `main.go` 端也没有按在线状态抑制推送的逻辑(`shouldDispatch` 只看 `Enabled()` 和收件人非空)。 ### 6.【MEDIUM】`ledger` 是 `EntryAbility` 实例字段,50 条就会开始漏 `EntryAbility.ets:42` `private ledger: NotificationLedger = new NotificationLedger(50)`。 台账按 `mail_id` 去重且**有界(50)**。高流量场景(Agent 批量发信)下, 第 51 封之后旧 `mail_id` 被挤出 ⇒ 同一封邮件若被系统重放 `want`(HMS 在网络重试时确实会) 会**再次触发跳转**。属于可接受但需知情的取舍,`limit=50` 是否够值得确认。 ### 7.【LOW】`client` 侧 `PushService` 在 `aboutToAppear` 之前就上报(已知,非新问题) `EntryAbility.onCreate:106` 用 `ApiClient.getInstance(...)` 上报,而该实例**尚未 `init()`** (`init()` 只在 `LoginPage.ets:101` 与 `MainPage.ets:3128` 调)⇒ 此刻无 token,服务端回 401。 `LoginPage.ets:168-192` 的 `reportPushToken` 已在登录后补报,**已修复**。 仅记录:`EntryAbility.ets:101` 那句注释仍写「`PushService.isEnabled`,默认关」, 而 `PushContract.DEFAULT_PUSH_ENABLED = true` —— **注释与实现相反**,会误导后续维护。 ### 8.【LOW】`SessionApi` 同类问题:`null` 违反本仓约定 `api/SessionApi.ets:48` `proposal: RenameProposal | null = null`。 本仓 ArkTS 约定偏向 `undefined`。服务端用 `null` 表示"没有待处理建议", 客户端需要原样承载 —— 属于**契约强制**,但与 `PushContract.ts` 的 `| undefined` 风格不一致。 --- # 第二部分:GUI(Electron/React) ## 一、先说结论:GUI 的 XSS 面是干净的 我独立复核并确认: - **全仓零** `dangerouslySetInnerHTML` / `innerHTML` / `insertAdjacentHTML` - 三个 Markdown 渲染点(`MailView.tsx:146`、`:690`、`ComposePage.tsx:305`)统一用 ``,**没有 `rehype-raw`** ⇒ 原始 HTML 被转义 - 唯一的动态 `href` 是 `Attachments.tsx:22` 的 `api.attachmentURL(a.attachment_id)` (服务端签发的 id + `encodeURIComponent` 过的 token),唯一的动态 `src` 是本地生成的 JPEG data URL - `electron/main.cjs:47-48`:`contextIsolation: true` / `nodeIntegration: false` ✔ - `main.cjs:47-48` 有 `preload`,未见 `openExternal` / `will-navigate` 缺口 **这条链上 `test/markdown-xss.test.mjs` 的不变量没有被绕过。** ## 二、GUI 的真实缺陷 ### 1.【CRITICAL】切账号不清 store ⇒ **A 账号的邮件显示在 B 账号下** `AccountSwitcher.tsx:56-62`: ```tsx const pick = async (id: string) => { setOpen(false); await setActive(id); await fetchInbox('all'); // ← 只重取了收件箱 }; ``` `setActive`(`accountStore.ts:186-197`)会 `syncAuth(next, id)`,**把 `api/config` 单例的 `API_BASE` 与 bearer 翻到新账号**。此后: - `useMailStore.sent`、`currentMail` - `useSessionStore.sessions`、`currentSession`、`currentSessionMails` - `useContactStore.contacts` / `archivedContacts` **一个都没复位**。而后续单账号请求(`api.getSent()`、`api.getMail()`、`api.archiveContact()`) 会带上 **B 的凭证**。 后果:A 账号下打开发件箱 → 切到 B → 发件箱**仍是 A 的邮件**; `MailView.tsx:67` 仍按 A 的会话渲染全文;**从这些陈旧视图点转发/归档/批准,改的是 B**。 grep 确认:只有 `MailList.pick` / `ContactPanel.open` / `PermissionList.pick` 会调 `clearSession()` / `clearCurrentMail()`,**账号切换路径一个都没有**。 **修法**:`setActive` 之后除 `fetchInbox` 外,还要 `clearCurrentMail()` / `clearSession()` / 清 `sent` / 清 contacts。 > 这与鸿蒙侧 `MailStore.clear()` **零调用**(上份报告第 3 条)是**同一个根因的两端**: > 两个客户端都在"切换身份"时只刷新了主列表,没清其余状态。 ### 2.【CRITICAL】`lastUploadedImage` 是模块级单例、不按账号分 ⇒ 第二个账号的壁纸**永不上传** `appearanceSync.ts:40`: ```ts let lastUploadedImage = ''; // 模块级,不带账号维度 ``` `pull()` 第 87 行写它(从服务端图回填时),`push()` 第 115 行用它做跳过判据: ```ts if (ok && bg.kind === 'image' && bg.imageDataUrl && bg.imageDataUrl !== lastUploadedImage) { ``` 路径:A 账号把壁纸设为 X(上传成功 ⇒ 标记 = X)→ 切到 B 账号,B 的服务端记录是 `saved:false` ⇒ `pull()` 走 `appearanceSync.ts:69-76` 的"**把本地这份推上去**"分支 (`:74` 调 `push()`)⇒ 但跳过判据被 A 留下的标记满足 ⇒ **B 的壁纸永远不传**,而 `:120` 仍把 `status` 置为 `'synced'`。 ⇒ B 的壁纸在其**其它所有设备**上都缺失,而界面上显示"已同步"。 讽刺的是该文件头明确把 localStorage 键按账号做了隔离, 却漏了这个变量 —— 它同样需要 `accountId` 维度。 **修法**:`lastUploadedImage` 改成 `Map`,或直接存进按账号分区的 localStorage(与现有 key 策略一致)。 ### 3.【HIGH】权限审批**吞掉错误** ⇒ 失败的"同意"看起来像成功了 `MailView.tsx:746-761`: ```ts } catch (err) { console.error(err); // ← 只进控制台 } finally { setBusy(false); } ``` `setDecided(...)`(`:753`)在 `await api.decidePermission` **之后**。 ⇒ 请求失败时 `decided` 仍是 `''`,界面回到可点状态,**用户看不到任何提示**, 只会以为"点了没反应"而反复点,而 Agent 那边一直阻塞。 同文件其它提交路径(`ForwardBar.submit:401`、`ReplyBar.send:1046`、`BudgetEditor.commit:252`) 都正确地 `setError` 了 —— 只有这一处是例外。 **修法**:`catch` 里 `setError(...)` 并把错误渲染出来。 ### 4.【HIGH】转发有双发窗口,且"关窗"依赖可能失败的刷新 `MailView.tsx:390-408`: ```ts if (!to.trim() || busy) return; setBusy(true); ... await api.forwardMail(...); await Promise.all([fetchInbox('all'), fetchSent(), fetchSessions(), fetchContacts()]); onClose(); ``` - `busy` 守卫要等 React 提交 `setBusy(true)` 之后才生效 ⇒ **快速双击会发出两个 `forwardMail`**(真转发,不是本地乐观更新) - 四个刷新里任一 reject ⇒ `onClose()` 被跳过,**尽管转发已经成功** ⇒ 用户看到报错、于是**再转一次** **修法**:`setBusy` 之前用同步 ref 置位;`onClose()` 挪进 `finally` 或改成 `Promise.allSettled`。 ### 5.【HIGH】ICS 导出的 object URL **同步 revoke** ⇒ 文件可能 0 字节 `CalendarView.tsx:262-267`: ```ts const url = URL.createObjectURL(new Blob([ics], ...)); const a = document.createElement('a'); a.href = url; a.download = `...ics`; a.click(); URL.revokeObjectURL(url); // ← 同一同步块内立即撤销 ``` `a` 也从未插入 document。Firefox / WebKit 的下载是**异步**取 blob 的 ⇒ 同步撤销会导致**静默失败或 0 字节 .ics**(Chromium 通常容忍,故容易漏测)。 **修法**:`setTimeout(() => URL.revokeObjectURL(url), 0)`,并把 `a` 挂到 document 后再 `remove()`。 ### 6.【MEDIUM】日历加载无请求代次守卫 ⇒ 快速翻月时"标题与内容错配" `CalendarView.tsx:101-106`: ```ts useEffect(() => { setLoading(true); load(); }, [range.from, range.to]); ``` `load()` 里无条件 `setEvents(r.events ?? [])`。 快速点「下一页/上一页」⇒ 两个请求同时在飞,**后落地的覆盖先落地的** ⇒ 标题显示 11 月而网格是 10 月的事件。 对比:`ThreadView.tsx:66-85` 用 `gen.current` / `myGen` **做对了**, `CalendarView` 没有对应实现。 ### 7.【MEDIUM】三个 `setTimeout` 无卸载清理 `ComposePage.tsx:160-163`(900ms 后清提示并关窗)、`AdminUsersPage.tsx:93-96`、 `KeyPanel.tsx:37-40`。`ComposePage` 那条可达(清空按钮在 `sending` 期间未禁用)。 ### 8.【MEDIUM】预设缩略图全部渲染同一张自定义图 `BackgroundPicker.tsx:120-122`: ```tsx className={`bg-preset-${p.id} absolute inset-0 block`} style={{ backgroundImage: 'var(--bg-image)', backgroundSize: 'cover' }} ``` 当 `kind==='image'` 时 `--bg-image` 有值 ⇒ **六个预设格子都显示同一张照片**, 而用户正在这一步里挑选预设。 ### 9.【LOW】`api.attachmentURL` 把 token 放进 query `api/client.ts:385-387` + `:118-122`。服务端**有意支持** (`main.go:299` 用 `middleware.UserAuthAllowQueryToken`,`handler/events.go:17` 注释说明 这是浏览器 `EventSource` 专用回退)⇒ **不是缺陷**。 但代价是 token 会进浏览器历史/服务端访问日志,是已知的取舍,记录备查。 (鸿蒙侧 `ApiClient.getBytes` 的注释明确写了"不用 `?token=`,那会把密钥写进日志" —— **两端做法不一致**,值得统一口径。) --- ## 三、本轮明确排除的疑点 - **推送 SQL 注入**:`repo/push_tokens.go` 的 `IN (...)` 占位符由序号生成、值全走绑定, 且两种方言都支持 `$N` ⇒ 安全。 - **Go 推送测试**:23 个测试覆盖了形状、分批、限额、无效 token 清理、配置容错 —— 覆盖面是好的;`TestHMSDailyLimitStopsSending` 用例也说明"按 token 计"是**已固化的行为**, 但**没人质疑这个计数单位**(见缺陷 1)。 - **`resetUI()` 登出不清数据 store**:单独看不可达(无账号时 `AccountSwitcher` 渲染 `null`), 真正的泄露面是**切账号**路径(缺陷 1)。不算独立缺陷。 - **Electron `MonthGrid key={i}`**:父级 `key={${anchor.getTime()}-${scale}}` 使其 每次导航都重挂载,索引不会跨数据变化 ⇒ 安全。 - **`ContactPanel` 双按钮**:非嵌套可交互元素,无点击吞没。 - **GUI 监听器/定时器清理**:`LoginPage.tsx:43-55`、`AddressInput.tsx:195-199` (`{capture:true}` 成对)、`backgroundStore.ts:379-393`(两条路径都 revoke)⇒ 正确。 --- ## 四、修复顺序建议 | 序 | 项 | 位置 | 理由 | |---|---|---|---| | 1 | 切账号清 store | `AccountSwitcher.tsx:56-62` | 唯一的**跨账号数据泄露**,改动最小 | | 2 | 壁纸按账号分 | `appearanceSync.ts:40` | 静默丢数据 + 谎报"已同步" | | 3 | 审批错误提示 | `MailView.tsx:756-757` | 权限决策失败却无反馈,Agent 一直阻塞 | | 4 | 每日额度计数单位 | `hms.go:192` | 用户可感知的功能失效(提前耗尽配额) | | 5 | HMS `click_action` + 真机实测 | `hms.go:206-217` | 需真机 token 才能关闭这个未知项 | | 6 | ICS revoke 时机 | `CalendarView.tsx:267` | 导出功能在部分内核上失效 | | 7 | 转发双发 / 关窗 | `MailView.tsx:390-408` | 数据重复 | | 8 | 锁屏文案 | `hms.go:211` + `push.go:36-38` | 注释与实现不一致(二选一,别留现状) | | 9 | 日历代次守卫 | `CalendarView.tsx:101` | 照抄 `ThreadView.tsx:66-85` 即可 | | 10 | provider 硬编码 | `PushContract.ts:13` | 影响"配置式多厂商"这个卖点 |