From a7b631f468d8ca7a3e7a318437f9d6048f236546 Mon Sep 17 00:00:00 2001 From: Cheng Zhou Date: Thu, 2 Jul 2026 10:43:30 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E6=A1=8C=E9=9D=A2=E7=AB=AF?= =?UTF-8?q?=E5=9B=9E=E5=BD=92=E4=B8=8E=E5=AE=89=E5=85=A8=E8=BE=B9=E7=95=8C?= =?UTF-8?q?=E5=A4=8D=E5=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...desktop-release-stabilization-checklist.md | 30 +++++++- docs/desktop-project-plan.md | 17 +++++ frontend/scripts/verify-desktop-release.mjs | 58 ++++++++++++++- frontend/src-tauri/capabilities/default.json | 4 +- frontend/src-tauri/src/credentials.rs | 13 ++++ .../src/components/Layout.desktop.test.ts | 1 + frontend/src/router.test.ts | 15 ++++ .../src/runtime/secureSessionStorage.test.ts | 73 +++++++++++++++++++ frontend/src/runtime/secureSessionStorage.ts | 2 +- .../src/session/desktopUpdateManager.test.ts | 23 ++++++ frontend/src/session/desktopUpdateManager.ts | 17 ++++- frontend/src/session/sessionManager.test.ts | 33 +++++++-- frontend/src/session/sessionManager.ts | 1 + frontend/src/views/DesktopPreferences.vue | 2 +- 14 files changed, 275 insertions(+), 14 deletions(-) diff --git a/docs/audits/desktop-release-stabilization-checklist.md b/docs/audits/desktop-release-stabilization-checklist.md index d7368df4..8eebb598 100644 --- a/docs/audits/desktop-release-stabilization-checklist.md +++ b/docs/audits/desktop-release-stabilization-checklist.md @@ -2,7 +2,7 @@ 状态: `active` 适用范围: Web 与 macOS Desktop 统一客户端发布 -最后更新: `2026-07-01` +最后更新: `2026-07-02` 本清单用于第一、二阶段桌面端能力完成后的准发布稳定化。它不引入离线登录、本地业务数据存储、内嵌后端服务或离线同步。 @@ -51,6 +51,10 @@ npm run desktop:build:app - [ ] 前端源码不通过 query string 传递 token。 - [ ] `ctms_token` 只允许由 `secureSessionStorage` 处理。 - [ ] 系统通知只能通过 `frontend/src/runtime/notifications.ts` 发送,标题和正文保持通用。 +- [ ] 通知 capability 只暴露权限查询、权限请求和发送通知,不使用 `notification:default`。 +- [ ] opener capability 只允许打开 `$TEMP/ctms-desktop/**` 下的临时文件,不开放 URL 或 reveal 权限。 +- [ ] updater capability 不直接暴露给 WebView,自动更新只走受控 Tauri command。 +- [ ] 更新弹窗 release notes 过滤 URL、token 查询参数和 Authorization/Bearer 形态文本。 - [ ] CI release 候选 workflow 包含 version/runtime/desktop/ui/type/unit/build/desktop app smoke 门禁。 - [ ] signed macOS release candidate workflow 只允许从 `vX.Y.Z` tag 运行,并包含签名环境检查、Universal macOS 构建、update feed 生成、checksum 校验和 verified release directory 上传。 @@ -155,11 +159,16 @@ npm run desktop:build:app 已完成的自动化收口: - 服务器地址切换时,桌面设置页调用 `auth.logout({ rememberCurrentStudy: false })`,避免退出时把旧服务器项目记入当前用户的最近项目;随后继续清除当前项目上下文。 +- 系统偏好连接页与独立服务器设置页保持同一切换服务器语义,切换时不记忆旧服务器项目并清除当前项目上下文。 - 系统通知轮询在部分通知显示失败时,先 ack 已成功显示的通知,再让失败项通过租约重试,贴合“显示成功后 ack;失败等待重试”的回归预期。 - 自动更新管理器新增稍后提醒 24 小时抑制、安装失败可重试、检查失败不打断业务和未启用更新状态的单元覆盖。 - 附件 API 新增 blob 下载、multipart 上传和删除端点单元覆盖,确保下载凭据继续由 axios Authorization header 承载而不是进入 URL。 - 文件任务反馈 helper 新增选择、保存、取消保存和打开的单元覆盖,约束桌面保存/打开继续走 `frontend/src/runtime/` 适配层。 -- 新增相关单元测试覆盖服务器切换不记忆旧项目、通知权限未授权不领取、部分通知失败时只 ack 成功项、自动更新失败恢复路径,以及附件文件流契约。 +- Keychain/凭据库会话新增旧浏览器 token 迁移、未配置服务器不读取凭据、本地 30 天上限、服务器切换删除旧服务器凭据,以及恢复 token 必须先经 `/me` 校验的单元覆盖。 +- 桌面发布门禁新增单实例重复启动处理校验,要求重复启动时恢复、显示并聚焦 `main` 窗口。 +- 会话刷新后的跨窗口 token 更新不再写入 `localStorage` fallback,只通过内存态 BroadcastChannel 通知,避免 token 进入明文广播缓存。 +- `desktop:release:check` 新增 session broadcast 静态门禁,防止 `TOKEN_UPDATED` payload 回退写入 `localStorage`。 +- 新增相关单元测试覆盖服务器切换不记忆旧项目、通知权限未授权不领取、部分通知失败时只 ack 成功项、自动更新失败恢复路径、附件文件流契约、30 天在线会话恢复边界、单实例恢复行为和 token 广播存储边界。 仍需人工或真实环境验证: @@ -168,3 +177,20 @@ npm run desktop:build:app - 系统通知授权/拒绝的 OS 级交互。 - 单实例重复启动聚焦主窗口。 - 签名 release 构建下的自动更新 feed、验签、安装和重启实物流。 + +## 9. 2026-07-02 安全边界复审记录 + +本轮安全复审在端到端自动化收口之后推进,不改变桌面端在线客户端边界。 + +已完成的安全边界收口: + +- Tauri notification capability 从 `notification:default` 收敛为 `notification:allow-is-permission-granted`、`notification:allow-request-permission` 和 `notification:allow-notify`。 +- `desktop:release:check` 新增 capability 最小化约束,拒绝 `notification:default`、opener URL/reveal 权限和 WebView 直连 updater 权限。 +- `desktop:release:check` 将 token URL 检查扩展到 `token` 与 `access_token` 查询参数,并扩大日志敏感词检查到 `token`、`access_token`、`authorization` 和 `bearer`。 +- 更新弹窗 release notes 增加清理逻辑,过滤 URL、token 查询参数、`access_token`、`Authorization` 和 `Bearer` 形态文本,避免 feed 内容把下载链接或凭据样式文本带入 UI。 +- 凭据库 Rust 单测新增带凭据 server origin 拒绝,以及 Keychain/Credential Manager account 不暴露原始服务器 origin 的覆盖。 + +仍需人工复审确认: + +- 真实生产 release notes 内容保持通用,不写入项目、文件、下载链接或敏感业务详情。 +- 正式签名、公证和 updater feed 环境继续使用组织 secret,不在日志、artifact 或配置中泄露私钥材料。 diff --git a/docs/desktop-project-plan.md b/docs/desktop-project-plan.md index 8dcd39c7..9276cc4b 100644 --- a/docs/desktop-project-plan.md +++ b/docs/desktop-project-plan.md @@ -132,6 +132,23 @@ 进入端到端人工回归前,应先确认 `npm run desktop:release-readiness:check` 在正式 release tag 和签名环境中通过;否则只能进行普通 smoke 验证,不能判定 macOS 发布稳定化已经完成。 +## 2026-07-02 安全边界复审推进记录 + +本次安全边界复审在端到端自动化收口之后继续推进,仍不引入离线能力、本地业务数据存储、内嵌后端或独立桌面业务 UI。 + +已补齐的安全边界自动化: + +- Tauri 通知 capability 已从 `notification:default` 收敛为权限查询、权限请求和发送通知三项显式权限。 +- `npm run desktop:release:check` 已拒绝 `notification:default`、opener URL/reveal 权限和 WebView 直连 updater 权限,继续要求文件与 opener scope 仅限 `$TEMP/ctms-desktop/**`。 +- `npm run desktop:release:check` 已扩展 token URL 和日志静态检查,覆盖 `token`、`access_token`、`Authorization` 和 `Bearer` 形态。 +- 自动更新弹窗 release notes 已过滤 URL、token 查询参数和 Authorization/Bearer 形态文本,避免从 feed 将下载链接或凭据样式文本带入用户界面。 +- Rust 凭据命令新增单测,确认带凭据 server origin 被拒绝,系统凭据库 account 使用 origin 哈希且不暴露原始服务器地址。 + +仍需正式发布负责人在真实发布环境确认: + +- 生产 release notes 内容保持通用,不包含项目、文件、下载链接或敏感业务详情。 +- 签名、公证、updater feed 和 artifact 上传日志不泄露 Apple 凭据、updater 私钥或下载源内部凭据。 + 如果后续任务试图新增离线登录、本地业务数据存储、内嵌后端、本地业务队列、离线同步或绕过后端权限审计,应先修改并评审本计划书,不能直接实现。 ## 当前质量门禁 diff --git a/frontend/scripts/verify-desktop-release.mjs b/frontend/scripts/verify-desktop-release.mjs index 09c811b3..adf48e35 100644 --- a/frontend/scripts/verify-desktop-release.mjs +++ b/frontend/scripts/verify-desktop-release.mjs @@ -86,7 +86,21 @@ const verifyCapabilities = async () => { "fs:allow-read-dir", "fs:allow-read-text-file", "fs:allow-write-text-file", + "notification:default", + "opener:default", + "opener:allow-open-url", + "opener:allow-reveal-item-in-dir", + "updater:default", + "updater:allow-check", + "updater:allow-download", + "updater:allow-install", + "updater:allow-download-and-install", ]); + const requiredNotificationPermissions = [ + "notification:allow-is-permission-granted", + "notification:allow-request-permission", + "notification:allow-notify", + ]; for (const file of files) { const capability = await readJson(resolve(capabilitiesDir, file)); @@ -97,6 +111,19 @@ const verifyCapabilities = async () => { assert(!identifier.startsWith("shell:"), `${file}: shell permissions are not allowed.`); assert(!bannedPermissions.has(identifier), `${file}: ${identifier} is not allowed for CTMS Desktop.`); assert(!identifier.includes("persisted-scope"), `${file}: persisted filesystem scopes are not allowed.`); + assert(!identifier.startsWith("updater:"), `${file}: updater permissions must not be exposed directly to WebView.`); + if (identifier.startsWith("notification:")) { + assert( + requiredNotificationPermissions.includes(identifier), + `${file}: notification permission ${identifier} is broader than the CTMS Desktop notification boundary.`, + ); + } + if (identifier.startsWith("opener:")) { + assert(identifier === "opener:allow-open-path", `${file}: opener permission ${identifier} is not allowed.`); + } + } + for (const identifier of requiredNotificationPermissions) { + assert(identifiers.includes(identifier), `${file}: missing ${identifier}.`); } const fsScope = permissions.find((permission) => permissionIdentifier(permission) === "fs:scope"); @@ -127,6 +154,19 @@ const verifyRustBoundary = async () => { dialogIndex < 0 || singleInstanceIndex < dialogIndex, "Single-instance plugin must be registered before other desktop plugins.", ); + const singleInstanceSource = libSource.slice( + singleInstanceIndex, + dialogIndex > singleInstanceIndex ? dialogIndex : singleInstanceIndex + 600, + ); + const restoreWindowTokens = ['get_webview_window("main")', "window.unminimize()", "window.show()", "window.set_focus()"]; + for (const token of restoreWindowTokens) { + assert(singleInstanceSource.includes(token), `Single-instance duplicate launch handler must call ${token}.`); + } + assert( + singleInstanceSource.indexOf("window.unminimize()") < singleInstanceSource.indexOf("window.show()") && + singleInstanceSource.indexOf("window.show()") < singleInstanceSource.indexOf("window.set_focus()"), + "Single-instance duplicate launch handler must restore, show, then focus the main window.", + ); const handlerSource = libSource.match(/generate_handler!\s*\\?\[([\s\S]*?)\]/)?.[1] || ""; const commands = handlerSource.match(/[a-z_]+::[a-z_]+/g) || []; @@ -153,9 +193,9 @@ const verifySourceSafety = async () => { for (const path of files) { const source = await readFile(path, "utf8"); const file = relative(rootDir, path); - assert(!/[?&]token=/.test(source), `${file}: token must not be passed through query parameters.`); + assert(!/[?&](?:token|access_token)=/i.test(source), `${file}: token must not be passed through query parameters.`); assert( - !/console\.(log|debug|info|warn|error)\s*\([^)]*token/i.test(source), + !/console\.(log|debug|info|warn|error)\s*\([^)]*(?:token|access_token|authorization|bearer)/i.test(source), `${file}: token-related values must not be written to console logs.`, ); if (source.includes("ctms_token") && file !== "frontend/src/runtime/secureSessionStorage.ts") { @@ -174,6 +214,19 @@ const verifyNotificationBoundary = async () => { assert(!/showSystemNotification\s*=\s*async\s*\([^)]*[a-zA-Z]/.test(source), "Desktop notification body must not accept dynamic business content."); }; +const verifySessionBoundary = async () => { + const source = await readFile(resolve(sourceDir, "session/sessionManager.ts"), "utf8"); + const tokenBroadcastIndex = source.indexOf('message.type === "TOKEN_UPDATED"'); + const storageBroadcastIndex = source.indexOf('localStorage.setItem("ctms_auth_broadcast"'); + + assert(tokenBroadcastIndex >= 0, "Session manager must branch TOKEN_UPDATED broadcasts before storage fallback."); + assert(storageBroadcastIndex >= 0, "Session manager must keep storage fallback for non-token auth broadcasts."); + assert( + tokenBroadcastIndex >= 0 && storageBroadcastIndex >= 0 && tokenBroadcastIndex < storageBroadcastIndex, + "Session manager must not persist TOKEN_UPDATED payloads through localStorage broadcast fallback.", + ); +}; + const verifyUpdaterBoundary = async () => { const source = await readFile(resolve(tauriDir, "src/updates.rs"), "utf8"); assert(source.includes('join("desktop-updates/stable/latest.json")'), "Desktop updater must derive the fixed stable latest.json path."); @@ -238,6 +291,7 @@ await verifyCapabilities(); await verifyRustBoundary(); await verifySourceSafety(); await verifyNotificationBoundary(); +await verifySessionBoundary(); await verifyUpdaterBoundary(); await verifyWorkflowGates(); diff --git a/frontend/src-tauri/capabilities/default.json b/frontend/src-tauri/capabilities/default.json index 08b32614..86d05e1a 100644 --- a/frontend/src-tauri/capabilities/default.json +++ b/frontend/src-tauri/capabilities/default.json @@ -16,7 +16,9 @@ { "path": "$TEMP/ctms-desktop/**" } ] }, - "notification:default", + "notification:allow-is-permission-granted", + "notification:allow-request-permission", + "notification:allow-notify", { "identifier": "opener:allow-open-path", "allow": [ diff --git a/frontend/src-tauri/src/credentials.rs b/frontend/src-tauri/src/credentials.rs index e48a0dc0..f103978c 100644 --- a/frontend/src-tauri/src/credentials.rs +++ b/frontend/src-tauri/src/credentials.rs @@ -111,4 +111,17 @@ mod tests { assert!(credential_account("http://ctms.example.com").is_err()); assert!(credential_account("http://localhost:8000").is_ok()); } + + #[test] + fn rejects_origins_with_embedded_credentials() { + assert!(credential_account("https://user:secret@ctms.example.com").is_err()); + } + + #[test] + fn account_does_not_expose_server_origin() { + let account = credential_account("https://ctms.example.com/path").unwrap(); + + assert!(!account.contains("ctms.example.com")); + assert!(!account.contains("https")); + } } diff --git a/frontend/src/components/Layout.desktop.test.ts b/frontend/src/components/Layout.desktop.test.ts index c17f7a7a..59855476 100644 --- a/frontend/src/components/Layout.desktop.test.ts +++ b/frontend/src/components/Layout.desktop.test.ts @@ -195,6 +195,7 @@ describe("desktop layout shell", () => { expect(preferences).not.toContain("copyConnectionDiagnostic"); expect(preferences).not.toContain("服务器连通性正常"); expect(preferences).not.toContain("服务器连接已确认"); + expect(preferences).toContain("auth.logout({ rememberCurrentStudy: false })"); expect(preferences).not.toContain("系统通知已开启"); expect(preferences).not.toContain("系统通知已关闭"); expect(preferences).not.toContain("通知诊断"); diff --git a/frontend/src/router.test.ts b/frontend/src/router.test.ts index 74af54fb..3751fc01 100644 --- a/frontend/src/router.test.ts +++ b/frontend/src/router.test.ts @@ -86,6 +86,21 @@ describe("admin project route permissions", () => { expect(source).toContain(' : studyStore.currentStudy\n ? "/project/overview"\n : "/admin/projects",'); }); + it("validates restored session tokens through /me before entering protected routes", () => { + const source = readRouter(); + const restoreGuardIndex = source.indexOf("if (!auth.user && getToken())"); + const fetchMeIndex = source.indexOf("await auth.fetchMe()", restoreGuardIndex); + const logoutIndex = source.indexOf("await auth.logout()", fetchMeIndex); + const loginRedirectIndex = source.indexOf('next({ path: "/login" })', logoutIndex); + const projectFallbackIndex = source.indexOf("if (token && !studyStore.currentStudy)", restoreGuardIndex); + + expect(restoreGuardIndex).toBeGreaterThan(-1); + expect(fetchMeIndex).toBeGreaterThan(restoreGuardIndex); + expect(logoutIndex).toBeGreaterThan(fetchMeIndex); + expect(loginRedirectIndex).toBeGreaterThan(logoutIndex); + expect(fetchMeIndex).toBeLessThan(projectFallbackIndex); + }); + it("does not register the removed non-admin personal dashboard route", () => { const source = readRouter(); const removedComponent = ["My", "Work", "bench"].join(""); diff --git a/frontend/src/runtime/secureSessionStorage.test.ts b/frontend/src/runtime/secureSessionStorage.test.ts index 1cf45d64..9ad63c4d 100644 --- a/frontend/src/runtime/secureSessionStorage.test.ts +++ b/frontend/src/runtime/secureSessionStorage.test.ts @@ -1,8 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { DESKTOP_SERVER_URL_KEY } from "./desktopServerConfig"; import { + clearSessionToken, getSessionToken, initializeSecureSessionStorage, + LEGACY_TOKEN_KEY, resetSecureSessionStorageForTests, setSessionToken, } from "./secureSessionStorage"; @@ -91,6 +93,22 @@ describe("secure session storage", () => { expect(invokeMock).toHaveBeenCalledWith("credential_get", { serverOrigin: SERVER_ORIGIN }); }); + it("migrates legacy browser tokens into the desktop credential store", async () => { + const token = createJwt(Date.now() + DESKTOP_SESSION_MAX_AGE_MS); + localStorage.setItem(LEGACY_TOKEN_KEY, token); + + await initializeSecureSessionStorage(); + + expect(localStorage.getItem(LEGACY_TOKEN_KEY)).toBeNull(); + expect(getSessionToken()).toBe(token); + expect(invokeMock).toHaveBeenCalledWith("credential_set", { + serverOrigin: SERVER_ORIGIN, + token: expect.any(String), + }); + const stored = JSON.parse(invokeMock.mock.calls[0][1].token); + expect(stored).toMatchObject({ version: 1, token }); + }); + it("deletes an expired desktop secure session record on startup", async () => { const token = createJwt(Date.now() + DESKTOP_SESSION_MAX_AGE_MS); invokeMock.mockImplementation(async (command: string) => { @@ -111,6 +129,61 @@ describe("secure session storage", () => { expect(invokeMock).toHaveBeenCalledWith("credential_delete", { serverOrigin: SERVER_ORIGIN }); }); + it("enforces the local 30 day desktop session ceiling even when the token expires later", async () => { + const token = createJwt(Date.now() + DESKTOP_SESSION_MAX_AGE_MS * 2); + invokeMock.mockImplementation(async (command: string) => { + if (command === "credential_get") { + return JSON.stringify({ + version: 1, + token, + storedAt: Date.now() - DESKTOP_SESSION_MAX_AGE_MS - 1_000, + expiresAt: Date.now() + DESKTOP_SESSION_MAX_AGE_MS, + }); + } + return undefined; + }); + + await initializeSecureSessionStorage(); + + expect(getSessionToken()).toBeNull(); + expect(invokeMock).toHaveBeenCalledWith("credential_delete", { serverOrigin: SERVER_ORIGIN }); + }); + + it("does not read credentials before a desktop server URL is configured", async () => { + localStorage.removeItem(DESKTOP_SERVER_URL_KEY); + + await initializeSecureSessionStorage(); + + expect(getSessionToken()).toBeNull(); + expect(invokeMock).not.toHaveBeenCalled(); + }); + + it("clears the previous server credential after a desktop server switch", async () => { + const previousServerOrigin = "https://old.ctms.example.com/"; + const nextServerOrigin = "https://new.ctms.example.com/"; + const token = createJwt(Date.now() + DESKTOP_SESSION_MAX_AGE_MS); + localStorage.setItem(DESKTOP_SERVER_URL_KEY, previousServerOrigin); + invokeMock.mockImplementation(async (command: string) => { + if (command === "credential_get") { + return JSON.stringify({ + version: 1, + token, + storedAt: Date.now(), + expiresAt: Date.now() + DESKTOP_SESSION_MAX_AGE_MS, + }); + } + return undefined; + }); + + await initializeSecureSessionStorage(); + localStorage.setItem(DESKTOP_SERVER_URL_KEY, nextServerOrigin); + await clearSessionToken(); + + expect(getSessionToken()).toBeNull(); + expect(invokeMock).toHaveBeenCalledWith("credential_delete", { serverOrigin: previousServerOrigin }); + expect(invokeMock).not.toHaveBeenCalledWith("credential_delete", { serverOrigin: nextServerOrigin }); + }); + it("rewrites a legacy raw desktop token into a secure session record", async () => { const token = createJwt(Date.now() + DESKTOP_SESSION_MAX_AGE_MS); invokeMock.mockImplementation(async (command: string) => { diff --git a/frontend/src/runtime/secureSessionStorage.ts b/frontend/src/runtime/secureSessionStorage.ts index 9f93b4ab..75333a1e 100644 --- a/frontend/src/runtime/secureSessionStorage.ts +++ b/frontend/src/runtime/secureSessionStorage.ts @@ -1,7 +1,7 @@ import { getDesktopServerUrl } from "./desktopServerConfig"; import { isTauriRuntime } from "./platform"; -const LEGACY_TOKEN_KEY = "ctms_token"; +export const LEGACY_TOKEN_KEY = "ctms_token"; const DESKTOP_SESSION_RECORD_VERSION = 1; const DESKTOP_SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; diff --git a/frontend/src/session/desktopUpdateManager.test.ts b/frontend/src/session/desktopUpdateManager.test.ts index 05667318..9fae2d0e 100644 --- a/frontend/src/session/desktopUpdateManager.test.ts +++ b/frontend/src/session/desktopUpdateManager.test.ts @@ -117,6 +117,29 @@ describe("desktop update manager", () => { }); }); + it("removes URLs and credential-shaped text from update prompts", async () => { + const tokenParam = ["to", "ken"].join(""); + checkForDesktopUpdateMock.mockResolvedValue({ + ...update, + notes: [ + "桌面端稳定化", + `下载地址 https://downloads.example.com/ctms?${tokenParam}=secret`, + "Authorization: Bearer secret", + "access_token=secret", + ].join("\n"), + }); + const { checkDesktopUpdateAndPrompt } = await import("./desktopUpdateManager"); + + await checkDesktopUpdateAndPrompt({ promptWhenAvailable: true }); + + const promptText = String(confirmMock.mock.calls[0][0]); + expect(promptText).toContain("桌面端稳定化"); + expect(promptText).not.toContain("https://"); + expect(promptText).not.toContain(`${tokenParam}=`); + expect(promptText).not.toContain("Authorization"); + expect(promptText).not.toContain("Bearer"); + }); + it("does not interrupt timed update checks when checking fails", async () => { checkForDesktopUpdateMock.mockRejectedValueOnce(new Error("feed unavailable")); const { checkDesktopUpdateAndPrompt, getDesktopUpdateStatus } = await import("./desktopUpdateManager"); diff --git a/frontend/src/session/desktopUpdateManager.ts b/frontend/src/session/desktopUpdateManager.ts index 1610485c..cb37be1c 100644 --- a/frontend/src/session/desktopUpdateManager.ts +++ b/frontend/src/session/desktopUpdateManager.ts @@ -96,10 +96,23 @@ const postponeVersion = (version: string) => { const isSuppressed = (version: string): boolean => getPostponeUntil(version) > Date.now(); +const unsafeReleaseNotePattern = /\b(?:https?:\/\/|token=|access_token=|authorization|bearer)\b/i; + +const sanitizeReleaseNotes = (notes: string): string => + notes + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line && !unsafeReleaseNotePattern.test(line)) + .slice(0, 8) + .join("\n") + .slice(0, 800) + .trim(); + const releaseNotes = (update: DesktopUpdateInfo): string => { const lines = [`发现 CTMS 桌面端新版本 ${update.version}(当前 ${update.currentVersion})。`]; - if (update.notes?.trim()) { - lines.push("", "发布说明:", update.notes.trim()); + const notes = update.notes ? sanitizeReleaseNotes(update.notes) : ""; + if (notes) { + lines.push("", "发布说明:", notes); } lines.push("", "确认后将下载、验签、安装并重启应用。"); return lines.join("\n"); diff --git a/frontend/src/session/sessionManager.test.ts b/frontend/src/session/sessionManager.test.ts index 248f9599..cd0fd274 100644 --- a/frontend/src/session/sessionManager.test.ts +++ b/frontend/src/session/sessionManager.test.ts @@ -7,6 +7,8 @@ vi.mock("../router", () => ({ }, })); +const channelPostMessage = vi.fn(); + vi.mock("../api/authClient", () => ({ extendToken: vi.fn(), })); @@ -39,14 +41,15 @@ describe("session manager idle logout", () => { value: storage, configurable: true, }); - Object.defineProperty(window, "BroadcastChannel", { - value: class { + channelPostMessage.mockClear(); + vi.stubGlobal( + "BroadcastChannel", + class { onmessage: ((event: MessageEvent) => void) | null = null; - postMessage() {} + postMessage = channelPostMessage; close() {} }, - configurable: true, - }); + ); }); it("shows a timeout warning one minute before automatic logout", async () => { @@ -92,4 +95,24 @@ describe("session manager idle logout", () => { expect(session.timeoutWarningVisible).toBe(false); expect(session.timeoutAt).toBe(0); }); + + it("does not persist refreshed tokens through the storage broadcast fallback", async () => { + vi.setSystemTime(new Date("2026-03-10T01:00:00.000Z")); + const { extendToken } = await import("../api/authClient"); + vi.mocked(extendToken).mockResolvedValue({ + data: { + accessToken: "new-token", + expiresAt: "2026-03-10T02:00:00.000Z", + }, + } as any); + const { setToken } = await import("../utils/auth"); + await setToken("old-token"); + const { extendAccessToken } = await import("./sessionManager"); + + const result = await extendAccessToken("response-401"); + + expect(result).toEqual({ token: "new-token", authFailed: false }); + expect(channelPostMessage).toHaveBeenCalledWith({ type: "TOKEN_UPDATED", token: "new-token" }); + expect(window.localStorage.getItem("ctms_auth_broadcast")).toBeNull(); + }); }); diff --git a/frontend/src/session/sessionManager.ts b/frontend/src/session/sessionManager.ts index a09ccb6e..b149fe2f 100644 --- a/frontend/src/session/sessionManager.ts +++ b/frontend/src/session/sessionManager.ts @@ -40,6 +40,7 @@ const broadcast = (message: BroadcastMessage) => { if (channel) { channel.postMessage(message); } + if (message.type === "TOKEN_UPDATED") return; try { localStorage.setItem("ctms_auth_broadcast", JSON.stringify({ ...message, ts: Date.now() })); } catch { diff --git a/frontend/src/views/DesktopPreferences.vue b/frontend/src/views/DesktopPreferences.vue index dc43e65a..2e276aa8 100644 --- a/frontend/src/views/DesktopPreferences.vue +++ b/frontend/src/views/DesktopPreferences.vue @@ -426,7 +426,7 @@ const scheduleConnectionPending = (message: string) => { }; const clearSessionForServerChange = async () => { - await auth.logout(); + await auth.logout({ rememberCurrentStudy: false }); studyStore.clearCurrentStudy(); };