实战三:文件、下载与 HTML 预览
JSON API 受到 Helmet 的影响通常不明显,但图片、下载文件和 HTML 预览同时涉及内容解释、跨源读取和页面资源加载,是 Helmet 与 CORS 最容易交叉的地方。
1. 下载与在线预览是两种响应
下载通常使用:
Content-Disposition: attachment; filename="report.zip"
在线展示通常使用:
Content-Disposition: inline
Content-Type: text/html; charset=utf-8
res.download() 负责读取并发送文件,res.attachment() 只设置附件 Header;后者仍需调用 sendFile()、流式传输或 send()。
app.get("/api/exports/:id/download", (req, res, next) => {
const absolutePath = resolveExportPath(req.params.id);
res.download(absolutePath, "项目文档.zip", (error) => {
if (error) next(error);
});
});
下载接口仍要防止路径穿越:不能把路由参数直接拼接成任意文件路径,应先用数据库 ID 或服务端映射找到已授权的绝对路径。
2. 跨源读取下载文件名
浏览器默认不会把所有响应头暴露给 JavaScript。跨源下载时:
app.use(
cors({
origin: "https://www.example.com",
exposedHeaders: ["Content-Disposition", "X-Request-Id"],
}),
);
前端才能执行:
const response = await fetch("https://api.example.com/api/exports/42/download");
const disposition = response.headers.get("content-disposition");
const file = await response.blob();
Access-Control-Expose-Headers 只决定脚本能否读取 Header,不决定浏览器是否能接收响应体,也不负责解析 Unicode 文件名。服务端生成的 Content-Disposition 通常会同时考虑 ASCII 回退名和 RFC 5987 的 filename*。
curl -i https://api.example.com/api/exports/42/download \
-H "Origin: https://www.example.com"
3. HTML 预览为什么风险更高
上传 ZIP 后生成 HTML 并在线预览,意味着浏览器可能执行其中的脚本、加载远程资源或发送请求。如果内容来自不可信用户,仅靠 Helmet 不足以安全展示。
建议按风险依次考虑:
- 对 Markdown 生成的 HTML 做严格清洗,移除危险标签、属性和 URL 协议。
- 默认禁用用户提供的脚本。
- 使用独立预览域名,与主站登录 Cookie 隔离。
- Cookie 不要设置为可覆盖预览域的宽泛 Domain。
- 给预览 HTML 设置严格 CSP。
- 如使用 iframe,结合
sandbox限制能力。
示例策略需要按真实资源调整:
app.get("/preview/:id", (_req, res) => {
res.setHeader(
"Content-Security-Policy",
[
"default-src 'none'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data:",
"font-src 'self'",
"script-src 'none'",
"connect-src 'none'",
"base-uri 'none'",
"form-action 'none'",
"frame-ancestors 'self'",
].join("; "),
);
res.type("html").send(renderedHtml);
});
这里 script-src 'none' 会使代码高亮库的客户端脚本无法运行。如果高亮在服务端生成 <span class="...">,通常只需允许对应 CSS。不要为了修复显示问题直接放宽到任意脚本源。
4. CORP、CORS 与 CSP 的不同判断点
- CORS:跨源 JavaScript 是否可以读取响应。
- CORP:资源是否允许被其他源的页面加载。
- CSP:当前页面允许从哪些位置加载或连接资源。
因此可能出现:API 的 CORS 已允许,但预览页面的 CSP connect-src 没允许 API;也可能图片服务允许 CORS,但 Cross-Origin-Resource-Policy: same-origin 仍阻止嵌入。
跨源公共图片可按实际需要调整 Helmet 的 CORP:
app.use(
"/public-images",
helmet.crossOriginResourcePolicy({ policy: "cross-origin" }),
);
这不是通用默认值。只有确认资源就是公开并允许跨源嵌入时才放宽。
5. Markdown ZIP 业务的职责建议
POST /api/markdown-exports 上传 ZIP,返回 JSON
GET /api/markdown-exports/:id 查询状态,返回 JSON
GET /api/.../:id/download 下载 ZIP
GET /preview/:id/index.html 在线预览 HTML
- 上传和状态 API:常规 Helmet + 精确 CORS。
- ZIP 下载:设置正确 MIME、附件名并暴露
Content-Disposition。 - HTML 预览:独立 CSP,最好放在独立域名。
- 预览静态资源:显式处理路径、Content-Type、缓存和 CORP。
- 下载 ZIP 不会执行其中 HTML,因此一般不需要为 ZIP 响应配置页面 CSP。
6. 验证命令
仅看预览 Header:
curl -I https://preview.example.com/preview/42/index.html
查看下载响应:
curl -D - https://api.example.com/api/markdown-exports/42/download \
-H "Origin: https://www.example.com" \
--output result.zip
-D - 把响应头打印到终端,而 --output result.zip 只把响应体写入 ZIP;不要把 -i 与 --output 这样组合,否则响应头也会写进输出文件并破坏 ZIP。只检查 Header 时也可以使用 -I。浏览器中还应检查 CSP Console 报错和每个静态资源的 Content-Type。