fix(files,security): S-1 per-source Move authorization, S-2 session-only annotation, S-5 restore guards
S-1 (/files/move, /files/organize action=move): - organize-guard: authorizeMoveEntities checks every source's media entities (declared entity plus entities of registered assets under the source); one-time approval still covers the whole request; any source that cannot be attributed to an entity only passes with an approval; denial lists every unauthorized item; optional non-consuming pre-check - transfer: entities per source (directory = registered assets beneath it) - organize: all move groups pre-checked before any group executes; any denial -> whole request 403 approval_required S-2: - api.ts: GET /files/download and POST /files/trash/purge marked auth:"session"; files routes attach requireAdminSession from the registry annotation; handler-side purge check removed, download keeps a same-rule defense check; permanent delete uses isAdminSession - DEV key rule written down: equals an admin-scope key for session-only endpoints, still admin for Move/delete approval bypass (as authorizeMove) - api-registry.test: session list updated; new reverse assertion that no non-session endpoint answers 403 to an admin-scope bearer S-5 (trash restore): - restore-to requires a writable managed library root (isWritableTarget) - in-place restore rebuilds missing parents level by level without following symlinks (mkdirWithin), realpath-checked per level - cross-volume restore capped by FILES_TRASH_CROSS_MAX_BYTES; cross-volume copies (trash in / restore out) go through fsproxy Tests: multi-entity move 403 + listing, unattributed source, organize foreign asset 403, download/purge 403 for DEV and admin-scope keys, restore target not writable, symlinked parent, rebuild parents, cross-volume cap. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
9887d8b311
commit
17cf3cbf97
16 files changed
+587
-107
No files matched your search
@@ -1,7 +1,7 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { lstat, mkdir, readdir, rm } from "node:fs/promises";
|
||||
import { lstat, mkdir, readdir, realpath, rm } from "node:fs/promises";
|
||||
import { basename, dirname, join, resolve } from "node:path";
|
||||
import { copyWithVerify, moveSafe } from "@umt/adapters";
|
||||
import { moveSafe } from "@umt/adapters";
|
||||
import type {
|
||||
FileDeleteCheck,
|
||||
FilesDeleteRequest,
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
import { sql, type TrashItemRow } from "@umt/db";
|
||||
import type { Principal } from "../../http/auth.ts";
|
||||
import { upsertDecision } from "../acquisition-organize/services/common.ts";
|
||||
import { fsRuntime } from "../acquisition-organize/services/organize.ts";
|
||||
import {
|
||||
actorOfP,
|
||||
errMessage,
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
type FmDeps,
|
||||
hashJson,
|
||||
isAdmin,
|
||||
isAdminSession,
|
||||
jsonb,
|
||||
onlineRoot,
|
||||
recheck,
|
||||
@@ -33,7 +35,7 @@ import {
|
||||
within,
|
||||
} from "./common.ts";
|
||||
import { audit, consumeApproval, filesChanged, loadPlan, retirePlan, savePlan } from "./ledger.ts";
|
||||
import { bindingsAt, downloadsFor, isDownloading, managedDownloads } from "./list.ts";
|
||||
import { bindingsAt, downloadsFor, isDownloading, isWritableTarget, managedDownloads } from "./list.ts";
|
||||
import { type RecordFile, writeRecord } from "./records.ts";
|
||||
|
||||
// 删除(原则 5):默认进回收站(同卷 <根>/.umt-trash/<yyyymmdd>/<uuid>/<原相对路径>,O(1) rename;
|
||||
@@ -170,7 +172,7 @@ export async function deleteFiles(
|
||||
if (body.mode === "permanent") {
|
||||
if (!d.options.allowPermanentDelete)
|
||||
throw new FilesError("permanent_disabled", "永久删除未开启(FILES_ALLOW_PERMANENT_DELETE),请使用回收站");
|
||||
if (!isAdmin(principal)) throw new FilesError("forbidden", "永久删除只允许控制台管理员会话");
|
||||
if (!isAdminSession(principal)) throw new FilesError("forbidden", "永久删除只允许控制台管理员会话");
|
||||
}
|
||||
const downloads = await managedDownloads(d);
|
||||
const results: Awaited<ReturnType<typeof checkItem>>[] = [];
|
||||
@@ -354,14 +356,17 @@ function recordFilesOf(
|
||||
return inner.map((f) => ({ src: join(abs, f), dst: where ? join(where, f) : null, size: 0, status }));
|
||||
}
|
||||
|
||||
async function copyTree(src: string, dst: string, files: string[], hash: "size" | "sha256" | "sample") {
|
||||
/** 跨卷复制(进回收站 / 从回收站还原):逐文件经 fsproxy(挂死隔离:停滞超时 → SIGKILL → TIMEOUT),复制后核验 */
|
||||
async function copyTree(d: FmDeps, src: string, dst: string, files: string[]) {
|
||||
const proxy = fsRuntime(d.ao).proxy;
|
||||
const hash = d.ao.ports.options.verifyHash;
|
||||
const st = await lstat(src);
|
||||
if (!st.isDirectory()) {
|
||||
await copyWithVerify(src, dst, { hash, createParents: true });
|
||||
await proxy.copy(src, dst, { hash, createParents: true });
|
||||
return;
|
||||
}
|
||||
await mkdir(dst, { recursive: true });
|
||||
for (const f of files) await copyWithVerify(join(src, f), join(dst, f), { hash, createParents: true });
|
||||
for (const f of files) await proxy.copy(join(src, f), join(dst, f), { hash, createParents: true });
|
||||
}
|
||||
|
||||
async function moveToTrash(
|
||||
@@ -396,7 +401,7 @@ async function moveToTrash(
|
||||
await rm(join(ctx.rootReal!, TRASH_DIR, stamp, id), { recursive: true, force: true }).catch(() => undefined);
|
||||
trashPath = join(resolve(d.options.dataDir), "trash", stamp, id, check.path);
|
||||
const { files } = await sizeOf(abs);
|
||||
await copyTree(abs, trashPath, files, d.ao.ports.options.verifyHash);
|
||||
await copyTree(d, abs, trashPath, files);
|
||||
await rm(abs, { recursive: true });
|
||||
}
|
||||
return d.ctx.db
|
||||
@@ -466,6 +471,30 @@ function trashPathSafe(d: FmDeps, row: TrashItemRow): boolean {
|
||||
return (inRoot || inData) && !row.trash_path.split("/").includes("..");
|
||||
}
|
||||
|
||||
/**
|
||||
* 在根内逐级建目录:每一级先 lstat,已存在的必须是真目录(符号链接一律拒绝,哪怕指向根内),不存在才 mkdir(非递归)。
|
||||
* 避免 mkdir -p 跟随中间的符号链接在根外建出目录(验收 07 S-5)。
|
||||
*/
|
||||
export async function mkdirWithin(rootReal: string, rel: string): Promise<void> {
|
||||
if (rel === "" || rel === ".") return;
|
||||
let cur = rootReal;
|
||||
for (const seg of rel.split("/")) {
|
||||
if (seg === "" || seg === ".") continue;
|
||||
if (seg === "..") throw new FilesError("path_escape", `路径含 ..: ${rel}`);
|
||||
cur = join(cur, seg);
|
||||
const st = await lstat(cur).catch((e) => {
|
||||
if (errnoOf(e) === "ENOENT") return null;
|
||||
throw e;
|
||||
});
|
||||
if (st?.isSymbolicLink())
|
||||
throw new FilesError("path_escape", `还原路径中的 ${relOfRoot(rootReal, cur)} 是符号链接,拒绝`);
|
||||
if (st && !st.isDirectory()) throw new FilesError("exists", `还原路径中的 ${relOfRoot(rootReal, cur)} 不是目录`);
|
||||
if (!st) await mkdir(cur);
|
||||
if (!within(await realpath(cur), rootReal)) throw new FilesError("path_escape", `还原路径逃出根: ${rel}`);
|
||||
}
|
||||
}
|
||||
const relOfRoot = (root: string, p: string) => (p === root ? "" : p.slice(root.length + 1));
|
||||
|
||||
export async function restoreTrash(
|
||||
d: FmDeps,
|
||||
principal: Principal | undefined,
|
||||
@@ -478,6 +507,9 @@ export async function restoreTrash(
|
||||
const target = body.target ? await resolveIn(d, body.target.root, body.target.path) : null;
|
||||
if (target && (!target.lst?.isDirectory() || target.isSymlink))
|
||||
throw new FilesError("bad_request", "还原目标必须是已存在的目录");
|
||||
// "还原到…"只能进可写的 managed 库根(与 move/copy 的目标规则一致;register_only / 下载根拒绝,验收 07 S-5)
|
||||
if (target && !(await isWritableTarget(target.def, target.rootReal)))
|
||||
throw new FilesError("not_writable", `还原目标根 ${target.def.name} 不可写(仅 managed 库根可作为目标)`);
|
||||
for (const id of body.ids) {
|
||||
try {
|
||||
const row = await d.ctx.db.selectFrom("trash_item").selectAll().where("id", "=", id).executeTakeFirst();
|
||||
@@ -495,11 +527,9 @@ export async function restoreTrash(
|
||||
rootReal = await onlineRoot(d, def);
|
||||
const parent = await resolveIn(d, row.root_id, row.original_path, { mustExist: false, def, rootReal }).catch(
|
||||
async (e) => {
|
||||
// 父目录已不存在:在根内重建后再解析
|
||||
// 父目录已不存在:在根内逐级重建(每级先 lstat,已存在的必须是真目录,不跟随符号链接)后再解析
|
||||
if (e instanceof FilesError && e.reason === "not_found") {
|
||||
const p = join(rootReal, dirname(row.original_path));
|
||||
if (!within(resolve(p), rootReal)) throw e;
|
||||
await mkdir(p, { recursive: true });
|
||||
await mkdirWithin(rootReal, dirname(row.original_path));
|
||||
return resolveIn(d, row.root_id, row.original_path, { mustExist: false, def, rootReal });
|
||||
}
|
||||
throw e;
|
||||
@@ -511,8 +541,15 @@ export async function restoreTrash(
|
||||
const r = await moveSafe(row.trash_path, dst);
|
||||
if (r.status === "exists") throw new FilesError("exists", "原位置已被占用");
|
||||
if (r.status === "exdev") {
|
||||
const { files } = await sizeOf(row.trash_path);
|
||||
await copyTree(row.trash_path, dst, files, d.ao.ports.options.verifyHash);
|
||||
// 跨卷还原:与跨卷进回收站同一上限(FILES_TRASH_CROSS_MAX_BYTES),复制经 fsproxy,核验后删回收站副本
|
||||
const { size, files } = await sizeOf(row.trash_path);
|
||||
const bytes = Math.max(size, Number(row.size_bytes));
|
||||
if (bytes > d.options.trashCrossMaxBytes)
|
||||
throw new FilesError(
|
||||
"too_large",
|
||||
`跨卷还原大小 ${bytes} 超过上限 ${d.options.trashCrossMaxBytes};请在同卷还原,或手工处理 ${row.trash_path}`,
|
||||
);
|
||||
await copyTree(d, row.trash_path, dst, files);
|
||||
await rm(row.trash_path, { recursive: true });
|
||||
}
|
||||
await d.ctx.db.deleteFrom("trash_item").where("id", "=", id).execute();
|
||||
|
||||
Reference in new issue
Block a user