1. 为什么要在 Nginx 层配置安全头
做 Web 开发或者运维的,多多少少都听说过“响应头少了几个字段被安全审计打了回票”这种事。安全头说白了就是服务器在 HTTP 响应里附加的一组头部字段,告诉浏览器“这个页面该怎么管自己”。浏览器拿到这些指令之后,会主动帮你拦截掉一部分跨站脚本、点击劫持、MIME 类型嗅探这类常见攻击。
那为什么非要在 Nginx 这一层加,而不是在应用框架里加?两个原因:一是省事,Nginx 统一管控,所有后端应用不管你是 PHP、Java、Node 还是 Python,只要走了这一层代理,头部全部补齐,不用每个服务单独写一遍;二是稳定,安全头是应用入口处的一道闸门,就算后端代码有历史包袱改不动,也能在接入层把底线守住。
我见过不少团队,框架里写了安全头的中间件,结果某个老接口绕过了中间件直接返回响应,安全头就丢了。放到 Nginx 上,只要配置不出错,所有经过代理的响应都会带上,除非后端主动覆盖。这个覆盖面就是 Nginx 层的核心价值。
适合谁来参考?两类人:一类是刚接手站点安全改造的运维或全栈开发,需要快速给线上站点补齐安全配置;另一类是写后端但对 HTTP 细节不太熟的开发者,理解这些头的作用之后,能在接口设计阶段就把安全因素考虑进去,省得后面返工。
需要说明的是,Nginx 安全头不是“配了就绝对安全”,它防的是浏览器侧的常见攻击路径,服务端自身的漏洞、业务逻辑问题该修还得修。这个定位先摆清楚,后面做配置的时候才不会产生“我全部配了怎么还被通报”的误解。
2. 常用安全头逐一拆解
2.1 X-Frame-Options:防点击劫持
点击劫持是攻击者用透明的 iframe 把你的页面嵌到恶意网站上,用户以为在点别人页面的按钮,实际上点的是你的页面。X-Frame-Options 就是告诉浏览器“我这个页面允不允许被 iframe 嵌入”。
配置方式:
add_header X-Frame-Options "SAMEORIGIN" always;三个取值:DENY 表示完全禁止被嵌入,SAMEORIGIN 表示同源页面可以嵌,ALLOW-FROM 指定某个域名可嵌。大多数场景 SAMEORIGIN 就够了,比如你自己的站点页面里用 iframe 嵌自己站内的内容。用 DENY 的话,连自己站内 embed 都会被拦,有时候反而误伤功能。
实际配置的时候有个注意点:如果用了 Content-Security-Policy 的 frame-ancestors 指令,它和 X-Frame-Options 会同时生效,浏览器会取更严格的那个。CSP 的 frame-ancestors 能支持多个域名白名单,比 X-Frame-Options 灵活,但兼容旧浏览器还是得靠 X-Frame-Options 兜底。
2.2 X-Content-Type-Options:禁止 MIME 类型嗅探
这是我最喜欢的一个头,因为配置无脑、收益直接。老版本浏览器在响应里没写 Content-Type 或者写得比较含糊的时候,会自己去“猜”文件类型,比如一个文本文件里嵌了 HTML 标签,浏览器可能把它当网页渲染,这就是 MIME 嗅探。如果这个文件可以被上传(比如论坛头像),攻击者就能构造一个伪装成图片的 HTML 文件,诱导浏览器执行脚本。
配置方式:
add_header X-Content-Type-Options "nosniff" always;加了 nosniff 之后,浏览器严格按 Content-Type 处理响应,CSS 必须以 text/css 返回、JS 必须以 JavaScript MIME 类型返回,否则直接拒绝执行。这一个头能把上传绕过类的 XSS 挡掉一大半,而且没有任何副作用,属于必配项。
2.3 Referrer-Policy:控制 Referrer 信息泄露
这个头的实际影响被很多人低估了。页面跳转、加载外部资源的时候,浏览器会自动带上 Referrer 头告诉对方“我从哪来”,这个字段里往往带着 URL 参数。如果 URL 参数里有 token、session id、用户标识这类信息,就等于把敏感数据泄露给了第三方站点。
我处理过一个线上事故:站点的分享链接里带了用户邀请码,用户点击跳转到外部落地页,Referrer 把完整带参 URL 传了出去。后面在 Nginx 上加了 Referrer-Policy 才控制住。
推荐配置:
add_header Referrer-Policy "strict-origin-when-cross-origin" always;这个取值的意思:同源请求发送完整 URL,跨源请求只发送 origin(协议+域名+端口),HTTPS 降级到 HTTP 时不发送。这是目前浏览器默认行为,也是安全性和功能之间的最佳平衡。如果你后端需要拿完整来源做统计,可以酌情调整,但原则上不要用 unsafe-url。
2.4 Permissions-Policy:限制浏览器特性的启用
这是个较新的头,作用是告诉浏览器页面能用哪些功能特性,比如摄像头、麦克风、地理位置、全屏、弹窗等。即使攻击者注入了脚本,没有权限也调不动这些敏感接口,相当于给浏览器的功能权限加了一道锁。
配置方式:
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;括号为空表示完全禁止,也可以写成 self 表示仅同源可用,或者指定可信域名。实际操作中建议按业务需求来,别一刀切全禁了,否则后面接入视频通话、地图定位功能的时候还得回来改配置。
3. 两个重点头:CSP 与 HSTS
3.1 CSP:内容安全策略的配置思路
Content-Security-Policy 是最有分量但也是最有坑的安全头。它告诉浏览器页面允许加载哪些来源的资源,包括脚本、样式、图片、字体、连接、frame 等等。配置得当的话,XSS 注入的脚本根本执行不了,因为来源不在白名单里。
一个常见的基础配置:
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'" always;拆开看:
- default-src 'self':所有资源默认只能从同源加载,这是兜底规则。
- script-src:脚本来源,'self' + 内联 + eval。注意了,'unsafe-inline' 和 'unsafe-eval' 是放宽项,如果你的站点没有用内联脚本,尽量去掉。
- style-src:样式来源。很多前端框架依赖内联样式,先留着 'unsafe-inline' 跑通再说。
- img-src data::允许 data URI 图片,这是给 base64 小图标留的口子。
- object-src 'none':禁止 Flash、Java Applet 这类老古董插件加载。
- frame-ancestors 'self':只允许同源 iframe 嵌入,作用等同于 X-Frame-Options。
配置 CSP 最容易出的问题是页面功能大面积崩溃。脚本被拦、样式错乱、接口连不上,排查起来头疼。我的建议是分三步走:
第一步,先用 Content-Security-Policy-Report-Only 模式上线,只上报不拦截:
add_header Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self'";浏览器遇到违规会向后端指定的地址发报告,但不会真的拦资源。跑一两周,看上报日志里被拦的是什么资源,在白名单里逐步补。
第二步,把白名单补全,确认没有非法资源后再切强制模式。第三步,上线后持续关注浏览器控制台报错,因为前端代码更新可能引入新的第三方资源,CSP 需要跟着维护。
还有一个常见误区:不是 CSP 里写了 frame-ancestors 就能完全替代 X-Frame-Options。CSP 对老版本浏览器的覆盖不如 X-Frame-Options 全面,两个都要配。
3.2 HSTS:强制 HTTPS 访问
HTTP Strict-Transport-Security 的作用是告诉浏览器:这个域名只能走 HTTPS,不要用 HTTP 访问。配置之后浏览器会在本地记住这个规则,之后用户手动输入 http:// 开头地址,浏览器也会自动改成 HTTPS,从源头杜绝中间人劫持。
配置方式:
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;参数含义:max-age 是记住秒数,31536000 正好一年;includeSubDomains 表示子域名也强制 HTTPS;preload 是申请加入浏览器内置的 HSTS 预加载列表。
这个头的坑比较深,我来逐个说。
第一个坑:依赖 HTTPS。HSTS 只在 HTTPS 响应里生效,如果用户在第一次访问时走的是 HTTP,服务器没法下发 HSTS 头,中间人照样可以把请求劫持到恶意站点。这也是 HSTS Preload 列表存在的意义——浏览器出厂时就内置了哪些域名强制 HTTPS,不走第一次访问流程。
第二个坑:includeSubDomains 要谨慎。加了这条之后,某个子域名如果没上 HTTPS 就要出大事。线上存在裸 HTTP 的子域名,比如内部管理系统、图片服务器没配证书,浏览器也会强制用 HTTPS 访问,直接打不开。
第三个坑:preload 一旦生效很难撤回。你把域名提交到预加载列表之后,想撤销的话要等一个很长的周期,而且各浏览器策略有别。提交之前务必确认全站 HTTPS 已经完备。
第四个坑是实际运营中容易忽视的:临时证书或测试环境的 HTTPS 也会下发 HSTS。一旦 max-age 太长,浏览器记住了,你后面换回 HTTP 调试,浏览器照样强制跳 HTTPS。调试的时候可以把 max-age 设小一点,比如 3600,或者只在生产环境配置这个头。
3.3 X-XSS-Protection 的取舍
这里单独说一下 X-XSS-Protection,因为这个头比较特殊。它是老版本浏览器内置的 XSS 过滤器,配置方式:
add_header X-XSS-Protection "1; mode=block" always;但是这个过滤器本身存在被攻击者绕过的历史,而且有时候会误伤正常页面,所以新版浏览器已经逐渐移除了这项功能。当前主流观点是:配置了 CSP 之后,X-XSS-Protection 的意义已经不大,甚至在 Chrome 里已经被弃用。
我的做法是:如果是新旧兼容性要求高的项目,配上无妨,mode=block 顶多就是把页面拦截掉,不会造成功能异常;如果站点已经上了完善的 CSP,这个头可以不配或者保持默认,不必过度纠结。
4. 完整配置示例与常见误区
4.1 一份可复用的安全头配置模板
下面给出一份可直接抄的配置,放在 server 块里:
server { listen 443 ssl http2; server_name example.com; # 核心安全头 add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Content-Type-Options "nosniff" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; # CSP 按需调整 add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'" always; # 反向代理配置 location / { proxy_pass http://backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } }有几个配置上的细节我要强调一下。
第一个细节,add_header 后面的 always 参数别漏。默认情况下 Nginx 只在响应码为 200、201、204、206、301、302、303、304、307、308 时附加 add_header 定义的头部,如果响应是 404、500 这类错误码,头部就不会加上。加了 always 之后,所有响应码都会带上。安全审计的时候最容易抓到的就是没加 always 导致 404 页面没有安全头。
第二个细节,关于 add_header 继承。这里有个老坑:如果在 server 块定义了 add_header,而某一个 location 块里也写了 add_header,那么 server 块里的全部 add_header 都会失效,Nginx 不会做合并,location 里的会整体覆盖。解决方法是要么所有 location 都不写 add_header,要么把公共的安全头放到一个独立配置文件里,在每个 location 里用 include 引进来。
比如先建一个 /etc/nginx/security-headers.conf:
add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Content-Type-Options "nosniff" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; add_header Content-Security-Policy "default-src 'self'" always;然后在 server 块和 location 块里分别引入:
server { include /etc/nginx/security-headers.conf; location / { # 这里如果写了 add_header,务必也 include 一次 include /etc/nginx/security-headers.conf; proxy_pass http://backend; } }第三个细节,X-Forwarded-Proto 的配置。如果你的站点是 HTTPS 对外,但 Nginx 和后端之间是 HTTP,后端应用判断请求是不是 HTTPS 就靠这个头。不配的话,有些框架会自动生成 HTTP 链接,导致页面里混入 http 资源,浏览器控制台一片 warning,HSTS 也保护不了这些混入的请求。
4.2 验证配置是否生效
配置完成之后,先测试 Nginx 配置有没有语法错误:
nginx -t然后重载:
nginx -s reload验证响应头是否生效,可以用 curl:
curl -I https://example.com重点看返回的响应里有没有这几个头:
curl -I https://example.com | grep -i "strict-transport-security\|content-security-policy\|x-frame-options\|x-content-type-options"也可以用在线工具检测,比如安全头检测类的服务,会逐项列出评分和缺失项。这类工具测完一般会给个等级,常见的 A+ 就是 HSTS 配置正确且 CSP 足够严格。
需要特别提醒的是,如果在前端页面部署了 Service Worker 或者 CDN 缓存,响应头可能被 CDN 节点缓存下来,源站改了配置但 CDN 还是旧头。这种情况要在 CDN 侧刷新缓存或者修改 CDN 的回源 Header 设置。
4.3 配置顺序与性能考量
安全头全部加在 Nginx 层,对性能的影响几乎可以忽略不计。add_header 只是往响应里追加文本,每个响应头加起来不过几百字节,在带宽面前毫无存在感。真正需要注意的是如果站点有大量小文件请求,比如验证码图片、静态小图标,每个响应都带上 CSP 这种长头部,响应体积会变大 5% 到 10% 左右。这不是什么大问题,但如果你的业务对流量成本极其敏感,可以区分静态资源路径和动态页面路径,静态资源只配 nosniff 和 X-Frame-Options,动态接口再配完整 CSP。
5. 常见问题与排查技巧
5.1 问题速查表
| 现象 | 可能原因 | 排查方向与方法 |
|---|---|---|
| 响应头没有出现 | add_header 没加 always,或者 location 块覆盖了 server 块 | 检查 location 里是否写了其他 add_header,用 nginx -T 查看实际生效配置 |
| CSP 上线后页面样式全乱 | 内联样式被拦截,或者加载了第三方 CDN 资源 | 浏览器控制台看具体被拦的资源,将其域名或 'unsafe-inline' 加入白名单 |
| 整站打不开,提示重定向过多 | HSTS + 后端强制跳转 HTTPS 形成循环 | 检查后端是否有 HTTP 到 HTTPS 的跳转逻辑,Nginx 层做了跳转后端又跳一次 |
| 子域名突然全部无法访问 | includeSubDomains 配置但子域名没有 HTTPS | 要么给子域名补证书,要么去掉 includeSubDomains |
| 前端埋点上报失败 | connect-src 没放开对应的接口域名 | 在 CSP 的 connect-src 里加上埋点服务域名 |
| WebSocket 连接失败 | connect-src 没有允许 ws/wss 协议 | 添加 connect-src wss:// 域名,注意不是写进 script-src |
| 老浏览器 iframe 嵌入被拦 | 只配了 CSP frame-ancestors,没配 X-Frame-Options | 两个头同时配置,老浏览器认 X-Frame-Options |
| 404 页面安全头缺失 | add_header 缺省不带错误码响应 | 统一加上 always 参数 |
| 检测工具提示缺少 CSP frame-ancestors | CSP 配置里没有写 frame-ancestors | 在 CSP 里补充 frame-ancestors 'self' 或指定域名 |
| 页面加载变慢 | CSP 白名单过多或报告模式上报频繁 | 关闭 Report-Only,减少多余域名,静态资源合并 |
5.2 容易被忽视的细节
Nginx 变量和 add_header 一起用的时候要小心转义。比如动态设置 CSP 的某个值,需要在配置里写清楚变量名,变量值里如果含有引号或分号,Nginx 编译配置可能会出问题。安全头的值尽量写死,不要拼接用户输入,否则等于给自己开了一个配置注入的口子。
还有一个运营上容易踩的坑:日志里出现大量 CSP 违规上报,很多人第一反应是白名单没写全,其实有可能是浏览器插件在页面里注入了脚本。这类插件注入的资源不属于站点本身,CSP 会拦,但自己把插件域名加进白名单又适得其反,反而削弱了策略强度。这种情况下要分析上报来源,区分业务资源和插件注入。
5.3 上线前的检查清单
结合我自己的上线经验,列一份执行顺序:
- 先确认全站 HTTPS 完备,包括所有子域名和裸域,再考虑上 HSTS 带 includeSubDomains。
- 先在预发环境完整配置所有安全头,用 curl 逐个核对响应头是否存在、取值是否正确。
- 浏览器控制台逐一检查 Console 报错和 Network 里被拦截的资源。
- 检查后端应用自身是否可能产生覆盖安全头的逻辑,比如某些框架会自动输出 Vary 头或者自定义安全头。
- 配置发布之后观察线上错误日志,特别是 CSP 上报接口有没有异常波动。
- 留存配置变更记录,安全头后续需要迭代时方便比对。
6. 实操过程与配置迭代经验
6.1 一个动态接口场景下的安全头处理
真实项目里,Nginx 不一定只代理静态页面,可能还挂着一堆 API 接口。接口响应一般没有 HTML 页面那些点击劫持的问题,但接口会返回 JSON 数据,如果响应头没设置 nosniff,老浏览器有可能误把 JSON 当 HTML 解析,MIME 嗅探就会生效,数据就有被脚本执行的风险。我的习惯是给 API 路径单独配置精简版安全头:
location /api/ { add_header X-Content-Type-Options "nosniff" always; add_header X-Frame-Options "SAMEORIGIN" always; add_header Content-Type "application/json; charset=utf-8" always; proxy_pass http://backend_api; }设置 Content-Type 不是 Nginx 安全头的标准做法,但它能进一步防止浏览器猜测响应类型。这类细节在接口改动频繁的项目里尤其有用。
6.2 多域名共用一个 Nginx 时的配置组织
一个 Nginx 实例挂了十几个站点,每个站点的 CSP 策略都不一样,直接在每个 server 块里复制粘贴会带来维护噩梦。我的做法是给每个站点建一个独立的配置文件目录,比如 /etc/nginx/conf.d/security/ 下放站点名.conf,里面写该站的安全头,然后主配置里统一 include:
include /etc/nginx/conf.d/security/*.conf;这样每个站点一个文件,集中管理又互不干扰,改动某个站点的策略时不会影响其他站点。
还有一种做法是用 map 指令根据 server_name 动态生成安全头值,不过这个方式对配置语法要求更高,容易把自己绕晕,小规模站点不推荐。
6.3 配置回滚预案
安全头配置上线后如果出现大面积故障,回滚比修复更快。nginx -s reload 是平滑重载,不影响现有连接,所以回滚只需要把配置改回去再 reload 一次。但有一点要注意:HSTS 一旦被浏览器记住,即使你回滚配置,浏览器端依然强制 HTTPS,这个“记忆”不会随配置回滚而消失。所以 HSTS 的 max-age 一开始不要设置太长,确认稳定运行之后再逐步调大,是一个比较稳妥的上线节奏。
6.4 移动端场景下的 Referrer-Policy 适配
有段时间我在做 H5 页面分享功能,发现从微信内打开的页面跳转外部落地页时,Referrer 字段的格式和桌面浏览器不一样。排查后发现是微信内置浏览器对 Referrer-Policy 的支持不完全符合标准,strict-origin-when-cross-origin 在某些版本下被当成了旧值处理。处理方式是先确认目标浏览器是否支持新取值,再决定要不要降级到 no-referrer-when-downgrade。安全头配置不能只在 Chrome 里验证,移动端 WebView 的响应行为也要列入测试范围。
7. 安全头不是终点
配置完这一套安全头,站点的安全基线算是立住了。但我想多说一句,安全头防护的是浏览器侧的常见攻击路径,服务端本身的漏洞、组件漏洞、业务逻辑越权这些问题,安全头一概管不了。实际做安全建设的时候,安全头是性价比最高的起点,不是全部。
我个人踩过不少坑,最大的体会是两点:第一,任何安全头配置上线前都要在真实浏览器环境里验证效果,特别是 CSP,别盲目追求严格,页面功能崩了影响业务,再严格的策略也留不住;第二,安全头配置是一个持续维护的过程,前端引了新的第三方库、加了新的外部域名,都要同步更新策略,一年不动的配置基本等于失效。
从 Nginx 这一层入手补安全头,确实是投入产出比很高的做法。碰到过不少项目,在代码层面改安全逻辑要排期、要测试,而 Nginx 配置只需要几分钟就能补上,效果来得快。先动手把基础安全头补齐,再逐步完善 CSP 和 HSTS 的细节,这个路线走下来,站点的防护能力会有明显提升。