拿到一个 Nginx 站点的第一件事我会习惯性地先看一眼它的响应头。这不算强迫症而是成本最低的安全体检方式——不用改一行业务代码只要在配置里补上几个字段站点就能直接挡掉一批最常见的 Web 攻击。很多人在做 Nginx 安全防护时第一反应是调超时、封 IP、上 WAF却经常把响应头这个“最后一公里”给漏掉。这篇指南解决的就是这类问题当你把站点部署在 Nginx 上想做基础安全加固却不太清楚哪些响应头该加、Nginx 里怎么加、加了之后会不会把业务弄挂下面这些内容就是给你准备的。我会把 X-Frame-Options、Content-Security-Policy、HSTS 这几类关键响应头逐个拆开讲附上可以直接复制的配置也会把我在线上踩过的坑一起写出来。适合刚接手站点运维的开发者也适合想给现有业务补安全基线的团队。1. 为什么响应头会成为安全防线中的关键环节1.1 “一行配置就能挡住”的攻击复盘以前接过一个应急处理的活儿某公司的一个活动 H5 页面在推广期间被第三方站点用 iframe 嵌进了一个仿冒页面里上层覆盖透明按钮诱导用户点击。用户以为自己点的是活动页上的按钮实际触发的是第三方站点预设的跳转和授权操作最后出现了一批账号异常绑定的投诉。复盘原因时发现这个页面几乎没有做任何基础防护最致命的就是响应头里没有 X-Frame-Options导致页面可以被任意域名嵌入。这种问题被安全平台扫描出来后修复方式简单到让人有点恍惚——在 Nginx 配置里加一行add_header X-Frame-Options SAMEORIGIN always;然后 reload 一下攻击面就关掉了。整个过程不超过五分钟。类似这种“配置缺失型”漏洞在响应头领域非常常见危害不一定小但修复成本往往低到可以忽略关键是你得知道该在配置里放什么。1.2 响应头防护的底层逻辑把最后一道防线交给浏览器服务端与浏览器之间的通信除了页面内容本身还有一堆元信息也就是响应头。浏览器拿到 HTML、JS、CSS 之后怎么执行、能不能被嵌入、能不能在 HTTPS 下降级、跨域请求怎么带身份信息这些决策很大程度上都由响应头来驱动。换句话说当恶意脚本已经到达浏览器时响应头是阻止它造成更大破坏的最后机会。X-Frame-Options 对抗的是点击劫持Content-Security-Policy 限制的是脚本来源和加载范围X-Content-Type-Options 防止浏览器把上传文件当 HTML 解析HSTS 强制浏览器走 HTTPS 防降级攻击。每个头部对应一条攻击链路。把这一层配置补上相当于在纵深防御体系里补上了最靠近用户的一端。从成本收益看响应头加固的性价比非常高。它不引入额外组件不改变业务逻辑不增加请求往返基本只是改几行文本。很多安全合规基线也把这类头部列为必检项配置好之后做等保测评或安全扫描时能省掉不少麻烦。2. 关键响应头逐个拆解功能、取值与适用边界2.1 反点击劫持X-Frame-Options 与 frame-ancestorsX-Frame-Options 是最早用来防点击劫持的头部取值只有三个DENY任何页面都不能把当前页面嵌入 iframe。SAMEORIGIN只允许同源页面嵌入跨域页面一律拦截。ALLOW-FROM uri允许指定来源嵌入但这个取值兼容性一般现在基本不建议用了。绝大多数业务用SAMEORIGIN就够。如果页面确实不需要被任何 iframe 嵌入直接用DENY更省心。在 Nginx 里写法是add_header X-Frame-Options SAMEORIGIN always;需要注意现代浏览器对 X-Frame-Options 的支持虽然普遍但更推荐的做法是同时在新版浏览器里用 CSP 的 frame-ancestors 指令来做更细粒度的控制。frame-ancestors 可以指定多个允许来源比如add_header Content-Security-Policy frame-ancestors self https://trusted.example.com always;如果两个头部都配了浏览器会优先遵循 CSP 的 frame-ancestorsX-Frame-Options 会被忽略。所以配置时要保证两者逻辑一致否则可能会出现“这边说能嵌、那边说不能嵌”的混乱局面。2.2 防MIME嗅探与旧式XSS过滤两个容易忽略的响应头X-Content-Type-Options 的取值只有一个nosniff。它的作用是禁止浏览器去猜测资源的 MIME 类型。举个例子攻击者上传了一个内容为 HTML 的文件但接口返回的 Content-Type 是 image/png如果浏览器开启了 MIME 嗅探它可能会把这个文件当页面渲染于是 XSS 就发生了。加上nosniff之后浏览器严格遵守声明的类型不再自作主张。配置写法add_header X-Content-Type-Options nosniff always;另一个头部 X-XSS-Protection 的情况有点微妙。这个头原本是让浏览器启用内置的 XSS 过滤器常见取值是1; modeblock即检测到反射型 XSS 时直接阻止页面渲染。问题是现代主流浏览器已经陆续移除了这个内置过滤器这个头在新版 Chrome 和 Edge 里基本不起作用了。但很多安全扫描平台和合规基线仍然把它列为检查项。我个人的处理方式是如果站点没有出现兼容性问题保留配置也无妨但如果你的前端需要在内联脚本场景下工作并且浏览器有兼容问题可以直接关掉或者省略。真正的 XSS 防护主力还得靠 CSP。add_header X-XSS-Protection 1; modeblock always;2.3 内容白名单Content-Security-Policy 的务实配置法Content-Security-Policy也就是 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; connect-src self; base-uri self; form-action self; frame-ancestors self; object-src none always;逐个说一下这些指令default-src self所有未单独指定的资源默认只允许同源。script-src self unsafe-inline unsafe-eval脚本默认只允许同源加载同时放行了内联脚本和 eval。这个放宽是很多老站点的无奈之举如果前端有 webpack 打包、代码没有完全处理内联脚本去掉这两个会直接白屏。style-src self unsafe-inline样式同理很多框架还是会动态插入 style 标签。img-src self data:允许同源图片和 data URI 图片业务里常见的 base64 图标就靠这个放行。font-src self字体一般只从同源加载如果有 iconfont CDN 需要额外加域名。connect-src selfXHR、fetch、WebSocket 这些连接默认只允许同源。这里最常见的问题是第三方统计和日志上报。base-uri self限制base标签篡改页面基础路径。form-action self表单只能提交到同源地址。frame-ancestors self这里是 CSP 里控制 iframe 嵌入的指令和 X-Frame-Options 重叠。object-src none禁止加载 flash、Java applet 这类插件早该关掉了。如果直接把这份配置扔到生产环境大概率会有功能悄悄挂掉。所以我的习惯是先上一份只告警不拦截的版本也就是Content-Security-Policy-Report-Only跑上一到两周把浏览器控制台里报出来的违规项都收集齐再逐步收紧到真实策略。这个灰度思路在后面会专门展开。2.4 强制HTTPSStrict-Transport-Security 的开启条件与风险HSTSStrict-Transport-Security的作用是告诉浏览器在指定时间内这个站点只能通过 HTTPS 访问即使用户手动输入 HTTP 地址浏览器也会自动升级为 HTTPS从而防住中间人降级攻击。标准写法是add_header Strict-Transport-Security max-age31536000; includeSubDomains always;这里有几个必须想清楚再动手的条件。前提是站点已经完全支持 HTTPS并且没有混用 HTTP 资源。如果页面里还有通过 HTTP 加载的图片、接口或脚本开启 HSTS 之后浏览器会强制拦截轻则资源加载失败重则整站报错。includeSubDomains要尤其谨慎。它表示所有子域名同样受 HSTS 保护。如果你有一个子域名还在跑 HTTP或者某个子域名根本没有配证书那用户访问这个子域名时会直接被浏览器拦下页面根本打不开。这个错误我在线上见过太多次了。另一个容易被忽略的是max-age的回退问题。浏览器收到 HSTS 后会在本地记住这个策略时间长度由max-age决定。一旦你发现配置有误想撤回并不能通过删除响应头来立刻生效用户只能等 max-age 到期或者手动清除浏览器站点数据。所以我建议新站点先从短时间实验开始比如max-age300确认全站 HTTPS 链路没有遗漏再逐步加长到一个年。2.5 隐私与权限收敛Referrer-Policy 和 Permissions-PolicyReferrer-Policy 控制的是浏览器在跳转时把什么样的来源信息放到 Referer 头里。很多人没注意URL 里的查询参数经常带着 token、会话 ID 这类敏感数据如果 Referer 把完整 URL 带给了第三方等于顺手把凭据也送了一份出去。推荐取值是strict-origin-when-cross-origin。它表示同源请求携带完整 URL跨域请求只带源协议域名端口并且 HTTPS 页面跳 HTTP 时不携带任何 Referer。这个取值兼顾了业务需要和隐私保护是目前最均衡的方案。add_header Referrer-Policy strict-origin-when-cross-origin always;Permissions-Policy 则是新一代的权限控制头部。它可以统一管理摄像头、麦克风、地理位置、支付、USB 等浏览器敏感能力的可用范围。如果你是一个内容站根本用不到摄像头和麦克风直接在响应头里全部禁掉add_header Permissions-Policy camera(), microphone(), geolocation(), payment(), usb() always;如果业务确实需要某些能力比如地图页面要用地理位置就把对应项放开为当前源geolocation(self)。需要支持第三方应用的场景还可以指定域名比如camera(self https://video.example.com)。顺便提一句Server 头也会暴露信息。Nginx 默认会在 Server 头里带上版本号攻击者可以根据版本号搜索已知漏洞。建议在配置里加上server_tokens off;让响应头里只保留nginx不带版本。这个虽然不属于严格意义上的安全响应头但和响应头加固是同一件事。3. Nginx 配置落地从单行指令到成套安全基线3.1 最容易踩的坑add_header 的继承规则Nginx 里给响应头加自定义头部用的是add_header指令。但这条指令有个继承规则很多人不知道导致配置“看起来加了实际没生效”。规则是这样的如果当前层级比如某个 location定义了任何一条add_header那么这个层级就不会继承上级比如 server 层或 http 层的所有add_header指令。看个错误示范server { add_header X-Frame-Options SAMEORIGIN always; add_header X-Content-Type-Options nosniff always; location /api { add_header X-XSS-Protection 1; modeblock always; } }这个配置下/api路径的所有响应只会带 X-XSS-Protection而 X-Frame-Options 和 X-Content-Type-Options 都会丢失。因为 location 里出现了一条 add_header整个继承就断了。这是线上安全头缺失最常见的原因之一。正确做法是要么在 location 里把公共头全部写一遍要么把公共安全头单独抽成一个 include 文件在需要自定义响应头的 location 里也 include 进去。# security-headers.conf add_header X-Frame-Options SAMEORIGIN always; add_header X-Content-Type-Options nosniff always; add_header X-XSS-Protection 1; modeblock always; add_header Referrer-Policy strict-origin-when-cross-origin always; add_header Strict-Transport-Security max-age31536000; includeSubDomains always; add_header Content-Security-Policy default-src self; ... always;各个层级按需 include就不会出现“某个路径少几个头”的诡异问题。3.2 一套可直接复用的安全响应头模板下面这份模板是我在多个站点验证过的可以直接抄走按业务情况微调。# /etc/nginx/conf.d/security-headers.conf # 统一放安全响应头配置在 server 块里 include # 防点击劫持页面不允许被其他域嵌入 iframe add_header X-Frame-Options SAMEORIGIN always; # 防 MIME 嗅探浏览器严格按 Content-Type 处理资源 add_header X-Content-Type-Options nosniff always; # 旧式 XSS 过滤保留以兼容安全扫描基线 add_header X-XSS-Protection 1; modeblock always; # 跨域跳转时只携带 origin避免完整 URL 泄漏参数 add_header Referrer-Policy strict-origin-when-cross-origin always; # 强制 HTTPS配置前确认全站已经 HTTPS 化 add_header Strict-Transport-Security max-age31536000; includeSubDomains always; # 内容安全策略按实际业务调整白名单 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; connect-src self; base-uri self; form-action self; frame-ancestors self; object-src none always; # 关闭不需要的浏览器能力 add_header Permissions-Policy camera(), microphone(), geolocation(), payment(), usb() always;在主配置的 server 块里引用server { listen 443 ssl http2; server_name your-domain.com; include /etc/nginx/conf.d/security-headers.conf; # 其他业务配置... }每个头部后面我都加了always关键字。这个参数很关键——不加的话Nginx 只会在 200、201、301、302 这类特定状态码下输出自定义响应头像 404、500 这些错误页面就都不带了。加上always所有响应才会统一携带安全头。攻击面往往就藏在这些容易被忽略的错误页里。3.3 配置验证curl 与浏览器调试检查配置完要验证不能 reload 完就以为万事大吉。最常用的是 curl 查看响应头curl -sI https://your-domain.com --max-time 5正常配置后预期输出里应该能看到类似这样的字段HTTP/2 200 server: nginx content-type: text/html; charsetutf-8 x-frame-options: SAMEORIGIN x-content-type-options: nosniff x-xss-protection: 1; modeblock content-security-policy: default-src self; script-src self unsafe-inline unsafe-eval; ... strict-transport-security: max-age31536000; includeSubDomains referrer-policy: strict-origin-when-cross-origin permissions-policy: camera(), microphone(), geolocation(), payment(), usb()还要按路径验证尤其是带 query 参数的动态接口和静态资源路径避免某个 location 因为 add_header 继承断链而漏掉。浏览器端的验证更直观打开 DevTools 切到 Network点开任意一条请求在 Headers 面板里就能看到响应头列表。也可以直接在 Console 里执行document.querySelectorAll(iframe)来确认当前页面是否允许被嵌入。想测试 X-Frame-Options 是否生效就自己写一个临时 HTML 用 iframe 嵌入目标页面打开看是否被拦截测完删掉就行。另外建议用公开的响应头检测平台跑一轮这些平台通常会模拟较新的浏览器环境还能对头部配置做合规评级。需要注意如果站点前面挂了 CDN检测结果反映的是 CDN 节点最终返回的响应头。你需要在源站和 CDN 两层都做验证有时候源站配置没问题但 CDN 没有透传这些自定义头访问端就仍然拿不到。这种情况要在 CDN 的响应头配置里同样加上。3.4 上线前的影响面评估与灰度思路改动响应头虽然是配置层面的调整但上线前的影响面评估不能省。最典型的就是 CSP。很多站点页面里嵌了第三方统计脚本、在线客服、地图 SDK、支付回调甚至还有灰度实验用的动态脚本。这些第三方域名如果不在白名单里功能会直接静默失效——脚本被拦接口连不上页面看起来正常但统计数据和客服入口全部挂掉。所以我的习惯是分两步走。第一步先用只报告不拦截的策略观察一段时间add_header Content-Security-Policy-Report-Only default-src self; script-src self unsafe-inline unsafe-eval; ... always;浏览器控制台会把所有违规加载项列在告警信息里。让开发和测试人员正常使用几天把所有告警整理出来逐一判断哪些是业务必需、哪些可以去除再去调整真实策略。第二步对 Referrer-Policy 和 HSTS 这类影响面大的头部先跟业务方确认有没有依赖 Referer 做来源判断的逻辑。比如有些后台系统靠 Referer 校验跨域跳转来源改成 strict-origin 之后来源信息变少可能影响接口鉴别。虽然这种情况更应该靠带签名参数来防但短期内得留出适配时间。灰度维度上可以先在一台测试机器、一个测试域名上验证再逐步推广到生产集群。安全头不像业务功能有明确的线上告警出了问题往往是用户侧先发现灰度可以帮你把影响范围缩小到可控程度。4. 实战踩坑记录从配置到线上稳定运行的最后一公里4.1 跨域接口下响应头的叠加与冲突站点启用子域名后经常要处理跨域问题。Nginx 里常见的跨域配置是在 location 里加 Access-Control-Allow-Origin。这里有几个坑。第一个坑是通配符与凭据冲突。如果接口需要携带 Cookie 做身份认证那Access-Control-Allow-Origin就不能写成*必须回显具体来源域名同时加上Access-Control-Allow-Credentials: true。否则浏览器直接拦截跨域响应前端调接口就报 CORS 错误。更危险的是如果你用了*并且接口不做其他校验任何网站都能向你的接口发起跨域读取请求等于把数据裸露在公网上。第二个坑是预检请求。非简单请求会先发一次 OPTIONS 预检Nginx 里必须对 OPTIONS 请求也返回正确的跨域头。很多人只在业务接口的 location 里配了 CORS 头预检请求走的是另一条规则结果前端控制台一直在报 CORS 错误排查半天才发现是 204 响应里没有带 Access-Control-Allow-Headers。跨域配置和前面那些安全响应头是叠加关系不是二选一。add_header 在同一个 location 里可以写很多条互不影响。但要注意别把 CORS 头放在公共安全头文件里全局生效否则一些不需要跨域的路径也打开跨域权限扩大攻击面了。4.2 HSTS开弓没有回头箭一次回退教训之前帮某团队处理过一个线上事故他们在主站开启了 HSTS并且带了includeSubDomains。原本以为站点的子域名都已经是 HTTPS 了结果漏了一个老旧的图片子域名机器上只跑了 HTTP 服务。HSTS 生效后用户浏览器收到主站的响应头会把这个策略应用到所有子域名上访问图片子域名时直接被浏览器拦截无法降级到 HTTP。更麻烦的是想回退并不是删掉配置就行。用户的浏览器里已经记住了“这个域名必须 HTTPS”的策略在 max-age 过期之前你就算把 HSTS 头彻底删掉用户端依然会被强制拦截。这个团队最后只能紧急给那个子域名补证书、配置 HTTPS才把访问恢复。这个案例给我的教训是HSTS 是这几个响应头里唯一“有权长期记住行为”的头部配置前必须把站点的所有子域名、所有 HTTP 资源路径摸清楚。想快速试水就用短 max-age确认没问题再拉长。preload参数更要慎重一旦被浏览器预加载列表收录想撤下来需要走非常漫长的流程基本等于不可逆。4.3 CSP上线白屏先把第三方依赖梳理清楚另一个踩坑案例是 CSP 上线导致页面功能大面积失效。某开发者的站点接入了第三方在线客服、一个图表库和一个外部字体服务加完 CSP 之后页面主体还能打开但客服按钮不见了、图表不渲染、图标全部变成方框。控制台里全都是 “Refused to connect” 和 “Refused to load” 之类的告警。表面原因是查漏了第三方域名根因是上线前没有做依赖梳理。现在很多站点表面上是自家页面实际运行时却依赖大量第三方资源。CSP 白名单必须把这些依赖一个一个对清楚。常见需要放行的资源类型包括脚本统计脚本、在线客服脚本、灰度发布脚本、第三方登录 SDK。样式部分富文本编辑器会动态加载外部样式。字体iconfont 和多语言字体服务。连接数据上报、日志收集、WebSocket 服务。图片第三方图片 CDN、社交分享缩略图。排查方法也很简单先上 Report-Only 模式收集告警浏览器控制台里每一条违规记录都指明了资源和指令类型照着往白名单里补即可。补的时候别图省事把整个域名放进default-src按script-src、img-src、connect-src分开加避免把某个域名的所有能力都放开。4.4 常见问题速查表现象可能原因处理方式响应头里看不到安全头location 里出现 add_header中断了上级继承把公共头单独抽成文件在各 location include404、500 页面没有安全头add_header 没加 always 关键字统一在末尾追加 alwaysHSTS 开启后子域名无法访问includeSubDomains 覆盖了仍走 HTTP 的子域先补子域 HTTPS或用短 max-age 验证后再拉长CSP 上线后图表/客服/统计失效白名单缺第三方资源域名先用 Content-Security-Policy-Report-Only 收集告警再放行CDN 环境安全头不见了源站配置正常但 CDN 未透传在 CDN 响应头配置里同步加上跨域接口报 CORS 错误允许来源配置错误或预检请求未覆盖按具体来源回显 Origin并处理 OPTIONS 预检页面可以被 iframe 嵌入缺少 X-Frame-Options 或 CSP frame-ancestors加上对应头部二选一或同时配置保持一致最后再分享一个我自己的习惯安全响应头这类事情最怕改完之后忘了回归。我的做法是把安全响应头配置单独抽成文件纳入版本管理每次变更都走和业务代码一样的评审和发布流程。配置上线后把 curl 检查写成一键脚本隔几天跑一次顺便确认新增的路径也没有漏掉这些头。安全这件事往往不是靠某个大动作一劳永逸而是靠这一行行不起眼的响应头字段把攻击面一点一点收窄。