实战三:文件、下载与 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 不足以安全展示。

建议按风险依次考虑:

  1. 对 Markdown 生成的 HTML 做严格清洗,移除危险标签、属性和 URL 协议。
  2. 默认禁用用户提供的脚本。
  3. 使用独立预览域名,与主站登录 Cookie 隔离。
  4. Cookie 不要设置为可覆盖预览域的宽泛 Domain。
  5. 给预览 HTML 设置严格 CSP。
  6. 如使用 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 的不同判断点

因此可能出现: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

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。