Nginx安全头配置实战:add_header、CSP与HSTS详解
发布时间:2026/10/16 9:45:04 锦皓数字建站

1. 这年头谁还在裸奔先说说安全头是干嘛的接手过几个项目的上线前安全检查最头疼的不是代码逻辑而是漏扫报告里那几十条黄色、红色的告警。仔细一看一半以上都是同一个问题响应头缺失。配一遍好了下个项目又原样来一遍。Nginx 安全头这件事属于那种看起来人人都会、配起来全是细节的活。你以为加几行 add_header 就完事了实际上里面有继承规则、参数含义、兼容性取舍甚至还有“配了反而更危险”的操作。先给还不熟悉的读者一句大白话解释安全头不是服务器自己防御攻击而是给浏览器下指令。浏览器本来就具备很多安全能力比如不执行不可信内容、不让页面被框架嵌入、不猜测文件类型只是默认状态下这些能力未必全开。Nginx 通过 HTTP 响应头把这些开关拨到正确位置相当于在房间门口装好门禁、烟雾报警器和监控让攻击者少几个突破口。这不需要代码评审、不需要买设备纯粹是配置层面的加固几分钟搞定性价比高得离谱。我见过不少运维和开发把安全头当成“扫描器不满意就加上”的应付项目。最典型的操作是网上抄一段头配置复制到 server 块里跑一遍检查工具看告警少了几个就算交差。这种做法最大的问题在于不知道每个头的保护范围也不清楚设置错参数会带来什么副作用。等到网站功能因为 CSP 限制而白屏或者 HSTS 配错导致用户半个小时内打不开站点才回头逐条查原因往往浪费更多时间。所以这篇东西不只是列举头名字和推荐值我会把每条头的原理、参数设计逻辑、在 Nginx 里的正确姿势以及配完之后如何验证一次性讲清楚。适合正在做站点上线前加固的运维也适合被安全检查报告追着改的开发者哪怕你是刚入门看完也能照着抄一份能用的配置。2. 常用安全头逐个拆解每条头到底在防什么2.1 X-Frame-Options拦住点击劫持的最朴素方案点击劫持的原理不复杂攻击者把自己网站的透明 iframe 覆盖在目标网页上诱导用户点击按钮实际上点到了目标网站的功能上。受害者以为自己在操作 A 网站其实已经授权、转账、发帖了。这个攻击要成立前提是目标页面允许被 iframe 嵌入。而 X-Frame-Options 就是告诉浏览器这个页面能不能放进别人的 frame 里。Nginx 里最常见的配置是add_header X-Frame-Options SAMEORIGIN always;DENY 表示任何情况下都不允许嵌入SAMEORIGIN 表示只有同源页面可以嵌入。绝大多数业务站点用 SAMEORIGIN 就够了因为你可能自己需要在同域名的页面里 iframe 嵌入某个功能模块。只有像登录页、支付回调页这种被嵌入概率低、被攻击后果严重的页面才值得上 DENY。有一点容易忽略X-Frame-Options 一次性设置不支持按域名区分也不支持多个值叠加。如果你确实需要允许某些特定第三方域名嵌入就得换 CSP 的 frame-ancestors 指令。它比 X-Frame-Options 更灵活可以写多个域名来源。浏览器对 frame-ancestors 的支持度已经很高如果你在 CSP 里写了 frame-ancestorsX-Frame-Options 可以省略或者作为兼容兜底两者同时存在时 CSP 优先。2.2 X-Content-Type-Options让浏览器别自作聪明这个头只有唯一取值 nosniff含义简单粗暴禁止浏览器猜测文件类型。正常情况下服务器返回 Content-Type 时已经声明了这个资源是 HTML、图片还是 JSON。但某些老版本浏览器在响应头缺失或含糊不清时会通过扫描文件内容来判断类型。这就给了攻击者可乘之机上传一个内容其实是 HTML 脚本的文件但 Content-Type 写着 image/png浏览器一嗅探发现是 HTML就按脚本执行了。这就是 MIME 嗅探攻击的典型路径。加了 nosniff 之后浏览器严格按 Content-Type 执行说你是图就别想装成脚本。这个头对图片上传、附件预览这类功能尤其重要我见过不少站点的附件下载接口就是因为没加它而被安全工具标红。配置同样是在响应头里加上add_header X-Content-Type-Options nosniff always;2.3 Strict-Transport-Security把用户牢牢钉在 HTTPS 上HSTS 是安全头里少有的、能主动影响浏览器行为的头。它的核心逻辑是服务器告诉浏览器接下来一段时间内这个域名只准用 HTTPS 访问不允许降级成 HTTP。用场景来解释最直观。用户第一次访问时输入了 http://example.comNginx 收到后 301 跳转到 https://example.com浏览器记录下 HSTS 策略。下次用户再输入 http://example.com 时浏览器在发起请求前就直接改成 https中间那一跳 HTTP 请求彻底消失。这样能防住什么SSL 剥离攻击。攻击者在公共 WiFi 上劫持用户请求如果用户一开始输的是 http攻击者可以拦截连接、冒充服务器甚至把 301 跳转抹掉让双方一直停留在 HTTP 通道里。有了 HSTS浏览器根本不会发 HTTP 请求攻击者就没有下手的机会。Nginx 配置长这样add_header Strict-Transport-Security max-age31536000; includeSubDomains always;max-age 单位是秒。31536000 正好是一年。为什么是一年因为要上 HSTS preload 列表的基本门槛就是至少 31536000 秒。preload 是浏览器内置的一份“强制 HTTPS”域名清单只要域名在里面即使用户第一次访问都直接走 HTTPS。这需要去相关站点提交提交前必须保证所有子域名都支持 HTTPS否则 includeSubDomains 会把没有 HTTPS 的子域名也钉死直接无法访问。这里有经验要说不要一上来就配 includeSubDomains 和一年有效期。HSTS 一旦被浏览器记住你很难主动让用户忘记。如果你某个子域名还没部署好证书配上 includeSubDomains 等于自杀。稳妥做法是先用短一点的 max-age比如 86400一天观察几天没有异常再逐步加长确认所有子域名都覆盖之后再考虑 preload。这和改 DNS 的 TTL 一个道理先小步试错。2.4 Content-Security-Policy最复杂也最有威慑力的一张网CSP 能防的东西太多XSS、数据注入、恶意 iframe、不信任的第三方脚本。它通过一套白名单机制告诉浏览器当前页面允许加载哪些来源的脚本、样式、图片、字体、连接目标等。凡是不在白名单里的一律拦截。除了拦截CSP 还能通过 report-uri 或 report-to 把违规行为上报等于给网站装了一台安全监控。直接看一个相对合适的基线配置add_header Content-Security-Policy default-src self; script-src self; img-src self data:; style-src self unsafe-inline; font-src self; connect-src self always;default-src self 是默认源意思是所有未单独列出的资源只允许从当前域名加载。script-src self 再把脚本收紧到当前域名。img-src 加 data: 是因为很多站用 base64 内联图片。style-src 允许 unsafe-inline 则是因为不少页面还有内联 style 或动态设样式。connect-src 管的是 fetch、XHR、WebSocket 这些连接目标。CSP 的坑在于配置严了页面功能先崩。最典型的是前端页面用了内联脚本、内联事件或者从 CDN 加载了静态资源、字体全被 CSP 拦掉。很多人一开 CSP整页白屏吓得赶紧删掉。解决办法不是关掉 CSP而是把它当作一个梳理前端资源的过程。对内联脚本给非行脚本加 nonce对第三方 CDN 资源在白名单里把域名加上对动态生成的内联样式考虑是否能用 class 或外部样式替代。我个人的建议是先上线 report-only 模式也就是 Content-Security-Policy-Report-Only这个模式下 CSP 不拦截只收集违规报告。观察一两周把误报、漏配的域名都加齐了再切换成强制模式。这个过程其实也是盘了一遍页面到底依赖哪些外部资源对做性能优化也有间接帮助。2.5 别被旧知识带偏X-XSS-Protection 的现代处理X-XSS-Protection 是浏览器内置 XSS 过滤器开关。老教程都让你配 1; modeblock强制拦截疑似 XSS 的响应。但这个头在 2020 年以后就被主流浏览器标记为不推荐甚至 Chrome 主动移除了它的默认过滤逻辑。原因是这个过滤机制本身存在被绕过的案例拦截后的行为还可能引发新的安全问题。说句实在话现代浏览器直接看 CSP 就够了X-XSS-Protection 反而成了鸡肋。安全扫描工具通常还会把它列为检查项。如果你想满足工具又不给自己挖坑可以显式设成 0add_header X-XSS-Protection 0 always;这个 0 不是无视安全而是明确关闭浏览器过时的过滤器让 CSP 承担真正的 XSS 防护职责。很多人在这一点上会犹豫担心安全工具打低分。其实设置成 0 并写好注释比盲目设置 1 反而更符合当前安全实践。2.6 Referrer-Policy 与 Permissions-Policy补齐信息泄漏的边角Referrer-Policy 控制的是浏览器在跳转到其他网站时请求头里带多少来源信息。你要是不设置浏览器默认策略在不同浏览器里不完全一样有的会带上完整的 URL里面可能含查询参数、token甚至内部路径。这种信息落到第三方手里属于典型的信息泄漏。推荐值add_header Referrer-Policy strict-origin-when-cross-origin always;意思是同源请求带完整 URL跨域请求只带来源域名并且只在 HTTPS 环境下传递。既保留了站内分析需要的完整来源链路又把跨域泄漏概率降到最低。对绝大多数站点而言这已经是安全性和业务功能的平衡点。Permissions-Policy 则用来关掉浏览器的高级 API比如摄像头、麦克风、地理位置、USB、振动等。不是所有页面都需要这些权限默认全开等于把攻击面交给页面里的任何脚本。限缩这些能力可以在即便出现 XSS 时降低恶意脚本可调用的系统能力。配置示例add_header Permissions-Policy geolocation(), camera(), microphone(), usb(), fullscreen(self) always;含义是这些 API 在当前页面一律禁止调用全屏仅允许同源页面使用。老一点的项目可能见过 Feature-Policy那是 Permissions-Policy 的前身现代浏览器基本都支持新写法。兼容性上可以同时保留旧的 Feature-Policy 值但不建议只配旧的头。2.7 别忘了 Cache-Control敏感页面不该被缓存它虽然不是传统意义上的“安全头”但和隐私安全的关系极为紧密。登录页、个人信息页、支付结果页一旦被浏览器或中间设备缓存下一位使用者按后退键时可能直接看到上一位的数据。location ~ ^/(login|profile|payment)/ { add_header Cache-Control no-store, no-cache, must-revalidate always; }no-store 是彻底不缓存的语义no-cache 是强制每次重新验证。对敏感路径用 no-store 最稳妥。静态资源倒是可以放宽Cache-Control 交给资源本身的版本号去管理。这个头建议和前面几个安全头一起检查安全工具未必会标红但缺了它真的出事。3. Nginx 配置实操add_header 的继承规则和两个高频坑3.1 add_header 的层级结构每一层都要心里有数Nginx 的 add_header 可以写在 http、server、location 三个层级。新手最容易在这里翻车同一层级的规则会覆盖父级而不是合并。比如 server 里写了一条 X-Frame-Optionslocation 里只要写了 add_header哪怕只写了一条server 层级的 X-Frame-Options 也不会自动带过来。很多人配了半天发现某条头在某些接口上有、在某些页面上没有经常就是被这个规则折腾的。正确的做法是把公共安全头统一放在 server 或 http 层location 里如果确实需要额外的头就显式把公共头也重新列一遍。或者用 include 把公共的 add_header 文件引进来这样每层都能保持一致。举个实际场景。你的接口都是 /api/ 开头代码里可能还需要跨域头location /api/ { add_header X-Frame-Options SAMEORIGIN always; add_header X-Content-Type-Options nosniff always; add_header Access-Control-Allow-Origin https://admin.example.com always; }你只写了 X-Frame-Options 和 X-Content-Type-Options 以及 CORS 头其他在 server 层级已经定义的安全头在这个 location 里全部失效因为它们不会自动合并。这是一个很隐蔽的问题主页面检查没问题接口单独看就少了头。注意如果你在检查工具里看到某个路径的响应缺少其他安全头先回想这个路径有没有自己的 add_header。八成就是继承覆盖了。3.2 always 参数错误页和 4xx/5xx 响应也要带头默认情况下 Nginx 只在成功响应200、201、204 等里加 add_header。404、500、301/302 这些响应不带。而攻击恰恰喜欢往不存在的路径打尤其在探测敏感文件时404 页面缺了安全头反而等于告诉对方“这是个没加固的站”。解决方式是每个关键字后面加 alwaysadd_header X-Content-Type-Options nosniff always;加了这个参数Nginx 会在包括 4xx/5xx 在内的所有响应里都加上该头。我第一次看到 404 响应头里没有 X-Content-Type-Options 时还诧异排查了一圈才明白是 always 没带上。这个参数看着不起眼却直接影响安全扫描的通过率。3.3 HSTS 该放在哪个层级用户第一次访问时可以走 httpNginx 收到后 301 到 https。但 HSTS 有一个硬性前提必须通过 HTTPS 响应返回HTTP 响应里的 HSTS 地址会被浏览器直接忽略。同时也不建议在 80 端口的 server 块里加 HSTS因为那条响应本来就是跳转用的浏览器不会记录它。所以正确的位置是 443 端口的 server 块server { listen 443 ssl; server_name example.com; add_header Strict-Transport-Security max-age31536000; includeSubDomains always; }80 端口的 server 块只需要负责 301 跳转不用写这条头。如果你把 HSTS 写在了公共配置的 http 层级那就意味着 80 端口的多余响应也会携带它没有实际作用还白白让响应头多几个字段没必要。3.4 避免在 if 块里使用 add_headerNginx 的 if 块是出了名的“邪恶”配置不当会造成各种稀奇古怪的行为。add_header 放在 if 里不仅会遇到继承丢失问题还会在分支逻辑复杂时出现头时有时无的现象。比如你根据 User-Agent 判断是否为老版本浏览器想给不同客户端返回不同的 CSP在 if 里写 add_header 的写法很容易出现意外覆盖。如果确实需要根据条件区分安全头比较好的做法是使用 map 指令先按变量映射出要设置的值再用统一的一条 add_header 引用变量map $http_user_agent $csp_value { default default-src self; ~*Trident default-src self; script-src unsafe-inline; } server { add_header Content-Security-Policy $csp_value always; }这种方式的优点在于 add_header 始终在层级内保持唯一map 负责根据条件算出结果逻辑清晰又不破坏继承规则。4. 一份可以抄的完整模板以及不同业务场景的调整思路4.1 基础通用配置模板直接把下面的内容放到你的 server 块里或者写在一个 conf 文件里再 include 进来就能覆盖绝大多数场景# 放在 443 端口对应的 server 块内 server { listen 443 ssl; server_name example.com; # 强制跳转到 HTTPS add_header Strict-Transport-Security max-age31536000; includeSubDomains always; # 防点击劫持 add_header X-Frame-Options SAMEORIGIN always; # 防 MIME 嗅探 add_header X-Content-Type-Options nosniff always; # 现代浏览器用 CSP 做 XSS 防护禁用老式过滤器 add_header X-XSS-Protection 0 always; # 引用来源控制 add_header Referrer-Policy strict-origin-when-cross-origin always; # 浏览器能力权限收敛 add_header Permissions-Policy geolocation(), camera(), microphone(), usb(), payment(), fullscreen(self) always; # 这里按你的页面灵活调整 CSP add_header Content-Security-Policy default-src self; script-src self; img-src self data:; style-src self unsafe-inline; font-src self; connect-src self always; # 敏感路径不再缓存 location ~ ^/(login|profile|payment)/ { add_header Cache-Control no-store, no-cache, must-revalidate always; } }这里我把 HSTS 放在 443 块里80 块的跳转配置另写server { listen 80; server_name example.com; return 301 https://$host$request_uri; }注意这个模板里的 CSP 是通用值不一定适合所有页面。如果你的前端有内联脚本、外链字体、第三方统计、地图 SDK 或其他外部资源需要先跑一圈 report-only 模式再切正式。验证配置是否正确重启 Nginx 后用 curl 看一眼curl -I https://example.com/正常响应头里应该能看到好几行 add_header 定义的头。如果想看某个页面或 API 的就把 URL 换成对应的地址。响应头数量一目了然少了哪条就去对应层级排查。4.2 不同场景的 CSP 差异化调整纯静态站点相对简单。没有登录态没有动态脚本CSP 可以收到最严add_header Content-Security-Policy default-src none; img-src self; style-src self; script-src self always;default-src none 意味着除了显式允许的其他资源全部不加载。静态页面用这个级别几乎不影响功能。前后端分离项目就需要把 API 域名放行。假设 API 在 api.example.com前端在 www.example.comadd_header Content-Security-Policy default-src self; script-src self; connect-src self https://api.example.com; img-src self data: https://cdn.example.com always;管理后台或包含第三方控件如富文本编辑器、地图组件、支付组件的页面要单独评估。比如某编辑器把样式写成内联 style就需要 style-src 里开 unsafe-inline某个地图 SDK 要加载第三方瓦片资源img-src 就要加上对方域名。碰到这种情况宁可先加白名单也不要盲目收紧。4.3 用报告辅助调 CSP省心又安全调整 CSP 最难的是“我看不到哪里被拦了”。把 CSP 从宽松调到严格页面白屏没有任何报错提示。更好的流程是先上 Content-Security-Policy-Report-Only让浏览器收集违规情况但不拦截。在浏览器开发者工具的 Console 里能看到报错也可以把报错汇总到一个你自定义的接口专门攒几天的报告再做决策。比如先配一条只上报不执行的头add_header Content-Security-Policy-Report-Only default-src self; report-uri /csp-report always;然后观察报告里哪些域名被拒绝最多再决定正式 CSP 的白名单里加哪些内容。很多团队第一次配 CSP 时都因为这个流程少走了不少弯路。真要比对着报错一条条查效率太低还容易误伤线上功能。5. 常见问题速查这些坑我基本都踩过现象原因解决办法安全头只在首页有API 响应里全没了location 层级写了 add_header父级定义被整体覆盖在对应的 location 里显式重新添加所有公共头404 或 500 页面响应里找不到安全头add_header 缺 always 参数逐条加上 alwaysHSTS 配置无误但不生效配置写在 80 端口或 http 层级浏览器忽略非 HTTPS 响应中的 HSTS只在 443 的 server 块中配置页面开启 CSP 后白屏内联脚本、外部 CDN、动态样式被拦截先用 Report-Only 汇总违规再加白名单或给内联脚本用 nonceCSP 拦截了 WebSocket 或 API 请求connect-src 未放行目标地址把 wss 和 API 域名显式加到 connect-src某些浏览器提示 Unknown directive 或忽略头Permissions-Policy 与 Feature-Policy 新旧写法不兼容两个头都配旧值用 Feature-Policy 兼容新值用 Permissions-Policy同一份配置安全工具给不同路径打了不同分有些 location 没有继承公共头用 include 统一维护 add_header 配置避免分散HSTS 配置后发现某子域名打不开includeSubDomains 强制了未部署 HTTPS 的子域名先在未覆盖全部子域名时禁用 includeSubDomains排障时建议先打开浏览器开发者工具的 Network 面板查看具体请求的响应头再用 curl -I 对比同一 URL。两者结合能快速定位是 Nginx 配置层级问题还是 CDN 或者源站把响应头洗掉了。CDN 场景稍微特殊一些源站配置的安全头如果被 CDN 清掉客户端收到的响应头可能来自 CDN 的缓存副本需要额外检查 CDN 的回源策略。另外还有一个小细节如果你经常用安全头检查和评分工具这类工具通常会根据响应头缺失情况打分。但评分只是一个参考维度不要为了毫无意义的满分而把不同安全头配置成互相冲突的值。比如 X-Frame-Options 和 CSP 的 frame-ancestors 同时存在时CSP 优先但如果你把 CSP 的 frame-ancestors 写成 none同时 X-Frame-Options 写 SAMEORIGIN其实后者已经没意义了。配置安全头追求的是真实有效地缩小攻击面刻意堆头只会增加维护负担。6. 收个尾安全头配置不是一次性工作很多人把安全头当作上线前一次性加完就再也不管的配置。但网站前端代码是会演进的今天没有内联脚本不代表半年后加个 A/B 测试工具不会引入外链脚本。CSP 白名单、Referrer-Policy 的业务含义、Permissions-Policy 的面向场景都应该随着网站变化定期复查。我自己的习惯是每个迭代版本发布前跑一遍在线安全头检查工具把新增路径的响应头扫一遍重点看改动过的 location 是否继承正常。这个动作只要几十秒比上线后被安全同事拿着漏扫报告来找你高效得多。如果你现在正准备给项目配置安全头我的建议是先把通用模板搭起来确认所有页面通过 HTTPS 访问再用 report-only 模式调 CSP等违规报告稳定下来再切强制模式。整个流程下来你的站点在攻击者面前就已经多了一层像样的铠甲。安全头配置的成本很低难度也不高做和不做的区别往往就是某次攻击事件里的“侥幸”与“所幸”。希望这篇内容能帮你少踩几个我踩过的坑。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。