前后端分离架构中 Helmet 到底影响谁
考虑最常见的生产结构:
浏览器
↓ HTTPS
Nginx
├─ GET /index.html → 返回前端 HTML
├─ GET /assets/app.js → 返回前端静态文件
└─ GET /api/users → 代理到 Express
关键原则是:响应头属于具体响应。谁生成或最终修改这份响应,谁就决定浏览器在处理这份内容时看到什么策略。
1. Express 的 Header 不会自动保护 Nginx 页面
假设只有 Express 使用:
app.use(helmet());
那么 /api/users 可能带 CSP、HSTS、CORP 等 Header,但 /index.html 和 /assets/app.js 是 Nginx 直接返回的,不会经过 Express,因此不会自动获得这些 Header。
最容易误解的是 CSP:
index.html 响应上的 CSP → 控制这个页面加载哪些脚本、样式、图片和 API
JSON API 响应上的 CSP → 不会反向控制已经打开的 index.html
因此,真正保护前端页面的 CSP 通常应由提供 HTML 的 Nginx、CDN 或前端托管服务设置。
2. API 响应上的 Header 仍可能有影响
不能由此得出“API 不需要 Helmet”。Express API 可能返回多种内容:
- JSON;
- HTML 错误页或 Swagger;
- 用户图片和文件预览;
- Markdown 转换后的 HTML;
- 下载附件;
- 重定向。
nosniff、CORP、下载相关 Header 和正确 MIME 类型会影响这些响应。API 若返回可直接打开的 HTML,CSP、frame 和 opener 策略也会直接影响该文档。
最佳实践是按响应内容和用途配置,而不是仅按“前端服务/后端服务”的名称判断。
3. 同域路径代理通常不需要 CORS
如果浏览器访问:
https://www.example.com/
https://www.example.com/api/users
协议、主机名和端口都相同,浏览器认为同源。即使 Nginx 在内部把 /api 转发到 127.0.0.1:8080,内部代理地址也不会改变浏览器看到的 Origin,通常无需 CORS。
如果是:
https://www.example.com/
https://api.example.com/users
主机名不同,属于跨源,需要 API 端正确返回 CORS Header。两个域名属于同一家公司或可能是 same-site,并不能免除 CORS。
4. 页面 CSP 与 API CORS需要同时允许
跨子域调用成功需要至少经过两类检查:
www.example.com 的 HTML CSP
connect-src 是否允许 https://api.example.com
↓
api.example.com 的 CORS
是否允许 Origin: https://www.example.com
任何一层拒绝,前端 fetch 都可能失败。CSP 错误通常在 Console 中明确提到 connect-src,CORS 错误则会提到 Access-Control-Allow-Origin 等字段。
5. 推荐的职责划分
| 内容/策略 | 推荐责任方 | 原因 |
|---|---|---|
| HTML 的 CSP | 提供 HTML 的 Nginx/前端托管 | 它最了解构建资源来源 |
| 静态资源缓存和 MIME | Nginx | 它直接提供资源 |
| HSTS | 最外层 HTTPS Nginx | 统一覆盖公网响应 |
| API CORS | Express | 可结合路由、环境白名单和认证方式 |
API JSON 的 nosniff |
Express 或统一 Nginx | 保证最终响应存在且不冲突 |
| API 文件的 CORP | Express | 它了解文件是否允许跨源使用 |
| iframe 策略 | 返回 HTML 的服务 | 只有文档提供方知道嵌入需求 |
这不是绝对规定。大型平台可能由边缘网关统一治理,关键是保持单一、可追踪的责任来源。
6. Nginx 与 Express 重复设置的风险
假设 Express 返回:
Access-Control-Allow-Origin: https://www.example.com
Nginx 又添加:
Access-Control-Allow-Origin: *
最终出现两个值,浏览器很可能拒绝响应。安全 Header 也可能外层覆盖内层,使本机 curl 8080 正常而公网域名失败。
排查时同时比较:
curl -i http://127.0.0.1:8080/api/users \
-H "Origin: https://www.example.com"
curl -i https://www.example.com/api/users \
-H "Origin: https://www.example.com"
两份结果的差异通常来自 Nginx、CDN、HTTPS 或不同路由配置。
7. Markdown HTML 预览场景
当前工程存在 Markdown ZIP 转 HTML 的业务,可以把它分为两种响应:
下载 ZIP
附件下载通常不需要像页面一样加载脚本。重点是:
- 正确的
Content-Type; - 安全的
Content-Disposition; - 跨域时用 CORS 暴露
Content-Disposition,让前端读取文件名; - 对上传内容做路径、大小、类型和资源限制。
在线预览 HTML
用户上传或转换出的 HTML 属于不可信内容。仅靠 Helmet 默认值不够,应该优先考虑:
- 彻底清理危险 HTML;
- 不允许脚本,使用严格 CSP;
- 与主站使用隔离 Origin;
- 限制 iframe 和导航能力;
- 谨慎处理外部图片、样式和链接。
若业务要求运行用户脚本,风险模型会完全改变,应使用强隔离方案,而不是简单给 CSP 增加 'unsafe-inline'。
8. 如何防止上线才出问题
8.1 让预发布环境接近生产
尽量具备:
- HTTPS;
- 同样的 Nginx 代理路径;
- 与生产一致的“同域或跨子域”结构;
- CSP、CORP、COOP 等真实策略;
- 相同类型的 CDN、对象存储和第三方登录回调。
8.2 检查最终公网响应
不能只访问 Express 的 8080 端口。至少检查页面、API 成功/失败、文件、OPTIONS:
curl -i https://www.example.com/
curl -i https://www.example.com/api/users
curl -i -X OPTIONS https://www.example.com/api/users \
-H "Origin: https://frontend.example.net" \
-H "Access-Control-Request-Method: POST"
8.3 使用真实浏览器回归
curl 不执行 CSP、CORP 和 CORS,因此最终还要测试:
- 首屏和异步路由;
- 登录弹窗、支付和
window.open(); - API 成功、401、404、500;
- 图片、字体、下载和预览;
- WebSocket、SSE;
- 浏览器 Console 中的 CSP/CORS 报告。
8.4 逐步收紧
CSP 可以先用 Report-Only 收集违规,再逐步阻断。上线时应具备配置回滚手段,但不要把“临时关闭所有安全头”当作长期修复。
9. 一个决策流程
碰到某个 Header 是否需要调整时,依次问:
- 它出现在哪个响应:HTML、JSON、图片还是下载?
- 最终是谁返回:Nginx、Express、CDN 还是对象存储?
- 浏览器用什么方式消费:文档、fetch、iframe 还是资源标签?
- 是同源、same-site 还是完全跨站?
- 放宽后会增加什么风险,能否只针对一个路由或来源调整?
回答完这些问题,配置通常会比“全局关闭 Helmet”或“CORS 全部设为 *”更可靠。