diff --git a/server/app.ts b/server/app.ts index 41fee60..b74fb3d 100644 --- a/server/app.ts +++ b/server/app.ts @@ -54,9 +54,11 @@ import { validateNewPassword, verifyPassword, } from "./security.js"; +import { createRateLimiter } from "./rate-limit.js"; import { isNewerVersion } from "./update.js"; import { ACTIVE_UPDATE_STATUSES, + ACTIVE_UPDATE_CONFLICT_SQL, checkForUpdate, currentReleaseVersion, publicCheckFromCache, @@ -100,6 +102,11 @@ const unsafeMethods = new Set(["POST", "PUT", "PATCH", "DELETE"]); const sessionCookie = "tally_session"; const csrfCookie = "tally_csrf"; +/** API paths are the only requests the global rate limiter and cache rules own. */ +function isApiPath(url: string): boolean { + return url.split("?", 1)[0]!.startsWith("/api/"); +} + type UpdateRateState = { checkedAt: number; downloadedAt: number; appliedAt: number }; const updateRateStates = new WeakMap>(); @@ -644,7 +651,7 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) { // retained by a browser, reverse proxy or shared cache. Keep this global so // future authenticated routes inherit the same privacy boundary. app.addHook("onSend", async (request, reply, payload) => { - if (request.url.split("?", 1)[0]!.startsWith("/api/")) { + if (isApiPath(request.url)) { reply.header("Cache-Control", "no-store"); reply.header("Pragma", "no-cache"); reply.header("Vary", "Cookie"); @@ -1028,7 +1035,10 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) { enforceUpdateCooldown(database.sqlite, config, request.auth!.admin.id, "apply", reply); const now = Date.now(); const active = database.sqlite.transaction(() => { - const conflictRow = database.sqlite.prepare(`SELECT id FROM update_jobs WHERE status IN (${ACTIVE_UPDATE_STATUSES.map(() => "?").join(",")}) AND id<>? LIMIT 1`).get(...ACTIVE_UPDATE_STATUSES, stagedJobId) as { id: string } | undefined; + // A reusable `staged/download` artifact must never block applying a + // *different* staged job: otherwise a leftover row keeps the queue + // permanently busy and the operator can never apply an update. + const conflictRow = database.sqlite.prepare(`SELECT id FROM update_jobs WHERE ${ACTIVE_UPDATE_CONFLICT_SQL} AND id<>? LIMIT 1`).get(...ACTIVE_UPDATE_STATUSES, stagedJobId) as { id: string } | undefined; if (conflictRow) throw new AppError(409, "UPDATE_IN_PROGRESS", "已有更新任务正在进行,请等待完成"); const changed = database.sqlite.prepare("UPDATE update_jobs SET operation='apply', error_message=NULL, requested_at=?, request_id=?, updated_at=? WHERE id=? AND status='staged' AND operation='download'").run(now, request.id, now, stagedJobId); if (changed.changes !== 1) throw new AppError(409, "UPDATE_IN_PROGRESS", "更新任务正在处理中,请稍候"); @@ -1053,9 +1063,11 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) { } // Preserve the actionable in-progress response for duplicate clicks before // applying the per-admin cooldown. + // Same rule as the download path: a reusable staged download is an + // artifact, not a running task, and must not block apply. const activeBeforeCheck = database.sqlite.prepare(` SELECT id FROM update_jobs - WHERE status IN (${ACTIVE_UPDATE_STATUSES.map(() => "?").join(",")}) + WHERE ${ACTIVE_UPDATE_CONFLICT_SQL} ORDER BY created_at DESC LIMIT 1 `).get(...ACTIVE_UPDATE_STATUSES) as { id: string } | undefined; if (activeBeforeCheck) throw new AppError(409, "UPDATE_IN_PROGRESS", "已有更新任务正在进行,请等待完成"); @@ -1079,7 +1091,7 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) { const active = database.sqlite.transaction(() => { const existing = database.sqlite.prepare(` SELECT id, status FROM update_jobs - WHERE status IN (${ACTIVE_UPDATE_STATUSES.map(() => "?").join(",")}) + WHERE ${ACTIVE_UPDATE_CONFLICT_SQL} ORDER BY created_at DESC LIMIT 1 `).get(...ACTIVE_UPDATE_STATUSES) as { id: string; status: UpdateJobStatus } | undefined; if (existing) throw new AppError(409, "UPDATE_IN_PROGRESS", "已有更新任务正在进行,请等待完成"); @@ -1169,7 +1181,9 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) { const input = updateDownloadSchema.parse(request.body); reconcileOrphanedUpdateJobs(database.sqlite, config); if (config.updateStrategy !== "systemd") throw new AppError(503, "UPDATE_NOT_AVAILABLE", "当前安装方式未启用一键更新,请使用命令行更新"); - const active = database.sqlite.prepare(`SELECT id FROM update_jobs WHERE status IN (${ACTIVE_UPDATE_STATUSES.map(() => "?").join(",")}) LIMIT 1`).get(...ACTIVE_UPDATE_STATUSES) as { id: string } | undefined; + // A finished `staged/download` row is a reusable artifact, not a running + // task, so it does not block a new download. Apply-phase rows still do. + const active = database.sqlite.prepare(`SELECT id FROM update_jobs WHERE ${ACTIVE_UPDATE_CONFLICT_SQL} LIMIT 1`).get(...ACTIVE_UPDATE_STATUSES) as { id: string } | undefined; if (active) throw new AppError(409, "UPDATE_IN_PROGRESS", "已有更新任务正在进行,请等待完成"); enforceUpdateCooldown(database.sqlite, config, request.auth!.admin.id, "download", reply); const checked = await checkForUpdate(database.sqlite, config); @@ -1181,7 +1195,7 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) { const now = Date.now(); const id = randomUUID(); database.sqlite.transaction(() => { - const conflict = database.sqlite.prepare(`SELECT id FROM update_jobs WHERE status IN (${ACTIVE_UPDATE_STATUSES.map(() => "?").join(",")}) LIMIT 1`).get(...ACTIVE_UPDATE_STATUSES) as { id: string } | undefined; + const conflict = database.sqlite.prepare(`SELECT id FROM update_jobs WHERE ${ACTIVE_UPDATE_CONFLICT_SQL} LIMIT 1`).get(...ACTIVE_UPDATE_STATUSES) as { id: string } | undefined; if (conflict) throw new AppError(409, "UPDATE_IN_PROGRESS", "已有更新任务正在进行,请等待完成"); database.sqlite.prepare(`INSERT INTO update_jobs(id, admin_id, session_hash, request_id, requested_at, operation, status, version, platform, release_url, asset_name, asset_url, expected_sha256, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'download', 'queued', ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, request.auth!.admin.id, request.auth!.tokenHash, request.id, now, version, checked.platform.target, cached.metadataUrl, cachedAsset.name, cachedAsset.url, cachedAsset.sha256, now, now); writeAudit(database.sqlite, { requestId: request.id, actorAdminId: request.auth!.admin.id, actorUsername: request.auth!.admin.username, action: "update.download_requested", targetType: "update", targetId: id, after: { version } }); @@ -1201,7 +1215,10 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) { app.post("/api/update/cancel", { preHandler: guard(database, config) }, async (request, reply) => { reconcileOrphanedUpdateJobs(database.sqlite, config); const body = (request.body && typeof request.body === "object" ? request.body : {}) as { jobId?: string }; - const result = cancelUpdateJob(database.sqlite, config, request.auth!.admin.id, request.id, body.jobId); + // `cancelUpdateJob` awaits its staging-workspace cleanup, so the caller must + // await it too; without the await `result` is a pending Promise and this + // branch would always report failure even after a successful cancel. + const result = await cancelUpdateJob(database.sqlite, config, request.auth!.admin.id, request.id, body.jobId); if (!result.cancelled) { throw new AppError(409, "CANNOT_CANCEL", result.message || "无法取消当前更新任务"); } @@ -1870,6 +1887,24 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) { return reply.send(await safeReadStream(config.exportsDir, job.filePath)); }); + // Global anti-flood backstop for the API surface. It is registered after all + // /api/* routes so it covers every one of them, but the path check keeps + // static assets, `/health` and the SPA fallback out of the limiter. The + // per-feature limits (login lockout, dangerous-operation re-auth, update + // cooldowns) stay authoritative; this only bounds raw request volume. + const apiRateLimiter = createRateLimiter({ limit: config.apiRateLimitPerMinute, windowMs: 60 * 1000 }); + app.addHook("preHandler", async (request, reply) => { + if (!isApiPath(request.url)) return; + // `request.ip` already honours the validated trustProxy configuration, so + // the counted address is the one the deployment declared. The limiter must + // never parse X-Forwarded-For itself, otherwise a client could spoof its + // way around the limit. + const decision = apiRateLimiter.check(request.ip); + if (decision.allowed) return; + reply.header("Retry-After", decision.retryAfterSeconds); + throw new AppError(429, "RATE_LIMITED", "请求过于频繁,请稍后再试"); + }); + const hasWeb = existsSync(config.webDir); if (hasWeb) { // Serve the Vite asset graph as well as the SPA entry. API routes are @@ -1879,7 +1914,7 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) { // Keep API errors structured even when the production frontend has not been // built yet (for example in a clean CI checkout or an API-only process). app.setNotFoundHandler((request, reply) => { - if (request.url.split("?", 1)[0]!.startsWith("/api/")) { + if (isApiPath(request.url)) { return reply.code(404).send(errorPayload(request, new AppError(404, "NOT_FOUND", "接口不存在"))); } if (hasWeb) return reply.sendFile("index.html"); diff --git a/server/cli/update.ts b/server/cli/update.ts index 16fa5d6..7213294 100644 --- a/server/cli/update.ts +++ b/server/cli/update.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import { lstat, mkdir, mkdtemp, readFile, realpath, rm } from "node:fs/promises"; +import { copyFile, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rm } from "node:fs/promises"; import path from "node:path"; import { pathToFileURL } from "node:url"; import type Database from "better-sqlite3"; @@ -22,6 +22,7 @@ import { selectReleaseAsset, sanitizeAssetName, validateHttpsUrl, + verifySha256, type ReleaseAsset, type ReleaseMetadata, type UrlPolicy, @@ -244,35 +245,243 @@ async function resolveRelease(options: UpdateRunOptions, platform: ReturnType { +/** + * Ownership expectation for a directory consumed by the privileged updater. + * + * `-1` disables the uid comparison while keeping the symlink and mode checks. + * Production always passes a concrete uid (0 for the root-owned + * `/.update-work`), so the check never depends on the effective + * uid of the current process and remains runnable from a non-root test. + */ +export type DirectoryOwnerUid = number; + +/** The staging area owned by the unprivileged web process and the private + * root-owned workspace are deliberately separate trust domains. */ +export class StagedWorkspaceError extends Error { + readonly reason: string; + constructor(message: string, reason: string) { + super(message); + this.name = "StagedWorkspaceError"; + this.reason = reason; + } +} + +/** Owner uid of an existing path, or -1 when it cannot be inspected. */ +export async function directoryOwnerUid(targetPath: string): Promise { + const info = await lstat(path.resolve(targetPath)).catch(() => null); + return info?.uid ?? -1; +} + +/** + * Resolve a privileged workspace root to its canonical path. + * + * The root itself may be reached through a symlinked ancestor (for example + * `/tmp` on macOS), so only the final component is required to be a real, + * non-symlink directory with private permissions and the expected owner. + */ +async function canonicalizePrivilegedRoot(directory: string, expectedUid: DirectoryOwnerUid, message: string): Promise { const resolved = path.resolve(directory); await mkdir(resolved, { recursive: true, mode: 0o700 }); const info = await lstat(resolved).catch(() => null); - const uid = typeof process.getuid === "function" ? process.getuid() : -1; - if (!info?.isDirectory() || info.isSymbolicLink() || (info.mode & 0o077) !== 0 || info.uid !== 0 || uid !== 0) { - throw new Error("更新工作目录必须是 root 拥有且权限为 0700"); + if (!info?.isDirectory() || info.isSymbolicLink() || (info.mode & 0o077) !== 0 || (expectedUid >= 0 && info.uid !== expectedUid)) { + throw new Error(message); } - return resolved; + const real = await realpath(resolved).catch(() => { throw new Error(message); }); + const realInfo = await lstat(real).catch(() => null); + if (!realInfo?.isDirectory() || realInfo.isSymbolicLink() || (realInfo.mode & 0o077) !== 0 || (expectedUid >= 0 && realInfo.uid !== expectedUid)) { + throw new Error(message); + } + return real; } -/** Validate a queued staged directory before a root process consumes it. */ -async function validateStagedWorkspacePath(candidate: string, workspaceRoot: string): Promise { - const rootResolved = path.resolve(workspaceRoot); - const rootInfo = await lstat(rootResolved).catch(() => null); - const uid = typeof process.getuid === "function" ? process.getuid() : -1; - if (!rootInfo?.isDirectory() || rootInfo.isSymbolicLink() || (rootInfo.mode & 0o077) !== 0 || rootInfo.uid !== 0 || uid !== 0) { - throw new Error("更新工作目录权限无效"); - } - const root = await realpath(rootResolved).catch(() => { throw new Error("更新工作目录无效"); }); +/** Assert that `candidate` is a real, private, expected-owner directory below `root`. */ +async function assertStagedDirectory(candidate: string, root: string, expectedUid: DirectoryOwnerUid): Promise { const resolved = path.resolve(candidate); - if (resolved === rootResolved || !resolved.startsWith(`${rootResolved}${path.sep}`)) throw new Error("更新暂存路径无效"); + if (resolved === root || !resolved.startsWith(`${root}${path.sep}`)) throw new StagedWorkspaceError("更新暂存路径无效", "staged_workspace_invalid"); const info = await lstat(resolved).catch(() => null); - if (!info?.isDirectory() || info.isSymbolicLink() || (info.mode & 0o077) !== 0 || info.uid !== 0) throw new Error("更新暂存目录权限无效"); - const real = await realpath(resolved).catch(() => { throw new Error("更新暂存目录无效"); }); - if (real !== resolved || !real.startsWith(`${root}${path.sep}`)) throw new Error("更新暂存路径无效"); + if (!info?.isDirectory() || info.isSymbolicLink() || (info.mode & 0o077) !== 0) throw new StagedWorkspaceError("更新暂存目录权限无效", "staged_workspace_insecure"); + if (expectedUid >= 0 && info.uid !== expectedUid) throw new StagedWorkspaceError("更新暂存目录属主无效", "staged_workspace_insecure"); + const real = await realpath(resolved).catch(() => { throw new StagedWorkspaceError("更新暂存目录无效", "staged_workspace_invalid"); }); + if (real !== resolved || !real.startsWith(`${root}${path.sep}`)) throw new StagedWorkspaceError("更新暂存路径无效", "staged_workspace_invalid"); return real; } +async function ensurePrivilegedWorkspace(directory: string, expectedUid: DirectoryOwnerUid): Promise { + return canonicalizePrivilegedRoot(directory, expectedUid, "更新工作目录必须是 root 拥有且权限为 0700"); +} + +/** + * Rebuild the staged workspace location for `jobId` instead of trusting the + * `download_path` column: the runner clears that column whenever it releases + * a workspace (`clearTransientJobPath`), and a nulled column cannot be used to + * find a payload that is still on disk waiting for the apply step. + * + * Candidate order: + * 1. `/update-` (web download workspace) + * 2. `/update--*` (mkdtemp variant) + * 3. the recorded `download_path`, but only while it stays inside the root + */ +export async function locateStagedWorkspace(options: { + jobId: string; + downloadPath?: string | null | undefined; + stagingRoot: string; + expectedUid: DirectoryOwnerUid; +}): Promise { + const root = await canonicalizePrivilegedRoot(options.stagingRoot, options.expectedUid, "更新暂存根目录权限无效"); + const prefix = `update-${options.jobId}`; + const candidates = [path.join(root, prefix)]; + const entries = await readdir(root, { withFileTypes: true }).catch(() => []); + for (const entry of entries.filter((candidate) => candidate.name.startsWith(`${prefix}-`)).sort((a, b) => a.name.localeCompare(b.name))) { + candidates.push(path.join(root, entry.name)); + } + const recorded = options.downloadPath?.trim(); + if (recorded && path.isAbsolute(recorded)) { + const resolvedRecorded = path.resolve(recorded); + if (resolvedRecorded.startsWith(`${root}${path.sep}`)) candidates.push(resolvedRecorded); + // A recorded path outside the staging root is never consumed. Reject it + // loudly when it exists so the operator sees the real cause instead of a + // generic "re-download" message. + else if (await lstat(resolvedRecorded).catch(() => null)) throw new StagedWorkspaceError("更新暂存路径无效", "staged_workspace_invalid"); + } + const seen = new Set(); + for (const candidate of candidates) { + const resolved = path.resolve(candidate); + if (seen.has(resolved)) continue; + seen.add(resolved); + // An existing candidate must satisfy every constraint: skipping it would + // hand the root process whatever else happens to sit in the staging area. + if (!(await lstat(resolved).catch(() => null))) continue; + return assertStagedDirectory(resolved, root, options.expectedUid); + } + throw new StagedWorkspaceError("暂存目录已不存在,请重新下载", "staged_workspace_missing"); +} + +/** Copy a verified payload tree without following or preserving symlinks. */ +async function copyReleaseTree(source: string, target: string): Promise { + await mkdir(target, { recursive: false, mode: 0o700 }); + const entries = await readdir(source, { withFileTypes: true }); + for (const entry of entries) { + const from = path.join(source, entry.name); + const to = path.join(target, entry.name); + if (entry.isSymbolicLink()) throw new Error("更新暂存内容包含符号链接"); + if (entry.isDirectory()) await copyReleaseTree(from, to); + else if (entry.isFile()) await copyFile(from, to); + else throw new Error("更新暂存内容包含不受支持的文件类型"); + } +} + +const STAGED_ARCHIVE_PATTERN = /\.(?:tar\.gz|tgz|tar|zip)$/i; + +async function assertStagedArchiveIntegrity(source: string, expectedSha256: string | null | undefined): Promise { + const expected = expectedSha256?.trim().toLowerCase(); + if (!expected) return; + if (!/^[a-f0-9]{64}$/.test(expected)) throw new Error("更新暂存校验值无效"); + const entries = await readdir(source, { withFileTypes: true }).catch(() => []); + const archive = entries + .filter((entry) => entry.isFile() && !entry.isSymbolicLink() && STAGED_ARCHIVE_PATTERN.test(entry.name)) + .sort((a, b) => a.name.localeCompare(b.name))[0]; + if (!archive) throw new Error("更新暂存归档缺失,无法校验完整性"); + const archivePath = path.join(source, archive.name); + const info = await lstat(archivePath).catch(() => null); + if (!info?.isFile() || info.isSymbolicLink()) throw new Error("更新暂存归档无效"); + if (!(await verifySha256(archivePath, expected))) throw new Error("更新文件 SHA-256 校验失败"); +} + +/** + * Snapshot the web-staged payload into a root-owned workspace before the apply + * flow touches it. The copy is what closes the TOCTOU window: the unprivileged + * web user keeps write access to its own staging directory, so the privileged + * process must never execute content that lives there. + * + * A copy (not a rename) is required because the data directory and the install + * prefix are frequently separate mounts, where `rename` fails with EXDEV. + */ +export async function preparePrivateApplyWorkspace(options: { + jobId: string; + source: string; + privateRoot: string; + expectedUid: DirectoryOwnerUid; + expectedSha256?: string | null | undefined; +}): Promise { + const root = await canonicalizePrivilegedRoot(options.privateRoot, options.expectedUid, "更新工作目录必须是 root 拥有且权限为 0700"); + const source = path.resolve(options.source); + const sourcePayload = path.join(source, "payload"); + const sourcePayloadInfo = await lstat(sourcePayload).catch(() => null); + if (!sourcePayloadInfo?.isDirectory() || sourcePayloadInfo.isSymbolicLink()) throw new Error("更新暂存内容无效"); + // Second integrity check right before the copy, so a payload swapped after + // the download verification is rejected instead of promoted to a release. + await assertStagedArchiveIntegrity(source, options.expectedSha256); + const target = path.join(root, `apply-${options.jobId}`); + const existing = await lstat(target).catch(() => null); + if (existing) await rm(target, { recursive: true, force: true }).catch(() => undefined); + try { + // Create the container explicitly: `copyReleaseTree` intentionally uses a + // non-recursive mkdir so a pre-existing/symlinked target can never be + // silently reused, and the parent must therefore already exist. + await mkdir(target, { recursive: false, mode: 0o700 }); + await copyReleaseTree(sourcePayload, path.join(target, "payload")); + await normalizeReleasePermissions(path.join(target, "payload")); + const copied = await lstat(path.join(target, "payload")).catch(() => null); + if (!copied?.isDirectory() || copied.isSymbolicLink()) throw new Error("更新暂存内容复制失败"); + return target; + } catch (error) { + await rm(target, { recursive: true, force: true }).catch(() => undefined); + throw error; + } +} + +/** Reason code attached to failures that must reach the UI verbatim. */ +function failureReason(error: unknown): string { + const reason = (error as { reason?: unknown } | null)?.reason; + return typeof reason === "string" && /^[a-z0-9_]{1,64}$/.test(reason) ? reason : "apply_precheck_failed"; +} + +/** + * Persist a real failure reason from the privileged apply path. + * + * `writeJob`/`updateJob` cannot be used here: their final-state guard + * (`WHERE update_jobs.status NOT IN (...)`) protects terminal rows, and it also + * makes the runner's own progress writes a no-op once a row is terminal. This + * helper issues an independent, guarded UPDATE so the true cause is visible in + * the UI instead of the runner's generic health-check message. + */ +export function failUpdateJobWithReason( + sqlite: Database.Database | undefined, + jobId: string, + message: string, + options: { reason?: string } = {}, +): boolean { + if (!sqlite) return false; + const safe = safeErrorMessage(message.length ? new Error(message) : new Error("更新失败")); + try { + return sqlite.transaction(() => { + const row = sqlite.prepare("SELECT status, version, request_id AS requestId, admin_id AS adminId FROM update_jobs WHERE id=?").get(jobId) as { + status: UpdateJobStatus; version: string; requestId: string | null; adminId: string | null; + } | undefined; + if (!row || row.status === "completed" || row.status === "cancelled" || row.status === "failed") return false; + const now = Date.now(); + const updated = sqlite.prepare("UPDATE update_jobs SET status='failed', error_message=?, completed_at=COALESCE(completed_at, ?), updated_at=? WHERE id=? AND status=?").run(safe, now, now, jobId, row.status); + if (updated.changes !== 1) return false; + writeAudit(sqlite, { + requestId: row.requestId || randomUUID(), + actorAdminId: row.adminId, + action: "update.failed", + targetType: "update", + targetId: jobId, + outcome: "failure", + before: { status: row.status, version: row.version }, + after: { status: "failed", version: row.version, reason: options.reason ?? "apply_precheck_failed", error: safe }, + }); + return true; + })(); + } catch { + // The database may not be open (or the row may not exist) when a request is + // rejected during preflight. Losing the diagnostic write must never turn a + // clean rejection into a crash. + return false; + } +} + export async function runUpdate(options: UpdateRunOptions): Promise { const platform = options.platform ?? detectPlatform(); const jobId = options.jobId ?? randomUUID(); @@ -437,13 +646,17 @@ export async function applyStagedUpdate(options: { maxBytes?: number; dataBackupMaxBytes?: number; workspaceRoot?: string; + /** Expected owner of `workspaceRoot`. Defaults to uid 0 (the installer + * provisions `/.update-work` as root-owned 0700). Tests inject + * the current user so the check never depends on `process.getuid()`. */ + workspaceOwnerUid?: DirectoryOwnerUid; }): Promise { const row = options.sqlite.prepare(`SELECT status, operation, version, platform, release_url AS releaseUrl, asset_name AS assetName, asset_url AS assetUrl, expected_sha256 AS expectedSha256, actual_sha256 AS actualSha256, size_bytes AS sizeBytes FROM update_jobs WHERE id=?`).get(options.jobId) as Record | undefined; if (!row || row.status !== "staged" || row.operation !== "apply") throw new Error("更新任务未处于待应用状态"); if (typeof row.version === "string" && row.version !== options.version) throw new Error("更新版本不一致"); const stagedPath = options.workspaceRoot - ? await validateStagedWorkspacePath(options.stagedPath, options.workspaceRoot) - : options.stagedPath; + ? await canonicalizePrivilegedRoot(options.workspaceRoot, options.workspaceOwnerUid ?? 0, "更新工作目录权限无效") + : path.resolve(options.stagedPath); const payload = path.join(stagedPath, "payload"); const payloadInfo = await lstat(payload).catch(() => null); if (!payloadInfo?.isDirectory() || payloadInfo.isSymbolicLink()) throw new Error("更新暂存内容无效"); @@ -481,7 +694,21 @@ function arg(name: string): string | undefined { return index >= 0 ? process.argv[index + 1] : undefined; } -export async function main(config: AppConfig = loadConfig()): Promise { +/** + * Ownership expectations the privileged entry point uses for the two trust + * domains it consumes. They are injectable so the apply flow can be exercised + * end-to-end from a non-root test process: production always uses the defaults + * (root-owned `/.update-work` and the `dataDir` owner for the + * unprivileged staging area) and never consults `process.getuid()`. + */ +export type UpdateMainOverrides = { + /** Expected owner of the unprivileged staging root (`config.stagingDir`). */ + stagingOwnerUid?: DirectoryOwnerUid; + /** Expected owner of the root-only private workspace (`config.updateWorkspaceDir`). */ + workspaceOwnerUid?: DirectoryOwnerUid; +}; + +export async function main(config: AppConfig = loadConfig(), overrides: UpdateMainOverrides = {}): Promise { const finalizeJobId = arg("--finalize-job"); if (finalizeJobId) { const finalStatus = arg("--finalize-status"); @@ -518,7 +745,9 @@ export async function main(config: AppConfig = loadConfig()): Promise { const dataBackupArchive = arg("--data-backup") ?? (request ? path.join(path.dirname(config.dataDir), "tallynote-backups", `data-${request.jobId}.tar.gz`) : undefined); const allowedHosts = process.argv.flatMap((value, index) => value === "--allow-host" && process.argv[index + 1] ? [process.argv[index + 1]!] : []); prepareDataDirectories(config); - if (request) await ensurePrivilegedWorkspace(stagingDir); + const workspaceOwnerUid = overrides.workspaceOwnerUid ?? 0; + const stagingOwnerUid = overrides.stagingOwnerUid ?? await directoryOwnerUid(config.dataDir); + if (request) await ensurePrivilegedWorkspace(stagingDir, workspaceOwnerUid); else await mkdir(stagingDir, { recursive: true, mode: 0o700 }); // The download phase intentionally runs beside the live app so users keep // access while the archive is fetched and staged. SQLite WAL plus the @@ -528,28 +757,67 @@ export async function main(config: AppConfig = loadConfig()): Promise { const database = openDatabase(config); try { if (request?.operation === "apply") { - const staged = database.sqlite.prepare("SELECT status, operation, download_path AS downloadPath, version FROM update_jobs WHERE id=?").get(request.jobId) as { status: UpdateJobStatus; operation: "download" | "apply"; downloadPath: string | null; version: string } | undefined; + const staged = database.sqlite.prepare("SELECT status, operation, download_path AS downloadPath, version, expected_sha256 AS expectedSha256 FROM update_jobs WHERE id=?").get(request.jobId) as { status: UpdateJobStatus; operation: "download" | "apply"; downloadPath: string | null; version: string; expectedSha256: string | null } | undefined; if (staged?.status === "staged" && staged.operation === "apply") { - if (!staged.downloadPath || staged.version !== request.version) throw new Error("更新暂存任务无效"); - const root = path.resolve(config.updateWorkspaceDir); - const candidate = await validateStagedWorkspacePath(staged.downloadPath, root); - await applyStagedUpdate({ - sqlite: database.sqlite, - jobId: request.jobId, - version: request.version, - stagedPath: candidate, - currentDir, - currentLink: request.currentLink, - releasesDir: request.releasesDir, - workspaceRoot: root, - ...(backupArchive ? { backupArchivePath: backupArchive } : {}), - ...(dataBackupArchive ? { dataBackupArchivePath: dataBackupArchive } : {}), - dataBackupSource: config.dataDir, - maxBytes: config.updateMaxBytes, - dataBackupMaxBytes: config.maxTotalBytes, - }); - console.log(`更新已切换:${request.version}`); - return; + // `download_path` is intentionally NOT required here. The runner NULLs + // that column as soon as it releases a workspace, so it can never be the + // source of truth for a payload that still exists on disk. The job id is + // the stable key; the column survives only as a last-resort candidate in + // `locateStagedWorkspace`. + if (staged.version !== request.version) throw new Error("更新暂存任务无效"); + let privateWorkspace: string | undefined; + try { + const source = await locateStagedWorkspace({ + jobId: request.jobId, + downloadPath: staged.downloadPath, + stagingRoot: config.stagingDir, + expectedUid: stagingOwnerUid, + }); + // Snapshot into the root-only workspace before applying. The web user + // keeps write access to the staging tree, so content that is executed + // by the privileged process must never live there (TOCTOU). + privateWorkspace = await preparePrivateApplyWorkspace({ + jobId: request.jobId, + source, + privateRoot: config.updateWorkspaceDir, + expectedUid: workspaceOwnerUid, + expectedSha256: staged.expectedSha256, + }); + await applyStagedUpdate({ + sqlite: database.sqlite, + jobId: request.jobId, + version: request.version, + stagedPath: privateWorkspace, + workspaceRoot: privateWorkspace, + workspaceOwnerUid, + currentDir, + currentLink: request.currentLink, + releasesDir: request.releasesDir, + ...(backupArchive ? { backupArchivePath: backupArchive } : {}), + ...(dataBackupArchive ? { dataBackupArchivePath: dataBackupArchive } : {}), + dataBackupSource: config.dataDir, + maxBytes: config.updateMaxBytes, + dataBackupMaxBytes: config.maxTotalBytes, + }); + // The private copy has been consumed by the release switch and the + // payload is now the live release, so the web-owned source tree is + // redundant. Best-effort cleanup must not fail an applied update. + await rm(source, { recursive: true, force: true }).catch(() => undefined); + console.log(`更新已切换:${request.version}`); + return; + } catch (error) { + // Covers failures raised before `applyStagedUpdate` took ownership of + // the private copy, and the "already committed" case where the failing + // path deliberately skips its own cleanup. + if (privateWorkspace) await rm(privateWorkspace, { recursive: true, force: true }).catch(() => undefined); + // The runner can only report its fixed health-check message. Record the + // real pre-flight cause so the UI and the audit trail show why the + // update was rejected. A terminal row is only reachable through an + // independent guarded UPDATE (`writeJob` refuses to mutate terminal + // rows), which is exactly what this helper issues. + failUpdateJobWithReason(database.sqlite, request.jobId, safeErrorMessage(error), { reason: failureReason(error) }); + throw error; + } } if (staged && !(staged.status === "queued" && staged.operation === "apply")) throw new Error("更新任务状态无效"); // A direct one-click request starts in queued/apply. Older clients do diff --git a/server/config.ts b/server/config.ts index 0addafc..7fc67d4 100644 --- a/server/config.ts +++ b/server/config.ts @@ -162,6 +162,11 @@ export function loadConfig() { exportsDir: path.join(dataDir, "exports"), migrationsDir: path.join(projectRoot, "migrations"), webDir: path.join(projectRoot, "dist", "web"), + // Coarse per-IP request ceiling applied to every /api/* request. It is a + // backstop against request floods, not a replacement for the stricter + // per-feature limits (login lockout, dangerous-operation re-auth, update + // cooldowns), so the default is deliberately generous. + apiRateLimitPerMinute: integerEnv("TALLYNOTE_RATE_LIMIT_PER_MINUTE", 600), maxFileBytes: integerEnv("TALLYNOTE_MAX_FILE_MB", 20) * 1024 * 1024, maxFilesPerRequest: integerEnv("TALLYNOTE_MAX_FILES_PER_REQUEST", 20), maxRecordBytes: integerEnv("TALLYNOTE_MAX_RECORD_MB", 100) * 1024 * 1024, diff --git a/server/rate-limit.ts b/server/rate-limit.ts new file mode 100644 index 0000000..cf8722a --- /dev/null +++ b/server/rate-limit.ts @@ -0,0 +1,80 @@ +/** + * In-memory, per-key request limiter used as a coarse anti-flood backstop for + * the whole HTTP API. + * + * The semantics are a fixed window per key: the first request of a window + * starts the clock, every later request in the same window increments the + * counter, and an expired window is reset on the next request. This mirrors + * the `login_attempts` window logic already used for login lockouts + * (`server/app.ts`), but it never touches the database: a rate limit decision + * must stay cheap enough to run on every request. + * + * Precise controls (per-IP login lockout, dangerous-operation re-auth) remain + * in place on top of this limiter; it only stops a client from issuing an + * abusive number of requests across all endpoints. + */ + +export type RateLimiterOptions = { + /** Maximum number of requests allowed per key inside one window. */ + limit: number; + /** Window length in milliseconds. */ + windowMs: number; + /** Injectable clock so tests can advance time without waiting. */ + now?: () => number; +}; + +export type RateLimitDecision = { + allowed: boolean; + /** Seconds the caller should wait before retrying; 0 when allowed. */ + retryAfterSeconds: number; +}; + +type Bucket = { + count: number; + windowStart: number; +}; + +/** Run a full sweep every N checks instead of on every call. */ +const SWEEP_INTERVAL_CHECKS = 1000; + +export function createRateLimiter(options: RateLimiterOptions) { + const { limit, windowMs } = options; + if (!Number.isInteger(limit) || limit < 1) throw new Error("rate limit 必须是大于等于 1 的整数"); + if (!Number.isInteger(windowMs) || windowMs < 1) throw new Error("rate limit 窗口必须是大于等于 1 的整数毫秒数"); + const now = options.now ?? Date.now; + const buckets = new Map(); + let checksSinceSweep = 0; + + return { + check(key: string): RateLimitDecision { + const current = now(); + let bucket = buckets.get(key); + // A key that is unknown or whose window has already elapsed starts a + // fresh window. This also recycles the single key being hit, so an + // idle client never leaves a stale counter behind. + if (!bucket || current - bucket.windowStart >= windowMs) { + bucket = { count: 0, windowStart: current }; + buckets.set(key, bucket); + } + // Bounds long-running memory growth: keys that stopped sending traffic + // are dropped by an amortized periodic sweep rather than on every call. + if (++checksSinceSweep >= SWEEP_INTERVAL_CHECKS) { + checksSinceSweep = 0; + for (const [candidateKey, candidate] of buckets) { + if (current - candidate.windowStart >= windowMs) buckets.delete(candidateKey); + } + } + if (bucket.count >= limit) { + return { allowed: false, retryAfterSeconds: Math.max(1, Math.ceil((bucket.windowStart + windowMs - current) / 1000)) }; + } + bucket.count += 1; + return { allowed: true, retryAfterSeconds: 0 }; + }, + /** Number of tracked keys; used to observe lazy cleanup. */ + size(): number { + return buckets.size; + }, + }; +} + +export type RateLimiter = ReturnType; diff --git a/server/update-service.ts b/server/update-service.ts index 6794850..00f8053 100644 --- a/server/update-service.ts +++ b/server/update-service.ts @@ -1,4 +1,4 @@ -import { lstatSync, realpathSync, readFileSync, unlinkSync } from "node:fs"; +import { lstatSync, readdirSync, realpathSync, readFileSync, unlinkSync } from "node:fs"; import { chmod, lstat, mkdir, mkdtemp, rename, rm, writeFile } from "node:fs/promises"; import path from "node:path"; import { createPublicKey, randomUUID, verify as verifySignature } from "node:crypto"; @@ -40,6 +40,20 @@ export const ACTIVE_UPDATE_STATUSES: readonly UpdateJobStatus[] = [ // after the service health check. The runner refreshes its recovery marker as // a lease while doing long downloads/backups; only an expired lease permits // the server to reclaim an active row. +/** + * Conflict predicate for "another update is already running". + * + * A row that is `staged` with `operation='download'` is a finished artifact + * waiting for an explicit apply, not a running task: the privileged runner only + * starts working after the apply request is written. It must therefore not + * block a new download. Real in-flight work (queued/downloading/verifying and + * the apply phases) remains protected, which is what keeps the apply path's + * concurrency guard intact. + * + * The SQL fragment expects ACTIVE_UPDATE_STATUSES bound as positional params. + */ +export const ACTIVE_UPDATE_CONFLICT_SQL = `status IN (${ACTIVE_UPDATE_STATUSES.map(() => "?").join(",")}) AND NOT (status='staged' AND operation='download')`; + export const ORPHANED_UPDATE_TIMEOUT_MS = 5 * 60 * 1000; export const QUEUED_UPDATE_TIMEOUT_MS = 25 * 1000; @@ -447,6 +461,61 @@ export function currentReleaseVersion(config: AppConfig): string | null { } } +/** + * Absolute paths that can hold a job's staging workspace. The web download flow + * always creates `update-`; the privileged runner may additionally use a + * `mkdtemp` variant named `update--XXXXXX`. + * + * `update_jobs.download_path` is deliberately NOT used to rebuild these paths: + * it held a bare basename while a download was in flight (rows written by older + * versions still store that basename) and the privileged runner NULLs the column + * after finalizing a row. Rebuilding from it could delete an unrelated staging + * entry that merely shares the basename. + */ +function jobWorkspaceCandidates(stagingDir: string, jobId: string): string[] { + // Job ids are UUIDs; reject anything that could escape the staging root. + if (!jobId || jobId !== path.basename(jobId) || jobId.includes("..")) return []; + const stagingRoot = path.resolve(stagingDir); + const prefix = `update-${jobId}`; + const names = [prefix]; + try { + for (const entry of readdirSync(stagingRoot)) { + if (entry.startsWith(`${prefix}-`)) names.push(entry); + } + } catch { + // A missing or unreadable staging directory still leaves the fixed-name + // candidate, which is what the web download path uses. + } + return names.map((name) => path.join(stagingRoot, name)); +} + +function isDirectoryNotSymlink(target: string): boolean { + try { + const info = lstatSync(target); + return info.isDirectory() && !info.isSymbolicLink(); + } catch { + return false; + } +} + +/** True while at least one staging workspace for the job still exists. */ +export function jobWorkspaceExists(stagingDir: string, jobId: string): boolean { + return jobWorkspaceCandidates(stagingDir, jobId).some(isDirectoryNotSymlink); +} + +/** + * Remove every staging workspace owned by a job. Deletion is awaited so callers + * (and tests) observe a settled filesystem when they return. + */ +async function removeJobWorkspaces(stagingDir: string, jobId: string): Promise { + for (const candidate of jobWorkspaceCandidates(stagingDir, jobId)) { + const info = await lstat(candidate).catch(() => null); + // Only real directories are removed; a symlink is never followed. + if (!info?.isDirectory() || info.isSymbolicLink()) continue; + await rm(candidate, { recursive: true, force: true }).catch(() => undefined); + } +} + /** * Release an update row left behind after its privileged runner lease expired. * This is deliberately conservative: staged downloads remain available for an @@ -558,6 +627,36 @@ export function reconcileOrphanedUpdateJobs(database: Database.Database, config: // A stale request/state marker therefore no longer protects an orphaned // row forever, while a fresh marker remains owned by the runner. if (row.status === "staged") { + // A staged row that lost its payload (the staging janitor removes + // `update-*` entries after 24h, and a manual cleanup has the same effect) + // can never be applied or completed. Report it instead of leaving a + // permanently actionable row that fails at apply time. + if (!matchingFreshRequest && !matchingFreshState && !jobWorkspaceExists(config.stagingDir, row.id)) { + const changed = database.transaction(() => { + const result = database.prepare(` + UPDATE update_jobs + SET status='failed', error_message=?, completed_at=?, updated_at=? + WHERE id=? AND status='staged' AND updated_at=? + `).run("暂存的更新文件已不存在,请重新下载更新包", now, now, row.id, row.updatedAt); + if (result.changes !== 1) return false; + writeAudit(database, { + requestId: row.requestId || randomUUID(), + actorAdminId: row.adminId, + action: "update.reconciled", + targetType: "update", + targetId: row.id, + outcome: "failure", + before: { status: row.status, operation: row.operation, version: row.version }, + after: { status: "failed", version: row.version, reason: "staged_workspace_missing" }, + }); + return true; + })(); + if (changed) { + reconciled += 1; + reconciledIds.add(row.id); + } + continue; + } if (row.operation !== "apply" || matchingFreshRequest || matchingFreshState) continue; const changed = database.transaction(() => { const result = database.prepare(` @@ -633,23 +732,39 @@ export function reconcileOrphanedUpdateJobs(database: Database.Database, config: return reconciled; } -export function cancelUpdateJob( +/** + * Rows an administrator may cancel from the web UI. + * + * `staged` is cancellable only while the row still belongs to the download + * stage. A staged row whose operation is already `apply` has been handed to the + * privileged runner (stop/backup/switch) and must not be interrupted here. + */ +const CANCELLABLE_JOB_SQL = "(status IN ('queued', 'downloading') OR (status='staged' AND operation='download'))"; + +export function isCancellableUpdateJob(status: UpdateJobStatus, operation: string): boolean { + if (status === "queued" || status === "downloading") return true; + return status === "staged" && operation === "download"; +} + +export async function cancelUpdateJob( database: Database.Database, config: AppConfig, adminId: string, requestId: string, jobId?: string, -): { cancelled: boolean; message?: string } { +): Promise<{ cancelled: boolean; message?: string }> { + type CancelRow = { id: string; status: UpdateJobStatus; operation: string; version: string; adminId: string | null }; + const columns = "id, status, operation, version, admin_id AS adminId"; const job = jobId - ? database.prepare("SELECT id, status, operation, version, admin_id AS adminId, download_path AS downloadPath FROM update_jobs WHERE id=? AND admin_id=?").get(jobId, adminId) as { id: string; status: UpdateJobStatus; operation: string; version: string; adminId: string | null; downloadPath: string | null } | undefined - : database.prepare("SELECT id, status, operation, version, admin_id AS adminId, download_path AS downloadPath FROM update_jobs WHERE admin_id=? AND status IN ('queued', 'downloading') ORDER BY created_at DESC LIMIT 1").get(adminId) as { id: string; status: UpdateJobStatus; operation: string; version: string; adminId: string | null; downloadPath: string | null } | undefined; + ? database.prepare(`SELECT ${columns} FROM update_jobs WHERE id=? AND admin_id=?`).get(jobId, adminId) as CancelRow | undefined + : database.prepare(`SELECT ${columns} FROM update_jobs WHERE admin_id=? AND ${CANCELLABLE_JOB_SQL} ORDER BY created_at DESC LIMIT 1`).get(adminId) as CancelRow | undefined; if (!job) return { cancelled: false, message: "当前没有处于等待调度或下载中的更新任务" }; - if (job.status !== "queued" && job.status !== "downloading") return { cancelled: false, message: "任务已进入就绪或切换阶段,无法取消" }; + if (!isCancellableUpdateJob(job.status, job.operation)) return { cancelled: false, message: "任务已进入就绪或切换阶段,无法取消" }; const now = Date.now(); const changed = database.transaction(() => { - const result = database.prepare("UPDATE update_jobs SET status='cancelled', error_message='已手动取消更新', completed_at=?, updated_at=? WHERE id=? AND admin_id=? AND status IN ('queued', 'downloading')").run(now, now, job.id, adminId); + const result = database.prepare(`UPDATE update_jobs SET status='cancelled', error_message='已手动取消更新', completed_at=?, updated_at=? WHERE id=? AND admin_id=? AND ${CANCELLABLE_JOB_SQL}`).run(now, now, job.id, adminId); if (result.changes !== 1) return false; writeAudit(database, { requestId, @@ -668,10 +783,11 @@ export function cancelUpdateJob( // The request marker is shared by the privileged runner. Never remove a // newer/different administrator's request while cancelling this row. if (requestJobId(config.updateRequestPath) === job.id) forceRemoveRequest(config.updateRequestPath); - if (job.downloadPath) { - const target = path.isAbsolute(job.downloadPath) ? job.downloadPath : path.join(config.stagingDir, job.downloadPath); - import("node:fs/promises").then(({ rm }) => rm(target, { recursive: true, force: true })).catch(() => {}); - } + // Locate the workspace by job id. `download_path` is not a reliable source + // (older rows hold a bare archive basename and the runner NULLs the column + // after finalizing), and a basename lookup could delete an unrelated entry. + // The await keeps the caller from racing a still-running download writer. + await removeJobWorkspaces(config.stagingDir, job.id); return { cancelled: true }; } return { cancelled: false, message: "取消失败,任务状态可能已改变" }; @@ -706,9 +822,13 @@ export async function downloadAndStageUpdate( // Claim the job: transition queued -> downloading. If the job was // cancelled or claimed by another caller, abort immediately. + // `download_path` always holds an absolute workspace path, both while the + // download runs and after the job is staged. Callers must not derive paths + // from it (the privileged runner NULLs it once it finalizes the row), but a + // single semantic keeps the column debuggable. const claim = database.prepare( "UPDATE update_jobs SET status='downloading', download_started_at=?, started_at=?, download_path=?, updated_at=? WHERE id=? AND status='queued'", - ).run(Date.now(), Date.now(), path.basename(archivePath), Date.now(), jobId); + ).run(Date.now(), Date.now(), workspace, Date.now(), jobId); if (claim.changes !== 1) return; const progressStartedAt = Date.now(); @@ -764,7 +884,13 @@ export async function downloadAndStageUpdate( const staged = database.prepare( "UPDATE update_jobs SET status='staged', operation='download', actual_sha256=?, size_bytes=?, download_path=?, updated_at=? WHERE id=? AND status IN ('verifying', 'downloading')", ).run(downloaded.sha256, downloaded.size, workspace, Date.now(), jobId); - if (staged.changes !== 1) return; // cancelled + if (staged.changes !== 1) { + // The row was cancelled or claimed elsewhere (status no longer + // verifying/downloading). This process owns the workspace it created, so + // remove it instead of leaking the payload into the staging directory. + await rm(workspace, { recursive: true, force: true }).catch(() => undefined); + return; + } writeAudit(database, { requestId: `download:${jobId}`, diff --git a/systemd/tallynote.env.example b/systemd/tallynote.env.example index 5c51a33..01c55c8 100644 --- a/systemd/tallynote.env.example +++ b/systemd/tallynote.env.example @@ -18,3 +18,18 @@ TALLYNOTE_UPDATE_APPLY_COOLDOWN_SECONDS=15 # Optional: configure a root-managed Ed25519 public key and set # TALLYNOTE_UPDATE_REQUIRE_SIGNATURE=true to require detached signatures. # TALLYNOTE_UPDATE_PUBLIC_KEY_FILE=/etc/tallynote/update-signing-key.pub + +# Reverse proxy trust. Leave this empty (or false) when TallyNote is reached +# directly. When the service runs behind a reverse proxy, set the exact number +# of proxy hops that terminate the client connection (a single nginx or caddy +# layer uses 1). Without it every request appears to come from the proxy +# address, so per-IP login lockouts degrade into a single shared global limit +# and the API rate limiter below counts all clients as one. `true` is rejected +# in production because it would let a client spoof its address. +# TALLYNOTE_TRUST_PROXY=1 + +# Global API rate limit, per client address, in requests per minute. This is a +# coarse anti-flood backstop for /api/* only; the login lockout, dangerous +# operation confirmation and update cooldowns remain stricter and separate. +# Defaults to 600 when unset, which is sufficient for normal browser use. +# TALLYNOTE_RATE_LIMIT_PER_MINUTE=600 diff --git a/tests/e2e/smoke.spec.ts b/tests/e2e/smoke.spec.ts index 26117f4..965d671 100644 --- a/tests/e2e/smoke.spec.ts +++ b/tests/e2e/smoke.spec.ts @@ -1,8 +1,12 @@ import { expect, test } from "@playwright/test"; +// Keep the expected copy in one place so a product-wide copy refresh cannot +// silently desynchronise this suite from web-next/src/pages/auth/LoginPage.tsx. +const LOGIN_HEADING = "登录 TallyNote 工作台"; + test("未登录时显示中文登录入口", async ({ page }) => { await page.goto("/"); - await expect(page.getByRole("heading", { name: "登录到 TallyNote", exact: true })).toBeVisible(); + await expect(page.getByRole("heading", { name: LOGIN_HEADING, exact: true })).toBeVisible(); await expect(page.locator(".tn-login-header")).toHaveCount(0); await expect(page.getByLabel("用户名", { exact: true })).toBeVisible(); await expect(page.getByLabel("密码", { exact: true })).toBeVisible(); @@ -17,7 +21,7 @@ for (const viewport of [ test(`未登录入口适配 ${viewport.width}px`, async ({ page }) => { await page.setViewportSize(viewport); await page.goto("/"); - await expect(page.getByRole("heading", { name: "登录到 TallyNote", exact: true })).toBeVisible(); + await expect(page.getByRole("heading", { name: LOGIN_HEADING, exact: true })).toBeVisible(); await expect(page.getByLabel("用户名", { exact: true })).toBeVisible(); await expect(page.getByLabel("密码", { exact: true })).toBeVisible(); await expect(page.getByRole("button", { name: "登录" })).toBeVisible(); diff --git a/tests/rate-limit.test.ts b/tests/rate-limit.test.ts new file mode 100644 index 0000000..d08bb27 --- /dev/null +++ b/tests/rate-limit.test.ts @@ -0,0 +1,208 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { buildApp } from "../server/app.js"; +import { loadConfig, prepareDataDirectories } from "../server/config.js"; +import { openDatabase } from "../server/db/index.js"; +import { createRateLimiter } from "../server/rate-limit.js"; + +const configKeys = [ + "TALLYNOTE_DATA_DIR", + "TALLYNOTE_PUBLIC_ORIGIN", + "TALLYNOTE_COOKIE_SECURE", + "TALLYNOTE_ALLOW_INSECURE_HTTP", + "TALLYNOTE_RATE_LIMIT_PER_MINUTE", + "TALLYNOTE_TRUST_PROXY", + "NODE_ENV", + "TALLYNOTE_ENV", +]; + +afterEach(() => { for (const key of configKeys) delete process.env[key]; }); + +describe("内存滑动窗口限流器", () => { + it("窗口内未超限时放行", () => { + let clock = 1_000; + const limiter = createRateLimiter({ limit: 3, windowMs: 60_000, now: () => clock }); + expect(limiter.check("a")).toEqual({ allowed: true, retryAfterSeconds: 0 }); + clock += 1_000; + expect(limiter.check("a")).toEqual({ allowed: true, retryAfterSeconds: 0 }); + clock += 1_000; + expect(limiter.check("a")).toEqual({ allowed: true, retryAfterSeconds: 0 }); + }); + + it("达到上限后拒绝并给出 Retry-After 秒数", () => { + let clock = 10_000; + const limiter = createRateLimiter({ limit: 2, windowMs: 30_000, now: () => clock }); + expect(limiter.check("a").allowed).toBe(true); + expect(limiter.check("a").allowed).toBe(true); + clock += 5_000; + const denied = limiter.check("a"); + expect(denied.allowed).toBe(false); + expect(denied.retryAfterSeconds).toBeGreaterThan(0); + // The window started at t=10000 and lasts 30s, so at t=15000 the caller + // must wait the remaining 25 seconds. + expect(denied.retryAfterSeconds).toBe(25); + }); + + it("窗口过期后计数重置并重新放行", () => { + let clock = 0; + const limiter = createRateLimiter({ limit: 1, windowMs: 1_000, now: () => clock }); + expect(limiter.check("a").allowed).toBe(true); + expect(limiter.check("a").allowed).toBe(false); + // One millisecond before the window closes the key is still limited. + clock = 999; + expect(limiter.check("a").allowed).toBe(false); + clock = 1_000; + expect(limiter.check("a")).toEqual({ allowed: true, retryAfterSeconds: 0 }); + // The reset key starts a brand-new window from the reset moment, so the + // same key is limited again until that new window also elapses. + clock = 1_500; + expect(limiter.check("a").allowed).toBe(false); + clock = 2_000; + expect(limiter.check("a")).toEqual({ allowed: true, retryAfterSeconds: 0 }); + }); + + it("不同键互不影响", () => { + const limiter = createRateLimiter({ limit: 1, windowMs: 60_000, now: () => 0 }); + expect(limiter.check("1.2.3.4").allowed).toBe(true); + expect(limiter.check("1.2.3.4").allowed).toBe(false); + expect(limiter.check("5.6.7.8").allowed).toBe(true); + expect(limiter.check("5.6.7.8").allowed).toBe(false); + expect(limiter.size()).toBe(2); + }); + + it("惰性清理过期桶,避免长期运行内存增长", () => { + let clock = 0; + const limiter = createRateLimiter({ limit: 10, windowMs: 1_000, now: () => clock }); + for (let index = 0; index < 999; index += 1) limiter.check(`stale-${index}`); + expect(limiter.size()).toBe(999); + clock = 5_000; + // The sweep is amortized: only a periodic full pass removes dead keys, so + // the count must drop back to just the key currently receiving traffic. + for (let index = 0; index < 1_000; index += 1) limiter.check("noisy"); + expect(limiter.size()).toBe(1); + }); + + it("拒绝无效的限流参数", () => { + expect(() => createRateLimiter({ limit: 0, windowMs: 1_000 })).toThrow(/limit/); + expect(() => createRateLimiter({ limit: 1.5, windowMs: 1_000 })).toThrow(/limit/); + expect(() => createRateLimiter({ limit: 1, windowMs: 0 })).toThrow(/窗口/); + }); +}); + +describe("全局限流配置", () => { + function validConfigEnv() { + process.env.TALLYNOTE_PUBLIC_ORIGIN = "http://127.0.0.1:3996"; + process.env.TALLYNOTE_COOKIE_SECURE = "false"; + } + + it("默认每分钟 600 次,并支持显式覆盖", () => { + validConfigEnv(); + expect(loadConfig().apiRateLimitPerMinute).toBe(600); + process.env.TALLYNOTE_RATE_LIMIT_PER_MINUTE = "120"; + expect(loadConfig().apiRateLimitPerMinute).toBe(120); + process.env.TALLYNOTE_RATE_LIMIT_PER_MINUTE = "1"; + expect(loadConfig().apiRateLimitPerMinute).toBe(1); + }); + + it("拒绝非整数或小于 1 的限流值", () => { + validConfigEnv(); + process.env.TALLYNOTE_RATE_LIMIT_PER_MINUTE = "0"; + expect(() => loadConfig()).toThrow(/TALLYNOTE_RATE_LIMIT_PER_MINUTE/); + process.env.TALLYNOTE_RATE_LIMIT_PER_MINUTE = "-10"; + expect(() => loadConfig()).toThrow(/TALLYNOTE_RATE_LIMIT_PER_MINUTE/); + process.env.TALLYNOTE_RATE_LIMIT_PER_MINUTE = "abc"; + expect(() => loadConfig()).toThrow(/TALLYNOTE_RATE_LIMIT_PER_MINUTE/); + process.env.TALLYNOTE_RATE_LIMIT_PER_MINUTE = "12.5"; + expect(() => loadConfig()).toThrow(/TALLYNOTE_RATE_LIMIT_PER_MINUTE/); + }); +}); + +describe("全局限流接入 HTTP 层", () => { + const dataDirs: string[] = []; + + afterEach(() => { + while (dataDirs.length > 0) rmSync(dataDirs.pop()!, { recursive: true, force: true }); + }); + + async function buildLimitedApp(limit: string, withWeb = false) { + const dataDir = mkdtempSync(path.join(tmpdir(), "tallynote-rate-limit-")); + dataDirs.push(dataDir); + process.env.TALLYNOTE_DATA_DIR = dataDir; + process.env.TALLYNOTE_PUBLIC_ORIGIN = "http://127.0.0.1:3996"; + process.env.TALLYNOTE_COOKIE_SECURE = "false"; + process.env.TALLYNOTE_RATE_LIMIT_PER_MINUTE = limit; + const config = loadConfig(); + // By default keep the test independent from the locally generated dist/web + // tree. When a real asset graph is requested the exemption must still hold, + // which proves it is path-based rather than an artefact of a missing webDir. + config.webDir = withWeb ? path.join(dataDir, "web") : path.join(dataDir, "missing-web"); + prepareDataDirectories(config); + if (withWeb) { + mkdirSync(path.join(config.webDir, "assets"), { recursive: true }); + writeFileSync(path.join(config.webDir, "index.html"), "tallynote-test-index"); + writeFileSync(path.join(config.webDir, "assets", "probe.js"), "console.log('tallynote-test-asset');"); + } + const database = openDatabase(config); + const app = await buildApp(database, config); + return { app, database }; + } + + it("超过配置的 /api/* 配额后返回 429 与 Retry-After,非 API 路径不受影响", async () => { + const { app, database } = await buildLimitedApp("2"); + try { + // Static/health traffic is exempt: the limiter only owns /api/*. + for (let index = 0; index < 5; index += 1) { + const health = await app.inject({ method: "GET", url: "/health" }); + expect(health.statusCode).toBe(200); + } + const first = await app.inject({ method: "GET", url: "/api/auth/status" }); + expect(first.statusCode).toBe(200); + const second = await app.inject({ method: "GET", url: "/api/auth/status" }); + expect(second.statusCode).toBe(200); + const limited = await app.inject({ method: "GET", url: "/api/auth/status" }); + expect(limited.statusCode).toBe(429); + expect(limited.json().error.code).toBe("RATE_LIMITED"); + expect(limited.json().error.message).toBe("请求过于频繁,请稍后再试"); + expect(limited.json().error.requestId).toBeTruthy(); + expect(Number(limited.headers["retry-after"])).toBeGreaterThan(0); + // The limiter must not touch the database: no new table, no writes to + // the login lockout table used by the stricter login protection. + const attempts = database.sqlite.prepare("SELECT COUNT(*) AS count FROM login_attempts").get() as { count: number }; + expect(attempts.count).toBe(0); + } finally { + await app.close(); + database.sqlite.close(); + } + }); + + it("配额耗尽后静态资源与 SPA 回退仍可访问", async () => { + const { app, database } = await buildLimitedApp("1", true); + try { + // Spend the whole /api/* quota for this client. + const first = await app.inject({ method: "GET", url: "/api/auth/status" }); + expect(first.statusCode).toBe(200); + const limited = await app.inject({ method: "GET", url: "/api/auth/status" }); + expect(limited.statusCode).toBe(429); + + // A limited client must still be able to load the page and its assets, + // otherwise recovery from the limit is impossible without a cache purge. + const index = await app.inject({ method: "GET", url: "/" }); + expect(index.statusCode).toBe(200); + expect(index.body).toContain("tallynote-test-index"); + + const asset = await app.inject({ method: "GET", url: "/assets/probe.js" }); + expect(asset.statusCode).toBe(200); + expect(asset.body).toContain("tallynote-test-asset"); + + // An unknown non-API path falls back to the SPA entry and stays exempt. + const fallback = await app.inject({ method: "GET", url: "/expenses" }); + expect(fallback.statusCode).toBe(200); + expect(fallback.body).toContain("tallynote-test-index"); + } finally { + await app.close(); + database.sqlite.close(); + } + }); +}); diff --git a/tests/update-api.test.ts b/tests/update-api.test.ts index e49152e..03a949f 100644 --- a/tests/update-api.test.ts +++ b/tests/update-api.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { chmodSync, existsSync, mkdtempSync, readFileSync, statSync, rmSync } from "node:fs"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, statSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { randomUUID } from "node:crypto"; @@ -8,6 +8,7 @@ import { loadConfig, prepareDataDirectories } from "../server/config.js"; import { openDatabase } from "../server/db/index.js"; import { hashPassword } from "../server/security.js"; import { detectPlatform } from "../server/update.js"; +import { reconcileOrphanedUpdateJobs } from "../server/update-service.js"; describe("更新 API", () => { let dataDir: string; diff --git a/tests/update-apply-staging.test.ts b/tests/update-apply-staging.test.ts new file mode 100644 index 0000000..da238c5 --- /dev/null +++ b/tests/update-apply-staging.test.ts @@ -0,0 +1,319 @@ +import { createHash, randomUUID } from "node:crypto"; +import { lstat, mkdir, mkdtemp, readFile, readlink, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { loadConfig, prepareDataDirectories, type AppConfig } from "../server/config.js"; +import { openDatabase } from "../server/db/index.js"; +import { main } from "../server/cli/update.js"; +import { createSafeArchive, detectPlatform } from "../server/update.js"; + +/** + * Link-level coverage for the two-process update hand-off: + * the unprivileged web process stages a verified payload into + * `/staging/update-`, then the privileged CLI (`main`) picks it + * up from a staged/apply DB row and switches the release. + * + * Ownership expectations are injected through `UpdateMainOverrides` because the + * suite runs as a non-root developer on macOS. Production defaults stay + * untouched: they never consult `process.getuid()`. + */ + +const CURRENT_UID = process.getuid?.() ?? 0; +const NEW_VERSION = "9.9.9"; +const METADATA_URL = "https://updates.example/latest"; + +const TRACKED_ENV = [ + "TALLYNOTE_DATA_DIR", + "TALLYNOTE_INSTALL_PREFIX", + "TALLYNOTE_PUBLIC_ORIGIN", + "TALLYNOTE_COOKIE_SECURE", + "TALLYNOTE_UPDATE_STRATEGY", + "TALLYNOTE_UPDATE_METADATA_URL", + "TALLYNOTE_UPDATE_ALLOWED_HOSTS", + "TALLYNOTE_UPDATE_REQUIRE_SIGNATURE", +] as const; + +const baselineEnv = new Map(TRACKED_ENV.map((key) => [key, process.env[key]])); +const baselineArgv = [...process.argv]; + +afterEach(() => { + for (const key of TRACKED_ENV) { + const value = baselineEnv.get(key); + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + process.argv.splice(0, process.argv.length, ...baselineArgv); +}); + +type ApplyFixture = { + root: string; + config: AppConfig; + jobId: string; + stagedDir: string; + digest: string; + assetName: string; +}; + +type FixtureOptions = { + /** Shape of `/update-`: a real staged tree, a symlink + * masquerading as one, or nothing at all. */ + stagedWorkspace?: "directory" | "symlink" | "absent"; + /** Whether the staged archive that `assertStagedArchiveIntegrity` hashes. */ + withArchive?: boolean; + /** Value written to `update_jobs.download_path`. The runner NULLs this column + * when it releases a workspace, so `null` is the post-runner production state. */ + downloadPath?: "null" | "stale" | "outside-staging-root"; + /** Whether the staged/apply row exists at all. */ + withDatabaseRow?: boolean; +}; + +/** Build the exact on-disk state the web download step leaves behind before a + * privileged apply runs: release layout, staged workspace, staged/apply row and + * the request file the CLI is invoked with. */ +async function setupApplyFixture(options: FixtureOptions = {}): Promise { + const root = await mkdtemp(path.join(tmpdir(), "tallynote-apply-staging-")); + const dataDir = path.join(root, "data"); + const installPrefix = path.join(root, "install"); + process.env.TALLYNOTE_DATA_DIR = dataDir; + process.env.TALLYNOTE_INSTALL_PREFIX = installPrefix; + process.env.TALLYNOTE_PUBLIC_ORIGIN = "http://127.0.0.1:3998"; + process.env.TALLYNOTE_COOKIE_SECURE = "false"; + process.env.TALLYNOTE_UPDATE_STRATEGY = "systemd"; + process.env.TALLYNOTE_UPDATE_METADATA_URL = METADATA_URL; + process.env.TALLYNOTE_UPDATE_ALLOWED_HOSTS = "updates.example"; + process.env.TALLYNOTE_UPDATE_REQUIRE_SIGNATURE = "false"; + const config = loadConfig(); + prepareDataDirectories(config); + + // Installer layout with a live current release so `atomicSwitchRelease` has a + // real previous target to report. + await mkdir(config.releasesDir, { recursive: true, mode: 0o755 }); + const previousRelease = path.join(config.releasesDir, config.appVersion); + await mkdir(path.join(previousRelease, "dist"), { recursive: true, mode: 0o755 }); + await writeFile(path.join(previousRelease, "dist", "marker"), "old"); + await symlink(previousRelease, config.currentLink); + + const assetName = `tallynote-${NEW_VERSION}-${detectPlatform().target}.tar.gz`; + const source = path.join(root, "release-source"); + await mkdir(path.join(source, "dist"), { recursive: true, mode: 0o700 }); + await writeFile(path.join(source, "dist", "marker"), "new"); + const archive = path.join(root, "release.tar.gz"); + await createSafeArchive(source, archive); + const bytes = await readFile(archive); + const digest = createHash("sha256").update(bytes).digest("hex"); + + const jobId = randomUUID(); + const stagedDir = path.join(config.stagingDir, `update-${jobId}`); + const stagedWorkspace = options.stagedWorkspace ?? "directory"; + if (stagedWorkspace === "directory") { + await mkdir(path.join(stagedDir, "payload", "dist"), { recursive: true, mode: 0o700 }); + await writeFile(path.join(stagedDir, "payload", "dist", "marker"), "new"); + if (options.withArchive !== false) await writeFile(path.join(stagedDir, "release.tar.gz"), bytes, { mode: 0o600 }); + } else if (stagedWorkspace === "symlink") { + // A symlinked workspace is the classic "swap the staged tree after the web + // process verified it" attack, and must never be followed by root. + const decoy = path.join(root, "decoy-workspace"); + await mkdir(path.join(decoy, "payload", "dist"), { recursive: true, mode: 0o700 }); + await writeFile(path.join(decoy, "payload", "dist", "marker"), "attacker"); + await symlink(decoy, stagedDir); + } + + let recordedDownloadPath: string | null = null; + if (options.downloadPath === "stale") recordedDownloadPath = path.join(root, "stale-workspace"); + if (options.downloadPath === "outside-staging-root") { + recordedDownloadPath = path.join(root, "outside-workspace"); + await mkdir(path.join(recordedDownloadPath, "payload", "dist"), { recursive: true, mode: 0o700 }); + } + + if (options.withDatabaseRow !== false) { + const database = openDatabase(config); + try { + const now = Date.now(); + database.sqlite.prepare(` + INSERT INTO update_jobs(id, operation, status, version, platform, asset_name, asset_url, + expected_sha256, download_path, created_at, updated_at, requested_at) + VALUES (?, 'apply', 'staged', ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run(jobId, NEW_VERSION, detectPlatform().target, assetName, `https://updates.example/${assetName}`, digest, recordedDownloadPath, now, now, now); + } finally { + database.sqlite.close(); + } + } + + await writeFile(config.updateRequestPath, JSON.stringify({ + jobId, + operation: "apply", + version: NEW_VERSION, + metadataUrl: config.updateMetadataUrl, + assetUrl: `https://updates.example/${assetName}`, + assetName, + expectedSha256: digest, + requestedAt: Date.now(), + currentLink: config.currentLink, + releasesDir: config.releasesDir, + dataDir: config.dataDir, + }), { mode: 0o600 }); + + return { root, config, jobId, stagedDir, digest, assetName }; +} + +/** Invoke the privileged entry point the way the runner does: through the + * request file, which is the only path that reaches the staged apply branch. */ +async function runMain(fixture: ApplyFixture, overrides: { stagingOwnerUid?: number; workspaceOwnerUid?: number } = {}): Promise { + process.argv.push("--request-file", fixture.config.updateRequestPath); + await main(fixture.config, { + stagingOwnerUid: overrides.stagingOwnerUid ?? CURRENT_UID, + workspaceOwnerUid: overrides.workspaceOwnerUid ?? CURRENT_UID, + }); +} + +function readJob(config: AppConfig, jobId: string): { status: string; operation: string; errorMessage: string | null; downloadPath: string | null } | undefined { + const database = openDatabase(config); + try { + return database.sqlite.prepare("SELECT status, operation, error_message AS errorMessage, download_path AS downloadPath FROM update_jobs WHERE id=?").get(jobId) as + { status: string; operation: string; errorMessage: string | null; downloadPath: string | null } | undefined; + } finally { + database.sqlite.close(); + } +} + +/** The audit row written by `failUpdateJobWithReason`, which carries the real + * machine-readable reason the UI renders instead of the runner's health text. */ +function readFailureAudit(config: AppConfig, jobId: string): { action: string; outcome: string; afterJson: string } | undefined { + const database = openDatabase(config); + try { + return database.sqlite.prepare("SELECT action, outcome, after_json AS afterJson FROM audit_events WHERE target_id=? ORDER BY id DESC LIMIT 1").get(jobId) as + { action: string; outcome: string; afterJson: string } | undefined; + } finally { + database.sqlite.close(); + } +} + +describe("web 暂存 → CLI apply 链路", () => { + it("场景 1:staged 行 + 暂存工作区存在时切换 current 到新 release", async () => { + const fixture = await setupApplyFixture(); + try { + await runMain(fixture); + + const link = await lstat(fixture.config.currentLink); + expect(link.isSymbolicLink()).toBe(true); + expect(await readlink(fixture.config.currentLink)).toBe(path.join(fixture.config.releasesDir, NEW_VERSION)); + expect((await lstat(path.join(fixture.config.releasesDir, NEW_VERSION))).isDirectory()).toBe(true); + expect(await readFile(path.join(fixture.config.releasesDir, NEW_VERSION, "dist", "marker"), "utf8")).toBe("new"); + + // The web-owned staging tree is consumed and the row leaves the staged state. + expect(await lstat(fixture.stagedDir).catch(() => null)).toBeNull(); + expect(readJob(fixture.config, fixture.jobId)).toMatchObject({ status: "applying", operation: "apply" }); + } finally { + await rm(fixture.root, { recursive: true, force: true }); + } + }); + + it("场景 2:download_path 为 NULL 时仍按 jobId 重建暂存工作区", async () => { + const fixture = await setupApplyFixture({ downloadPath: "null" }); + try { + // Precondition: the runner already cleared the transient column. + expect(readJob(fixture.config, fixture.jobId)?.downloadPath).toBeNull(); + + await runMain(fixture); + + expect(await readlink(fixture.config.currentLink)).toBe(path.join(fixture.config.releasesDir, NEW_VERSION)); + expect(await readFile(path.join(fixture.config.releasesDir, NEW_VERSION, "dist", "marker"), "utf8")).toBe("new"); + } finally { + await rm(fixture.root, { recursive: true, force: true }); + } + }); + + it("场景 2b:download_path 指向已消失的陈旧路径时仍回退到 jobId 候选", async () => { + const fixture = await setupApplyFixture({ downloadPath: "stale" }); + try { + expect(readJob(fixture.config, fixture.jobId)?.downloadPath).toBe(path.join(fixture.root, "stale-workspace")); + + await runMain(fixture); + + expect(await readlink(fixture.config.currentLink)).toBe(path.join(fixture.config.releasesDir, NEW_VERSION)); + } finally { + await rm(fixture.root, { recursive: true, force: true }); + } + }); + + it("场景 3:候选暂存目录不存在时拒绝并把行置为 failed", async () => { + const fixture = await setupApplyFixture({ stagedWorkspace: "absent" }); + try { + await expect(runMain(fixture)).rejects.toThrow(/暂存目录已不存在/); + + // No release may be published from a workspace that was never staged. + expect(await readlink(fixture.config.currentLink)).toBe(path.join(fixture.config.releasesDir, fixture.config.appVersion)); + expect(await lstat(path.join(fixture.config.releasesDir, NEW_VERSION)).catch(() => null)).toBeNull(); + + const job = readJob(fixture.config, fixture.jobId); + expect(job?.status).toBe("failed"); + expect(job?.errorMessage).toBe("暂存目录已不存在,请重新下载"); + expect(readFailureAudit(fixture.config, fixture.jobId)).toMatchObject({ action: "update.failed", outcome: "failure" }); + expect(JSON.parse(readFailureAudit(fixture.config, fixture.jobId)!.afterJson)).toMatchObject({ reason: "staged_workspace_missing" }); + } finally { + await rm(fixture.root, { recursive: true, force: true }); + } + }); + + it("场景 4a:暂存工作区是符号链接时拒绝执行", async () => { + const fixture = await setupApplyFixture({ stagedWorkspace: "symlink" }); + try { + await expect(runMain(fixture)).rejects.toThrow(/更新暂存目录权限无效/); + + // The decoy payload must never be promoted to a release. + expect(await readlink(fixture.config.currentLink)).toBe(path.join(fixture.config.releasesDir, fixture.config.appVersion)); + expect(await lstat(path.join(fixture.config.releasesDir, NEW_VERSION)).catch(() => null)).toBeNull(); + + expect(readJob(fixture.config, fixture.jobId)?.status).toBe("failed"); + expect(readJob(fixture.config, fixture.jobId)?.errorMessage).toBe("更新暂存目录权限无效"); + expect(JSON.parse(readFailureAudit(fixture.config, fixture.jobId)!.afterJson)).toMatchObject({ reason: "staged_workspace_insecure" }); + } finally { + await rm(fixture.root, { recursive: true, force: true }); + } + }); + + it("场景 4b:记录路径位于 stagingDir 之外时拒绝,即使暂存目录缺失", async () => { + const fixture = await setupApplyFixture({ stagedWorkspace: "absent", downloadPath: "outside-staging-root" }); + try { + await expect(runMain(fixture)).rejects.toThrow(/更新暂存路径无效/); + + expect(await lstat(path.join(fixture.config.releasesDir, NEW_VERSION)).catch(() => null)).toBeNull(); + expect(readJob(fixture.config, fixture.jobId)?.status).toBe("failed"); + expect(JSON.parse(readFailureAudit(fixture.config, fixture.jobId)!.afterJson)).toMatchObject({ reason: "staged_workspace_invalid" }); + } finally { + await rm(fixture.root, { recursive: true, force: true }); + } + }); + + it("场景 5:暂存区属主与期望 uid 不符时拒绝", async () => { + const fixture = await setupApplyFixture(); + try { + await expect(runMain(fixture, { stagingOwnerUid: CURRENT_UID + 1 })).rejects.toThrow(/更新暂存根目录权限无效/); + + expect(await readlink(fixture.config.currentLink)).toBe(path.join(fixture.config.releasesDir, fixture.config.appVersion)); + const job = readJob(fixture.config, fixture.jobId); + expect(job?.status).toBe("failed"); + expect(job?.errorMessage).toBe("更新暂存根目录权限无效"); + expect(JSON.parse(readFailureAudit(fixture.config, fixture.jobId)!.afterJson)).toMatchObject({ reason: "apply_precheck_failed" }); + } finally { + await rm(fixture.root, { recursive: true, force: true }); + } + }); + + it("场景 5b:工作区属主与期望 uid 不符时在 preflight 阶段拒绝", async () => { + const fixture = await setupApplyFixture(); + try { + await expect(runMain(fixture, { workspaceOwnerUid: CURRENT_UID + 1 })).rejects.toThrow(/更新工作目录必须是 root 拥有且权限为 0700/); + + expect(await lstat(path.join(fixture.config.releasesDir, NEW_VERSION)).catch(() => null)).toBeNull(); + expect(await readlink(fixture.config.currentLink)).toBe(path.join(fixture.config.releasesDir, fixture.config.appVersion)); + // The preflight rejection happens before the database is opened, so the + // row is left staged for the runner to finalize. Recorded as observed + // behavior, not asserted as a requirement. + expect(readJob(fixture.config, fixture.jobId)?.status).toBe("staged"); + } finally { + await rm(fixture.root, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/update.test.ts b/tests/update.test.ts index a082c2f..2316392 100644 --- a/tests/update.test.ts +++ b/tests/update.test.ts @@ -346,6 +346,12 @@ describe("更新安全工具", () => { insert.run(applyingId, "apply", "applying", config.appVersion, "linux-x64", "https://updates.example/applying.tar.gz", staleAt, staleAt); insert.run(stagedId, "download", "staged", "9.9.9", "linux-x64", "https://updates.example/staged.tar.gz", staleAt, staleAt); insert.run(stagedApplyId, "apply", "staged", "9.9.9", "linux-x64", "https://updates.example/staged-apply.tar.gz", staleAt, staleAt); + // A staged row is only actionable while its staged payload exists. The + // real download path always creates `update-` before flipping a job + // to `staged`, so create the workspace here too; otherwise the fixture + // tests an impossible state where the row claims an artifact it never had. + await mkdir(path.join(config.stagingDir, `update-${stagedId}`), { recursive: true }); + await mkdir(path.join(config.stagingDir, `update-${stagedApplyId}`), { recursive: true }); const now = Date.now(); expect(reconcileOrphanedUpdateJobs(database.sqlite, config, now)).toBe(3); expect(database.sqlite.prepare("SELECT status FROM update_jobs WHERE id=?").get(queuedId)).toEqual({ status: "failed" }); diff --git a/web-next/src/pages/update/UpdatePage.tsx b/web-next/src/pages/update/UpdatePage.tsx index 8eb60e9..1df3dfa 100644 --- a/web-next/src/pages/update/UpdatePage.tsx +++ b/web-next/src/pages/update/UpdatePage.tsx @@ -1049,14 +1049,21 @@ export default function UpdatePage({
• 升级过程具备原子切换与自愈保护,若健康检查异常将自动回退至当前版本。
-