fix: 修复在线更新暂存链路并增加全局 API 限流备底

- 新增 server/rate-limit.ts:进程内固定窗口限流器,无数据库写入
- server/app.ts 注册全局 preHandler,仅作用于 /api/*,超限返回 429 与 Retry-After
- 提取 isApiPath 统一 onSend、preHandler 与 404 的路径判断
- 更新任务冲突判定改用 ACTIVE_UPDATE_CONFLICT_SQL,staged/download 产物不再阻塞新任务
- cancelUpdateJob 调用补上 await,避免结果恒为 pending Promise
- server/cli/update.ts 增加特权工作区所有权校验与暂存路径重建逻辑
- 新增 tests/rate-limit.test.ts 与 tests/update-apply-staging.test.ts
This commit is contained in:
Qiufeng
2026-09-17 13:12:20 +08:00
parent 5afcd98ebd
commit ae8966baf6
12 changed files with 1145 additions and 71 deletions
+312 -44
View File
@@ -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<ty
return { asset: { name: sanitizeAssetName(options.assetName ?? path.basename(assetUrl.pathname)), url: assetUrl.toString(), ...(options.expectedSha256 ? { sha256: options.expectedSha256 } : {}) }, version: options.version };
}
async function ensurePrivilegedWorkspace(directory: string): Promise<string> {
/**
* 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
* `<installPrefix>/.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<DirectoryOwnerUid> {
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<string> {
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<string> {
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<string> {
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<string> {
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. `<stagingRoot>/update-<jobId>` (web download workspace)
* 2. `<stagingRoot>/update-<jobId>-*` (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<string> {
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<string>();
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<void> {
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<void> {
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<string> {
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<UpdateRunResult> {
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 `<installPrefix>/.update-work` as root-owned 0700). Tests inject
* the current user so the check never depends on `process.getuid()`. */
workspaceOwnerUid?: DirectoryOwnerUid;
}): Promise<void> {
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<string, unknown> | 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<void> {
/**
* 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 `<installPrefix>/.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<void> {
const finalizeJobId = arg("--finalize-job");
if (finalizeJobId) {
const finalStatus = arg("--finalize-status");
@@ -518,7 +745,9 @@ export async function main(config: AppConfig = loadConfig()): Promise<void> {
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<void> {
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