
前言先澄清一个版本事实PHP 8.5 并没有引入名为「安全头」的新特性标题里的版号容易让人误会。安全头security headers是 HTTP 响应头由应用或 Web 服务器下发PHP 从 5.x 时代就能用header()发它们。本文按 PHP 8.5 的运行环境来演示同时给出 nginx 侧的做法因为有些响应根本不会经过 PHP。很多人对 XSSCross-Site Scripting跨站脚本的防护理解停留在「输出时转义」但转义只解决了「数据被当成代码执行」这一种情况。真实场景里攻击者可能从完全不同的位置注入用户提交的内容进了数据库被另一个页面渲染第三方 CDN 上的脚本被投毒一个历史遗留的内联事件属性被忽略。安全头的作用是给浏览器加一层兜底即使某个输出点漏了转义浏览器也不会执行那段脚本。还有一个普遍存在的误区以为把X-XSS-Protection: 1; modeblock加上就安全了。这个头已经被主流浏览器废弃某些情况下它的过滤逻辑反而会引入新的漏洞。真正干活的是 CSPContent Security Policy内容安全策略。本文就按「各响应头做什么 → PHP 侧怎么下发 → CSP 怎么写才有效 → nginx 侧的坑」讲清楚。一、这几类响应头分别负责什么响应头作用建议值说明Content-Security-Policy限制页面能加载/执行哪些资源default-src self; script-src self nonce-...; object-src none; base-uri self; frame-ancestors none防 XSS 的主力能拦住漏转义导致的脚本执行X-Content-Type-Options禁止浏览器猜测 MIME 类型nosniff防止把上传的文本文件当脚本解析X-Frame-Options禁止被其他站点套框DENY防点击劫持现代浏览器更认 CSP 的frame-ancestorsReferrer-Policy控制 Referer 里带多少信息strict-origin-when-cross-origin避免把带参数的 URL 泄露给第三方Strict-Transport-Security强制后续请求走 HTTPSmax-age31536000; includeSubDomains只有全站 HTTPS 已就绪才能开Permissions-Policy关闭不需要的浏览器能力geolocation(), camera(), microphone()缩小被利用面X-XSS-Protection已废弃的 XSS 过滤器不要发或发0现代浏览器已移除该过滤器X-Content-Type-Options: nosniff这条经常被小看。文件上传接口如果返回的Content-Type不对浏览器在旧行为下会「猜」内容类型把一个纯文本文件当成 HTML 执行于是上传就变成了存储型 XSS。加上nosniff之后这类猜测行为会被禁止。二、PHP 侧怎么下发一个统一的头函数关键约束是响应头必须在任何输出之前发送。任何字符都算输出包括文件开头的 BOM、?结束标签后面的空行、以及某个include文件里多敲的一个回车。?php declare(strict_types1); /** * security_headers.php —— 统一在入口处下发安全响应头 * 最低 PHP 8.0用到了 str_containsnonce 生成依赖 PHP 7.0 起的 random_bytes * * 启动php -S 127.0.0.1:8000 -t . * 观察curl -sI http://127.0.0.1:8000/security_headers.php */ /** * 下发全部安全头返回本响应专用的 CSP nonce。 * nonce 必须每次响应都不同页面里的内联脚本要靠它才能通过 CSP 校验。 */ function send_security_headers(): string { $nonce base64_encode(random_bytes(16)); $csp implode(; , [ default-src self, script-src self nonce-{$nonce}, style-src self, img-src self data:, connect-src self, object-src none, base-uri self, form-action self, frame-ancestors none, ]); header(Content-Security-Policy: . $csp); header(X-Content-Type-Options: nosniff); header(X-Frame-Options: DENY); header(Referrer-Policy: strict-origin-when-cross-origin); header(Permissions-Policy: geolocation(), camera(), microphone()); // HSTS 只在该域名已经全站 HTTPS 时才发否则会把用户锁在 https 上出不来 $isHttps ($_SERVER[HTTPS] ?? ) on || ($_SERVER[HTTP_X_FORWARDED_PROTO] ?? ) https; if ($isHttps) { header(Strict-Transport-Security: max-age31536000; includeSubDomains); } return $nonce; } // 顺手抹掉会泄露 PHP 版本号的响应头等价于 php.ini 里 expose_php Off header_remove(X-Powered-By); // headers_sent() 是最后一道防线一旦已经输出过内容header() 会抛 warning 且静默失败 if (headers_sent($file, $line)) { error_log(安全头下发失败{$file}:{$line} 已经开始输出); exit(配置错误响应头必须在任何输出之前发送); } $nonce send_security_headers(); header(Content-Type: text/plain; charsetutf-8); // 真实页面里这个 nonce 会被拼进内联脚本的 nonce 属性 // 这里只把它打印出来方便你对照响应头检查是否一致 echo nonce, $nonce, PHP_EOL;用curl -sI发一个 HEAD 请求可以看到对应的响应头curl -sI http://127.0.0.1:8000/security_headers.phpHTTP/1.1 200 OK Host: 127.0.0.1:8000 Content-Security-Policy: default-src self; script-src self nonce-xxxxxxxxxxxxxxxxxxxx; style-src self; ... X-Content-Type-Options: nosniff X-Frame-Options: DENY Referrer-Policy: strict-origin-when-cross-origin Permissions-Policy: geolocation(), camera(), microphone() Content-Type: text/plain; charsetutf-8注意Content-Security-Policy里的 nonce 值和响应体里打印的 nonce 必须完全一致。这两个值不一致是 CSP 报错刷屏的常见原因下一节还会提到。三、CSP 怎么写才有效CSP 的核心思想是「默认拒绝」没有明确允许的一律不许加载或执行。default-src self兜底规则未单独指定的类型都只能加载同源资源。script-src self nonce-xxx脚本只能来自同源外加带正确 nonce 的内联脚本。object-src none彻底禁用 Flash 时代的插件对象。base-uri self防止攻击者用一个被劫持的base标签把相对路径的脚本指到别的域名。frame-ancestors none等价于X-Frame-Options: DENY现代浏览器优先看这条。这里最容易犯的错是加unsafe-inline。它一旦出现所有内联脚本都能跑CSP 对 XSS 的防护基本等于关掉。如果历史代码里内联脚本太多、一时改不完正确做法是先用Content-Security-Policy-Report-Only跑一段时间收集违规报告把内联脚本逐个换成外链加 nonce再切换到强制模式。四、nginx 侧的两件事静态资源与失效继承安全头不能只在 PHP 里发。图片、CSS、JS 由 nginx 直接返回压根不会走到 PHP如果只在 PHP 入口下发静态资源就没有任何防护。nginx 侧的配置server { add_header X-Content-Type-Options nosniff always; add_header Referrer-Policy strict-origin-when-cross-origin always; add_header Content-Security-Policy default-src self; object-src none; base-uri self always; location /static/ { add_header Cache-Control public, max-age31536000 always; # 注意这个 location 里没有重复写上面那三行 # nginx 的 add_header 不会从 server 继承到「自己有 add_header」的 location } }最后那行注释是个真坑nginx 中只要location块里出现了任何一条add_header它就不会再继承外层server块的add_header。结果就是静态资源的安全头悄悄消失了而配置文件看起来完全正常。另外always参数也别忘了——不加它4xx 和 5xx 响应根本不会带上这些头而错误页面恰恰是攻击者最爱利用的地方。常见坑点❌ 在header()之前 echo 了任何东西包括 BOM、空行、?后的空白。✅ 用headers_sent($file, $line)提前判断并在日志里记录出错位置纯 PHP 文件省略结尾的?。❌ 在 CSP 里写unsafe-inline图省事。✅ 用 nonce 或 hash 允许必要的内联脚本迁移期先用Report-Only模式收集违规。❌ 继续下发X-XSS-Protection: 1; modeblock。✅ 不发这个头或显式发0把精力放在 CSP 和输出转义上。❌ 只在index.php里发安全头其他入口文件、错误页、静态资源全都漏掉。✅ 在 PHP 侧的框架引导文件里统一发同时用 nginx 的add_header ... always覆盖静态资源。❌ 在location里加了Cache-Control导致外层server块里的三条安全头全部失效。✅ 在这个location里把安全头原样重复一遍。❌ 开了 HSTS 之后又退回 HTTP 调试浏览器强制跳 https本地怎么改都打不开。✅ HSTS 只在确认全站 HTTPS 可用后再开调试阶段先用小的max-age值试水。❌ nonce 每次请求都随机生成但页面被 CDN 或反向代理缓存住了。✅ 带 nonce 的响应必须设置Cache-Control: no-store或按用户维度隔离缓存否则浏览器拿到的 nonce 和响应头里的对不上CSP 报错刷屏。❌ 以为配了安全头就不需要转义了。✅ CSP 是兜底htmlspecialchars($v, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401, UTF-8)才是主力两者缺一不可。总结防护层手段拦住的是什么输出层htmlspecialchars()按上下文转义数据被当成 HTML/JS 执行存储层参数化查询、上传文件重命名与类型校验入库时就被注入浏览器层CSPnonce 方案漏转义、第三方脚本投毒传输层HSTS 全站 HTTPS中间人篡改响应服务器层nosniff、frame-ancestors、关闭expose_phpMIME 猜测、点击劫持、版本泄露防 XSS 没有单点银弹安全头是兜底而不是替代品输出转义是基础而不是全部。落地上最重要的一步是把安全头的下发收敛到「PHP 入口统一发 nginx 静态资源补齐」这两处然后在响应头里确认 nonce 与页面里用到的一致、always参数没漏、location的继承没断——这三处检查完绝大多数「配置了却没生效」的问题都能定位到。