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
@@ -6,12 +6,14 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { executeRecord } from "../../src/modules/acquisition-organize/services/organize.ts";
|
||||
import { fsErrorToFiles } from "../../src/modules/file-manager/common.ts";
|
||||
import {
|
||||
ADMIN_KEY,
|
||||
createFmHarness,
|
||||
EXEC_KEY,
|
||||
type FmHarness,
|
||||
key,
|
||||
pgAvailable,
|
||||
READ_KEY,
|
||||
SESSION,
|
||||
seedAsset,
|
||||
seedBinding,
|
||||
seedEntity,
|
||||
@@ -328,13 +330,18 @@ describe.skipIf(skip)("files W5-A2", () => {
|
||||
target_path: "gone",
|
||||
});
|
||||
|
||||
const perm = await h.api("POST", "/files/delete", {
|
||||
idempotencyKey: key(),
|
||||
items: [{ root: h.libRoot, path: "gone/x.srt" }],
|
||||
mode: "permanent",
|
||||
confirmName: "x.srt",
|
||||
dryRun: false,
|
||||
});
|
||||
const perm = await h.api(
|
||||
"POST",
|
||||
"/files/delete",
|
||||
{
|
||||
idempotencyKey: key(),
|
||||
items: [{ root: h.libRoot, path: "gone/x.srt" }],
|
||||
mode: "permanent",
|
||||
confirmName: "x.srt",
|
||||
dryRun: false,
|
||||
},
|
||||
SESSION,
|
||||
);
|
||||
expect(perm.status).toBe(200);
|
||||
const prec = await h.db
|
||||
.selectFrom("organization_record")
|
||||
@@ -357,29 +364,32 @@ describe.skipIf(skip)("files W5-A2", () => {
|
||||
const url = (path: string, extra = "") =>
|
||||
`/files/download?root=${encodeURIComponent(h.libRoot)}&path=${encodeURIComponent(path)}${extra}`;
|
||||
|
||||
const ok = await h.raw("GET", url("dl-test/海报.jpg", "&inline=true"));
|
||||
const ok = await h.raw("GET", url("dl-test/海报.jpg", "&inline=true"), SESSION);
|
||||
expect(ok.status).toBe(200);
|
||||
expect(await ok.text()).toBe("JPEGDATA");
|
||||
expect(ok.headers.get("content-type")).toBe("image/jpeg");
|
||||
expect(ok.headers.get("content-disposition")).toMatch(/^inline; .*filename\*=UTF-8''%E6%B5%B7%E6%8A%A5\.jpg/);
|
||||
expect(ok.headers.get("x-content-type-options")).toBe("nosniff");
|
||||
const nfo = await h.raw("GET", url("dl-test/movie.nfo", "&inline=true"));
|
||||
const nfo = await h.raw("GET", url("dl-test/movie.nfo", "&inline=true"), SESSION);
|
||||
expect(nfo.headers.get("content-disposition")).toMatch(/^attachment;/);
|
||||
expect(await nfo.text()).toBe("<movie/>");
|
||||
|
||||
expect((await h.api("GET", url("dl-test/movie.nfo"), undefined, READ_KEY)).status).toBe(403);
|
||||
expect((await h.api("GET", url("dl-test/movie.nfo"), undefined, EXEC_KEY)).status).toBe(403);
|
||||
const video = await h.api("GET", url("dl-test/v.mkv"));
|
||||
// 验收 07 S-2:admin scope 的 Key 与 DEV key 同样 403(注册表 auth:"session",中间件执行)
|
||||
expect((await h.api("GET", url("dl-test/movie.nfo"), undefined, ADMIN_KEY)).status).toBe(403);
|
||||
expect((await h.api("GET", url("dl-test/movie.nfo"))).status).toBe(403);
|
||||
const video = await h.api("GET", url("dl-test/v.mkv"), undefined, SESSION);
|
||||
expect(video.status).toBe(403);
|
||||
const big = await h.api("GET", url("dl-test/big.srt"));
|
||||
const big = await h.api("GET", url("dl-test/big.srt"), undefined, SESSION);
|
||||
expect(big.status).toBe(413);
|
||||
expect(big.body.error.message).toMatch(/^too_large:/);
|
||||
const leak = await h.api("GET", url("dl-test/leak.nfo"));
|
||||
const leak = await h.api("GET", url("dl-test/leak.nfo"), undefined, SESSION);
|
||||
expect(leak.status).toBe(400);
|
||||
expect(leak.body.error.message).toMatch(/^path_escape:/);
|
||||
expect((await h.api("GET", url("dl-test"))).status).toBe(400);
|
||||
expect((await h.api("GET", url("dl-test/none.nfo"))).status).toBe(404);
|
||||
expect((await h.api("GET", url("../outside/secret.txt"))).status).toBeGreaterThanOrEqual(400);
|
||||
expect((await h.api("GET", url("dl-test"), undefined, SESSION)).status).toBe(400);
|
||||
expect((await h.api("GET", url("dl-test/none.nfo"), undefined, SESSION)).status).toBe(404);
|
||||
expect((await h.api("GET", url("../outside/secret.txt"), undefined, SESSION)).status).toBeGreaterThanOrEqual(400);
|
||||
|
||||
const audits = await h.db
|
||||
.selectFrom("audit_event")
|
||||
|
||||
Reference in new issue
Block a user