完善桌面端回归与安全边界复审

This commit is contained in:
Cheng Zhou
2026-07-02 10:43:30 +08:00
parent 0d03e1656a
commit a17fa618cd
14 changed files with 275 additions and 14 deletions
+56 -2
View File
@@ -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();
+3 -1
View File
@@ -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": [
+13
View File
@@ -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"));
}
}
@@ -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("通知诊断");
+15
View File
@@ -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("");
@@ -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) => {
+1 -1
View File
@@ -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;
@@ -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");
+15 -2
View File
@@ -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");
+28 -5
View File
@@ -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();
});
});
+1
View File
@@ -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 {
+1 -1
View File
@@ -426,7 +426,7 @@ const scheduleConnectionPending = (message: string) => {
};
const clearSessionForServerChange = async () => {
await auth.logout();
await auth.logout({ rememberCurrentStudy: false });
studyStore.clearCurrentStudy();
};