本章把上一章的安全读取能力放进实际业务:用户上传 Markdown 项目 ZIP,服务端转换 HTML 和静态资源,再交给 archiver 生成下载包。
不要让程序“猜测任意 ZIP 的意图”。新人项目可以先规定一种简单结构:
my-document.zip
├── README.md # 默认入口
├── chapters/
│ └── install.md
└── assets/
├── architecture.png
└── article.css
第一版合同可以是:
README.md 是入口;也允许请求参数明确指定一个 .md 文件;.md、.markdown、.png、.jpg、.jpeg、.gif、.webp;http:/https: 图片是否允许,由产品策略明确决定;SVG、HTML 和 CSS 都比普通位图更复杂。SVG 可以包含脚本或外部资源,CSS 可能加载远程 URL。新人版本可以先拒绝它们,确认有业务需求后再增加专门的清洗策略。
ZIP 路径安全
防止写出隔离目录、符号链接和资源耗尽
文件类型安全
决定业务接受哪些文件,并验证实际内容
HTML 内容安全
防止 Markdown 转换结果中的 XSS 和危险 URL
例如,assets/a.png 是安全相对路径,不代表文件内容一定是 PNG;文件确实是 PNG,也不代表 Markdown 转换器产生的全部 HTML 都安全。
manifest 是经过校验的内部清单,后续转换阶段只使用清单,不再直接相信原始 Entry。
type ImportedFileKind = "markdown" | "image";
interface ImportedFile {
relativePath: string;
absolutePath: string;
kind: ImportedFileKind;
size: number;
}
interface MarkdownProjectManifest {
entryMarkdown: ImportedFile;
markdownFiles: ImportedFile[];
assetFiles: ImportedFile[];
totalUncompressedSize: number;
}
manifest 带来几个好处:
注意不要把用户提供的绝对路径放进响应。absolutePath 只在服务内部使用。
阶段 A:接收
Multer 限制 ZIP 自身大小并写入临时文件
阶段 B:检查与提取
yauzl 逐个读取 Entry,校验路径、类型和资源上限
阶段 C:建立 manifest
确定 Markdown、资源文件和唯一入口
阶段 D:转换
读取允许的 Markdown,渲染并清洗 HTML,复制安全资源
阶段 E:打包
archiver 生成结果 ZIP
阶段 F:响应与清理
发送下载;无论成功、失败或客户端中断都清理临时目录
不要在看到第一个 README.md 时就开始向客户端输出 ZIP。后面的 Entry 仍可能是路径穿越或 ZIP bomb;一旦响应头和 ZIP 内容已经发送,就无法改成结构化 JSON 错误。
扩展名白名单适合做第一道业务筛选:
import path from "node:path";
const markdownExtensions = new Set([".md", ".markdown"]);
const imageExtensions = new Set([
".png",
".jpg",
".jpeg",
".gif",
".webp",
]);
function classifyFile(relativePath: string): ImportedFileKind {
const extension = path.posix.extname(relativePath).toLowerCase();
if (markdownExtensions.has(extension)) return "markdown";
if (imageExtensions.has(extension)) return "image";
throw new Error("UNSUPPORTED_PROJECT_FILE");
}
但扩展名由用户控制。图片若会被公开访问,至少应检查 Magic Number;更严格的图片业务应使用成熟图片库完整解码并重新编码。不要通过:
fileName.endsWith(".png")
就断言文件内容一定安全。
对于 Markdown,应限制单文件大小并按 UTF-8 严格解码,避免把任意二进制加载成巨大字符串。
可以采用清晰的优先级:
entry,且它精确匹配 manifest 中的 Markdown;README.md(比较时可忽略大小写,但不能容忍两个大小写变体);MARKDOWN_ENTRY_AMBIGUOUS。function chooseEntryMarkdown(
markdownFiles: ImportedFile[],
requestedEntry?: string,
): ImportedFile {
if (requestedEntry !== undefined) {
const match = markdownFiles.find(
(file) => file.relativePath === requestedEntry,
);
if (match === undefined) throw new Error("MARKDOWN_ENTRY_NOT_FOUND");
return match;
}
const readmes = markdownFiles.filter(
(file) => file.relativePath.toLowerCase() === "readme.md",
);
if (readmes.length === 1) return readmes[0];
if (markdownFiles.length === 1) return markdownFiles[0];
throw new Error("MARKDOWN_ENTRY_AMBIGUOUS");
}
请求里的 entry 同样是用户输入,必须先按 ZIP 相对路径规则规范化,不能接受服务器绝对路径。
若 chapters/install.md 中写着:

资源路径应相对于当前 Markdown 所在目录解析,而不是相对于 ZIP 根目录盲目拼接:
import path from "node:path";
function resolveMarkdownResource(
markdownRelativePath: string,
resourceReference: string,
): string {
if (/^(?:[a-z]+:)?\/\//i.test(resourceReference)) {
throw new Error("EXTERNAL_RESOURCE_NOT_ALLOWED");
}
const withoutQuery = resourceReference.split(/[?#]/, 1)[0];
const decoded = decodeURIComponent(withoutQuery);
const markdownDirectory = path.posix.dirname(markdownRelativePath);
const resolved = path.posix.normalize(
path.posix.join(markdownDirectory, decoded),
);
if (
resolved === ".." ||
resolved.startsWith("../") ||
path.posix.isAbsolute(resolved)
) {
throw new Error("MARKDOWN_RESOURCE_PATH_INVALID");
}
return resolved;
}
随后必须确认 resolved 精确存在于 manifest 的资源集合中。不要把 Markdown URL 直接交给 fs.readFile()。
真实工程还应处理:
#fragment;data: URL 是否允许;对这些情况应制定合同,而不是静默猜测。
Markdown 可以承载危险链接或原始 HTML。例如:
[点击](javascript:alert(1))
<img src=x onerror=alert(1)>
推荐两层处理:
html: false;sanitize-html 白名单清洗。import MarkdownIt from "markdown-it";
import sanitizeHtml from "sanitize-html";
const markdown = new MarkdownIt({
html: false,
linkify: true,
typographer: false,
});
function renderSafeMarkdown(source: string): string {
const rendered = markdown.render(source);
return sanitizeHtml(rendered, {
allowedTags: [
"h1", "h2", "h3", "h4", "h5", "h6",
"p", "blockquote", "pre", "code", "ul", "ol", "li",
"strong", "em", "del", "a", "img", "hr", "br", "table",
"thead", "tbody", "tr", "th", "td",
],
allowedAttributes: {
a: ["href", "title"],
img: ["src", "alt", "title"],
code: ["class"],
},
allowedSchemes: ["http", "https"],
allowProtocolRelative: false,
});
}
配置必须和业务一致。如果最终 HTML 要引用包内相对图片,需要确认 sanitize-html 的 URL 策略保留经过重写的相对地址。若允许代码高亮的 class,应限制生成来源,不要开放任意 style。
sanitize-html 不是文件解压安全工具,也不会检查图片真实格式;它只负责 HTML 内容边界。
这是两个不同问题:
yauzl 在 decodeStrings: true 时,根据 ZIP 标志等信息把名称解码为字符串。通常 UTF-8 名称体验最好,旧 ZIP 也可能使用 CP437。无法稳定解码、包含替换字符或规范化后冲突的名称,建议拒绝并提示用户重新用 UTF-8 打包。
第一版合同可以只接受 UTF-8。使用 TextDecoder 的严格模式检测非法字节:
const utf8Decoder = new TextDecoder("utf-8", {
fatal: true,
});
function decodeMarkdown(buffer: Uint8Array): string {
return utf8Decoder.decode(buffer);
}
不要静默用系统默认编码读取,否则中文乱码可能进入最终 HTML,且不同服务器表现不一致。
简单的结果 ZIP 可以采用:
result.zip
├── index.html
└── assets/
└── architecture.png
在生成 HTML 前,把所有本地资源引用重写到输出结构。例如将:

改成最终 HTML 所在位置可访问的:
<img src="assets/architecture.png" alt="流程">
服务器生成的输出名称和用户上传名称应分开。若需要保留原始名称,可在 manifest 中作为显示元数据保存,但不能跳过安全校验直接用作磁盘路径。
任何阶段失败都应让整个任务失败:
import { rm } from "node:fs/promises";
async function runImport(taskDirectory: string): Promise<void> {
try {
// 安全提取、构建 manifest、转换、打包
} finally {
await rm(taskDirectory, {
recursive: true,
force: true,
});
}
}
实际 Express 流程还应处理客户端中断:
req/res close
→ 触发 AbortController
→ zipFile.close()
→ 销毁当前 Entry 流
→ archive.abort()
→ 删除临时目录
客户端断开不会自动停止 Markdown 转换、文件写入或 archiver。清理逻辑要设计成幂等:重复执行不会删除其他任务目录,也不会因为文件已经不存在而再次失败。
推荐错误码:
MARKDOWN_ENTRY_NOT_FOUND
MARKDOWN_ENTRY_AMBIGUOUS
MARKDOWN_ENCODING_INVALID
MARKDOWN_RESOURCE_NOT_FOUND
MARKDOWN_RESOURCE_PATH_INVALID
EXTERNAL_RESOURCE_NOT_ALLOWED
UNSUPPORTED_PROJECT_FILE
HTML_SANITIZATION_FAILED
ARCHIVE_CREATION_FAILED
先保持简单,不需要一开始引入复杂架构:
Controller
接收上传、设置下载响应、处理客户端断开
ZipImportService
使用 yauzl 检查并提取,返回 manifest
MarkdownService
选择入口、解析资源、渲染并清洗 HTML
ArchiveService
使用 archiver 打包 generated 目录
每层传递有类型的对象,不传整个 req、res。这样既方便测试,也避免底层转换函数意外发送 HTTP 响应。
chapters/a.md 引用 ../assets/a.png 时,资源路径应以哪里为基准解析?sanitize-html 解决的是不同问题?html: false 后为什么仍建议清洗最终 HTML?