feat(sandbox): 不可信字节在沙箱子进程里解析(撤销权限、超时与内存强杀);种子、文件预览、字幕解析迁入

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-03 01:44:31 +08:00
1 parent 1a76d2d700
commit eeec91e892
21 files changed
+632 -178

No files matched your search

@@ -0,0 +1,113 @@
// 文件预览的纯计算(沙箱任务 file.preview):文件头字节 → 预览结构;读文件在主进程(preview.ts)。
import { type BencodeValue, bdecode, parseTorrent } from "@umt/adapters";
import type { FilePreview } from "@umt/contracts";
import {
CUE_LIMIT,
decodeText,
IMAGE_MIME,
imageSize,
maskTracker,
parseAss,
parseSrtLike,
parseSup,
} from "./preview-parse.ts";
export const TORRENT_FILES = 500;
/** 不是有效 .torrent 时抛出的错误信息(主进程据此转成 400) */
export const BAD_TORRENT = "BAD_TORRENT";
const str = (v: BencodeValue | undefined) => (v instanceof Uint8Array ? new TextDecoder().decode(v) : null);
function trackersOf(root: { [k: string]: BencodeValue }): string[] {
const out: string[] = [];
const tiers = root["announce-list"];
if (Array.isArray(tiers))
for (const tier of tiers) for (const t of Array.isArray(tier) ? tier : [tier]) out.push(str(t) ?? "");
out.push(str(root.announce) ?? "");
return [...new Set(out.filter(Boolean).map(maskTracker))];
}
export interface PreviewInput {
kind: "image" | "torrent" | "subtitle" | "text";
ext: string;
/** 文件头字节(base64) */
base64: string;
/** 文件实际大小 */
size: number;
}
export function buildPreview(input: PreviewInput): FilePreview {
const { kind, ext, size } = input;
const buf = Uint8Array.from(atob(input.base64), (c) => c.charCodeAt(0));
if (kind === "image") {
const dim = imageSize(buf);
return {
type: "image",
size,
mime: IMAGE_MIME[ext] ?? "application/octet-stream",
width: dim?.width ?? null,
height: dim?.height ?? null,
};
}
if (kind === "torrent") {
let meta: ReturnType<typeof parseTorrent>;
let root: { [k: string]: BencodeValue };
try {
meta = parseTorrent(buf);
root = bdecode(buf) as { [k: string]: BencodeValue };
} catch {
throw new Error(BAD_TORRENT);
}
const info = root.info as { [k: string]: BencodeValue } | undefined;
const created = typeof root["creation date"] === "number" ? root["creation date"] : null;
return {
type: "torrent",
size,
name: meta.name,
hash: meta.hash,
infohashV1: meta.infohashV1,
infohashV2: meta.infohashV2,
private: meta.private,
totalSize: meta.totalSize,
fileCount: meta.files.length,
files: meta.files.slice(0, TORRENT_FILES).map((f) => ({ path: f.path, length: Math.max(0, f.length) })),
trackers: trackersOf(root),
comment: str(root["comment.utf-8"]) ?? str(root.comment),
createdBy: str(root["created by"]),
createdAt: created && created > 0 ? new Date(created * 1000).toISOString() : null,
pieceLength: typeof info?.["piece length"] === "number" ? info["piece length"] : null,
};
}
const truncated = size > buf.length;
if (kind === "subtitle") {
if (ext === "sup") {
const all = parseSup(buf);
return {
type: "subtitle",
size,
format: "sup",
encoding: null,
cues: all.slice(0, CUE_LIMIT),
parsed: all.length,
truncated,
title: null,
};
}
const { text, encoding } = decodeText(buf, truncated);
const ass = ext === "ass" || ext === "ssa";
const parsed = ass ? parseAss(text) : { cues: parseSrtLike(text), title: null };
const cues = [...parsed.cues].sort((a, b) => a.start - b.start);
return {
type: "subtitle",
size,
format: ext as "srt" | "vtt" | "ass" | "ssa",
encoding,
cues: cues.slice(0, CUE_LIMIT),
parsed: cues.length,
truncated,
title: parsed.title,
};
}
const { text, encoding } = decodeText(buf, truncated);
return { type: "text", size, format: ext, encoding, text, truncated };
}
+20 -102
View File
@@ -1,21 +1,12 @@
import { open } from "node:fs/promises";
import { basename } from "node:path";
import { BencodeError, type BencodeValue, bdecode, parseTorrent } from "@umt/adapters";
import type { FilePreview, FilesPreviewQuery } from "@umt/contracts";
import type { Principal } from "../../http/auth.ts";
import { runSandbox, SandboxError } from "../../sandbox/client.ts";
import { b64encode } from "../../sandbox/jobs.ts";
import { FilesError, type FmDeps, isAdminSession, recheck, resolveIn } from "./common.ts";
import {
CUE_LIMIT,
decodeText,
extOf,
IMAGE_MIME,
imageSize,
maskTracker,
parseAss,
parseSrtLike,
parseSup,
previewKindOf,
} from "./preview-parse.ts";
import { BAD_TORRENT } from "./preview-build.ts";
import { extOf, previewKindOf } from "./preview-parse.ts";
// GET /files/preview(FM-2):仅管理员会话;路径守卫同下载(realpath 在根内、符号链接目标也须在根内,打开前复核)。
// 只读文件头:文本/字幕 ≤ 1 MiB、图片头 512 KiB;.torrent 整个读但上限 4 MiB。只回 JSON,不回原始内容。
@@ -23,7 +14,6 @@ import {
export const TEXT_PREVIEW_MAX = 1024 * 1024;
const IMAGE_HEAD = 512 * 1024;
const TORRENT_MAX = 4 * 1024 * 1024;
const TORRENT_FILES = 500;
async function readHead(abs: string, max: number): Promise<{ buf: Uint8Array; size: number }> {
const fh = await open(abs, "r");
@@ -44,17 +34,6 @@ async function readHead(abs: string, max: number): Promise<{ buf: Uint8Array; si
}
}
const str = (v: BencodeValue | undefined) => (v instanceof Uint8Array ? new TextDecoder().decode(v) : null);
function trackersOf(root: { [k: string]: BencodeValue }): string[] {
const out: string[] = [];
const tiers = root["announce-list"];
if (Array.isArray(tiers))
for (const tier of tiers) for (const t of Array.isArray(tier) ? tier : [tier]) out.push(str(t) ?? "");
out.push(str(root.announce) ?? "");
return [...new Set(out.filter(Boolean).map(maskTracker))];
}
export async function previewFile(
d: FmDeps,
principal: Principal | undefined,
@@ -69,82 +48,21 @@ export async function previewFile(
if (!kind) throw new FilesError("forbidden", `不支持预览 .${ext || "无扩展名"} 文件`);
const real = await recheck(r.abs, r.rootReal);
if (kind === "image") {
const { buf, size } = await readHead(real, IMAGE_HEAD);
const dim = imageSize(buf);
return {
type: "image",
size,
mime: IMAGE_MIME[ext] ?? "application/octet-stream",
width: dim?.width ?? null,
height: dim?.height ?? null,
};
const head = kind === "image" ? IMAGE_HEAD : kind === "torrent" ? TORRENT_MAX : TEXT_PREVIEW_MAX;
const { buf, size } = await readHead(real, head);
if (kind === "torrent" && size > TORRENT_MAX)
throw new FilesError("too_large", `.torrent 文件 ${size} 字节,超过预览上限 4 MiB`);
// 文件内容不可信:解析放进沙箱子进程(docs/adr/ADR-005-sandbox.md)
try {
return (await runSandbox(
"file.preview",
{ kind, ext, base64: b64encode(buf), size },
{ timeoutMs: 15_000 },
)) as FilePreview;
} catch (e) {
if (e instanceof SandboxError && e.kind === "job" && e.message.includes(BAD_TORRENT))
throw new FilesError("bad_request", "不是有效的 .torrent 文件");
if (e instanceof SandboxError) throw new FilesError("bad_request", `预览失败:${e.message}`);
throw e;
}
if (kind === "torrent") {
const { buf, size } = await readHead(real, TORRENT_MAX);
if (size > TORRENT_MAX) throw new FilesError("too_large", `.torrent 文件 ${size} 字节,超过预览上限 4 MiB`);
try {
const meta = parseTorrent(buf);
const root = bdecode(buf) as { [k: string]: BencodeValue };
const info = root.info as { [k: string]: BencodeValue } | undefined;
const created = typeof root["creation date"] === "number" ? root["creation date"] : null;
return {
type: "torrent",
size,
name: meta.name,
hash: meta.hash,
infohashV1: meta.infohashV1,
infohashV2: meta.infohashV2,
private: meta.private,
totalSize: meta.totalSize,
fileCount: meta.files.length,
files: meta.files.slice(0, TORRENT_FILES).map((f) => ({ path: f.path, length: Math.max(0, f.length) })),
trackers: trackersOf(root),
comment: str(root["comment.utf-8"]) ?? str(root.comment),
createdBy: str(root["created by"]),
createdAt: created && created > 0 ? new Date(created * 1000).toISOString() : null,
pieceLength: typeof info?.["piece length"] === "number" ? info["piece length"] : null,
};
} catch (e) {
if (e instanceof BencodeError) throw new FilesError("bad_request", "不是有效的 .torrent 文件");
throw e;
}
}
const { buf, size } = await readHead(real, TEXT_PREVIEW_MAX);
const truncated = size > buf.length;
if (kind === "subtitle") {
if (ext === "sup") {
const all = parseSup(buf);
return {
type: "subtitle",
size,
format: "sup",
encoding: null,
cues: all.slice(0, CUE_LIMIT),
parsed: all.length,
truncated,
title: null,
};
}
const { text, encoding } = decodeText(buf, truncated);
const ass = ext === "ass" || ext === "ssa";
const parsed = ass ? parseAss(text) : { cues: parseSrtLike(text), title: null };
const cues = [...parsed.cues].sort((a, b) => a.start - b.start);
return {
type: "subtitle",
size,
format: ext as "srt" | "vtt" | "ass" | "ssa",
encoding,
cues: cues.slice(0, CUE_LIMIT),
parsed: cues.length,
truncated,
title: parsed.title,
};
}
const { text, encoding } = decodeText(buf, truncated);
return { type: "text", size, format: ext, encoding, text, truncated };
}