资讯动态

点击劫持防线:X-Frame-Options配置全解与CSP升级

发布时间:2026/10/17 7:58:36 来源:尧图企业网站定制
1. 点击劫持一个被低估的Web安全威胁先说个真事。我有一次给某公司的业务系统做安全巡检发现支付页面上埋了一个不属于该站点的iframe嵌套。当时安全测试报告里的结论是“中危点击劫持”。很多开发者看到“点击劫持”这四个字第一反应是“哦不就是别人把我的网站放到他的iframe里吗能有多大危害”。如果你也这么想那就大错特错了。点击劫持Clickjacking在OWASP Top 10里常年占据前端安全漏洞的重要位置它最可怕的地方在于攻击者不需要破解你的密码不需要注入任何SQL甚至不需要用户猜到任何东西只需要诱导用户“点一下”就能完成转账、授权、发帖、修改配置等一系列敏感操作。我见过一个很典型的案例。某后台管理系统的管理员账号密码保护做得很严实二次验证也是有的结果攻击者把后台的“修改绑定手机号”页面透明地覆盖在一个娱乐抽奖页面上受害者毫无防备地点了“领取奖品”按钮实际上点中的是“确认更换手机号”。整个攻击过程用户完全无感等到发现账号异常攻击者早就把信息带走了一轮。这就是点击劫持的杀伤力。而X-Frame-Options正是解决这个问题最基础、最直接的一道防线。它告诉我这个页面允不允许被放进别人的iframe里展示谁来放谁不准放。说白了它就是页面在浏览器层面的“门锁”。这篇文章我会从攻击原理讲起把X-Frame-Options的每个指令值掰开揉碎再给出Nginx、Apache、IIS以及常见后端框架里的具体配置方法最后补充CSP的frame-ancestors这个升级方案和我在实际排查中踩过的坑。内容不复杂但对做Web开发的、做安全运维的、甚至对前端安全感兴趣的测试同学都有直接参考价值。2. 先看对手点击劫持的攻击链到底怎么走2.1 从一次“看不见的点击”说起理解点击劫持最好的方式就是看一条完整的攻击链。整个过程大概分四步。第一步攻击者先找一个“有价值”的页面作为目标。什么样的页面有价值凡是需要用户主动点击确认、且操作结果不可逆的页面都算。比如转账确认页、授权登录页、开启远程控制的开关页、删除数据的确认弹窗这些页面的共同特点是用户只需要一个“确认”动作就能触发关键操作。第二步攻击者在自己的恶意站点上创建一个页面在里面用iframe加载目标页面。注意这里的关键前提是目标站点没有设置拒绝iframe加载的响应头。第三步攻击者把iframe设置成完全透明或者只保留一个按钮区域可见再通过CSS把iframe定位到一个特定的位置比如正好覆盖在恶意页面某个诱导按钮的上方。这样用户看到的是一张笑脸、一个“点击领取奖励”、一个“播放视频”的按钮实际上手点下去时点击坐标会穿透到上层iframe里的目标页面上。第四步用户点击的一瞬间目标页面接收到真实的click事件关键操作被执行。整个过程用户完全无感知因为显示的页面和实际点击的目标页面长得完全不一样。这四步走完一次点击劫持就完成了。它在移动端同样成立甚至移动端因为屏幕小、交互方式有限更容易出现误触。2.2 iframe加载限制是破局点从攻击链可以看出来整条攻击链路里最关键的“帮凶”其实就是:目标页面允许被任意站点通过iframe加载。如果能从源头禁止这种加载行为后面那堆透明iframe、坐标对齐的把戏全部白搭。这就是X-Frame-Options存在的意义。它由客户端服务器在页面响应里通过HTTP头下发客户端浏览器读到这个头之后就会根据指令值决定这个页面能不能被放到iframe、frame、embed这些“内嵌容器”里。需要注意的是X-Frame-Options对用户没有任何可见的影响。它不像弹窗拦截那样会提示“已阻止”也不会改变正常打开页面的行为。只有当页面被尝试嵌入到另一个页面时浏览器才会根据这个响应头判断该加载还是拒绝加载。而且一旦判定拒绝浏览器会直接在内嵌区域显示一个空白块或者错误提示不会让用户产生“页面坏了”的错觉之外的实际交互。3. 认识X-Frame-Options的三个指令值3.1 DENY最严格的“谁都不准”DENY是三个值里最严格的一个。设置了DENY之后无论发起嵌入请求的页面是哪个站点——包括你自己的站点——浏览器都会拒绝这个页面被嵌入iframe。什么时候用DENY我强烈建议一切不需要被嵌入到其他页面里的内容全部无脑上DENY。比如后台管理系统、登录页、支付处理页、个人中心、数据管理页面这些页面本来就不需要被别人页面套用直接DENY是最省心、最安全的选择。有人可能会问“DENY会不会影响我们自己网站的页面互相嵌套”会如果你们产品里确实有“在同域名下页面A用iframe嵌入页面B”的需求DENY会把这条路也堵死。所以这个值适合的场景就是这个页面铁了心不进任何frame。3.2 SAMEORIGIN兼顾同源业务的“白名单”SAMEORIGIN稍微宽松一点它允许“同源”页面之间的iframe嵌入。这里的“同源”指的是协议、域名、端口完全一致。比如一个页面配置了SAMEORIGIN那么它在https://example.com下的任意页面里可以被iframe正常加载但如果有人把它放到https://evil.com的页面里浏览器照样会拒绝。这个值最适合的场景是你的平台内部确实有页面嵌套需求。比如一个门户后台左侧菜单页面要嵌入右侧的内容页或者有一些仪表盘聚合组件需要把多个模块页面嵌到一个大页面里。这种场景下SAMEORIGIN比DENY灵活得多。但这里有个特别容易踩坑的点同源判断是基于“发起嵌入的顶层页面”还是“发起嵌入的iframe页面”实际上浏览器的判断逻辑是只要父级页面也就是放置iframe的那个页面和目标页面同源就允许加载。如果父级页面是跨域的即使再嵌套一层同源的iframe浏览器也会拒绝。所以不要觉得“我中间套了一层同源页面就能绕过”这是不成立的。3.3 ALLOW-FROM实际意义已经名存实亡早年标准里还定义过ALLOW-FROM语法是ALLOW-FROM https://trusted.com表示只允许指定的来源来嵌入。听起来这个值最灵活但我劝你别再用了。为什么因为几乎所有的现代浏览器都放弃了对ALLOW-FROM的支持。据我所知这类指令在浏览器里早就形同虚设你配置了它绝大多数浏览器都会直接忽略甚至把它当作没有任何防护来处理。我曾经见过不少老项目里还残留着ALLOW-FROM xxx.com的配置实际测试下来完全是裸奔状态。如果你看到老代码里有这个请立刻改成其他方案。3.4 三个指令值对比速查我把三个值放到一张表里方便你对照选择指令值允许嵌入范围推荐场景浏览器兼容性DENY所有站点一律拒绝登录页、后台管理、支付页面全兼容SAMEORIGIN仅允许同源页面嵌入站点内部模块化嵌套全兼容ALLOW-FROM仅允许指定URL嵌入不推荐使用主流浏览器均已废弃4. 动手配置五大场景的完整实操4.1 Nginx里的标准配置法现在绝大多数Web服务前面都挂着Nginx所以我先说Nginx里的做法。如果你的服务是静态页面或者反向代理模式可以直接在server块里加响应头server { listen 443 ssl; server_name example.com; # 全站统一设置 add_header X-Frame-Options SAMEORIGIN always; location /admin { # 管理后台单独设置为DENY add_header X-Frame-Options DENY always; } location /api/ { # API接口不需要被嵌入 add_header X-Frame-Options DENY always; } }这里有一个特别关键的细节always参数。Nginx里add_header指令默认只在响应码为200、201、204、206、301、302、303、304、307、308这些“标准成功或重定向”响应里生效。如果你的站点会出现404、500、502等错误响应不加always的话这些错误页面就会漏掉X-Frame-Options头。我之前就踩过这个坑。某个接口在服务端异常时返回了500测试人员拿着500响应说“没有检测到X-Frame-Options头缺口还在”。我一开始还以为是服务端代码没有继承响应头排查了半天最后发现就是Nginx里少写了always。另一个要注意的是如果你在server块和location块都设置了add_headerNginx的规则是下级覆盖上级而不是合并。也就是说location里写了add_header X-Frame-Options之后server块的add_header X-Frame-Options在location范围内就失效了。如果你还指望server块里其他add_header能继续生效那是不会的。想一个都没落下要么在location里把全部响应头重新声明要么用include管理多个响应头配置。4.2 Apache的两种写法和继承机制Apache里配置X-Frame-Options有两种方式一种是改httpd.conf或者apache2.conf另一种是用.htaccess文件。我建议优先改主配置因为主配置可以全局控制而且不受目录级配置覆盖的影响。主配置文件里这样写# 全站设置 Header always set X-Frame-Options SAMEORIGIN # 只对某个目录做调整 Directory /var/www/admin Header always set X-Frame-Options DENY /Directory # 只对某个URL做调整 Location /api/ Header always set X-Frame-Options DENY /Location注意到我每句都加了always。Apache的Header set默认只在成功响应2xx系列里生效如果不加always错误响应同样会漏掉响应头。这点和Nginx的坑一模一样。如果你用的是.htaccess写法是一样的Header always set X-Frame-Options SAMEORIGIN但.htaccess有额外要求目录的AllowOverride配置里必须包括FileInfo或Headers相关的权限不然.htaccess文件会被整个忽略。我曾经遇到过明明在.htaccess里写好了配置但就是不见效果最后发现是服务器把AllowOverride设置成了None。你排查的时候可以先用命令行工具确认配置是否生效再考虑是不是AllowOverride的问题。顺便提一句Apache里Header set会覆盖掉上游服务端代码里已经设置的同名响应头而Header append则是追加。如果你需要保留应用层设置的X-Frame-Options同时又想用Apache强制覆盖用set就对了。如果两者配置冲突你总得选择一个最终值建议在Apache这一层统一收口因为应用层多语言、多框架你很难逐个保证。4.3 IIS里微软生态的特殊姿势Windows Server上跑IIS的同仁配置路径和Unix生态不太一样。打开IIS管理器的“HTTP响应标头”功能直接添加一个名称为X-Frame-Options、值为SAMEORIGIN的响应头即可。这是最直观的图形化操作适合单机少量站点。如果是运维脚本管理大量站点建议直接改web.configconfiguration system.webServer httpProtocol customHeaders add nameX-Frame-Options valueSAMEORIGIN / remove nameX-Powered-By / /customHeaders /httpProtocol /system.webServer /configuration这里有个IIS的细节customHeaders是从网站根目录继承的子应用默认会继承父级配置。如果你某个子应用需要改成DENY就得在对应的web.config里显式添加一条DENY。而且要注意add和remove的顺序如果你既加了X-Frame-Options又写了一条remove执行顺序会影响最终结果建议把remove放在add之前。4.4 应用层兜底Java与Python框架的配置不是所有场景都在前面挂网关。有些内网应用、临时测试环境、或者微服务子模块可能直接暴露应用端口。这种时候就得在应用框架层补上这个响应头。Java的Servlet规范支持Filter机制我先说Spring系列项目里的做法。如果是传统Spring Boot项目最简单的就是加一个FilterComponent public class XFrameOptionsFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletResponse httpResponse (HttpServletResponse) response; httpResponse.setHeader(X-Frame-Options, SAMEORIGIN); chain.doFilter(request, response); } }如果你用的是Spring Security其实不用自己写框架已经内建了支持。只需要在配置里显式开启Configuration EnableWebSecurity public class SecurityConfig extends WebSecurityConfigurerAdapter { Override protected void configure(HttpSecurity http) throws Exception { http.headers() .frameOptions() .sameOrigin(); // 可选 deny() } }Spring Security默认情况下frameOptions是开启的默认值是DENY。如果你之前明确配置过frameOptions().disable()那等于自己把这道防线拆了赶紧改回来。Python这边Flask的写法最直观在after_request钩子里统一加from flask import Flask, g app Flask(__name__) app.after_request def add_security_headers(response): response.headers[X-Frame-Options] SAMEORIGIN return responseDjango的话系统内置了django.middleware.clickjacking.XFrameOptionsMiddleware默认就在MIDDLEWARE配置里注册着。它的默认值是SAMEORIGIN如果你想改成DENY只需要在settings里加一行X_FRAME_OPTIONS DENY。这里还是提醒一句应用层设置响应头一定要注意Filter或中间件的执行顺序。比如Spring Security的过滤链是有前后顺序的如果某个过滤器在设置响应头之后又写了内容或改了状态码这个响应头可能被覆盖掉。遇到“配了但没生效”的问题时第一件事就是检查过滤链的顺序。4.5 Electron桌面应用的特殊场景我发现一个容易被忽略的场景基于Electron等桌面框架开发的应用内部经常要通过webview标签加载远程页面。这种情况下webview渲染的页面同样会遵守X-Frame-Options的约束。如果远程页面设置了DENY在你的应用里就加载不出内容webview区域直接白屏或显示错误。这不是配置错误其实保护行为在正常工作。解决方案是既然你的应用是受控客户端安全信任边界不同应该让远程合作方在设置响应头时提供更细粒度的方案。比如保留SAMEORIGIN的同时由客户端代码通过加载代理方式让跨域嵌入合法化。不过这个思路涉及架构方案调整又比较特殊如果遇到具体问题可以再展开。5. 超越X-Frame-OptionsCSP frame-ancestors才是未来5.1 为什么还要看CSPX-Frame-Options已经存在很多年了基本所有浏览器都支持但它有两个明显短板。第一个短板是只有三种取值表达力太弱。你只能选择“全拒绝”或“同源允许”没法表达“允许A站和B站嵌入但拒绝其他站点”这种精细化需求。第二个短板是它和CSP家族的策略体系是两套东西安全头多了之后管理起来容易乱。所以现在业界更推荐用CSPContent Security Policy里的frame-ancestors指令来做精细控制。frame-ancestors专门控制“当前页面可以被哪些页面通过iframe嵌入”。它的语法比X-Frame-Options灵活得多Content-Security-Policy: frame-ancestors self; Content-Security-Policy: frame-ancestors self https://trusted.example.com; Content-Security-Policy: frame-ancestors none;三个指令分别对应仅同源允许、允许同源和指定可信站点、完全禁止。可以看到它解决了X-Frame-Options无法表达多个信任来源的问题。5.2 两者共存时的兼容策略这里有一个很多开发者都会搞混的知识点当X-Frame-Options和CSP frame-ancestors同时存在时浏览器会以更严格的那条规则为准。换句话说你不能用frame-ancestors放宽X-Frame-Options的限制但可以用frame-ancestors收紧X-Frame-Options的放行。举两个具体场景场景一X-Frame-Options设置SAMEORIGINframe-ancestors设置none。同源嵌入在X-Frame-Options看来是允许的但frame-ancestors直接禁止了最终结果是同源也不能嵌入。场景二X-Frame-Options设置DENYframe-ancestors设置http://trusted.com。理论上frame-ancestors是想放行trusted.com的但X-Frame-Options的DENY更严格、优先级更高最终结果是谁都不能嵌入。所以我的建议是如果你们已经全面接入CSP就不需要再同时设置X-Frame-Options了直接只保留frame-ancestors即可。如果必须兼容非常老的浏览器那就X-Frame-Options和CSP一起上并且主动接受“按更严格策略执行”的规则。5.3 CSP配置时的常见坑frame-ancestors有个很容易出问题的写法把允许的来源写成带路径的URL比如frame-ancestors https://example.com/admin。这种写法是不生效的CSP的来源匹配只看协议、域名、端口不看路径。如果你发现配置了之后仍然被拒绝先检查是不是写了路径。还有一个坑是frame-ancestors只影响当前页面被嵌入的行为不影响当前页面在顶层打开。所以你在页面里恶意代码检测时不要指望通过它来防止顶部页面通过window.open方式打开新窗口那是另一套问题。6. 验证与加固配置完了不等于完事6.1 快速验证配置是否生效配置完成后第一件事就是验证。我最常用的方法是用curl看响应头curl -I https://example.com/admin重点关注响应里是否有X-Frame-Options: DENY或Content-Security-Policy包含frame-ancestors。但响应头存在不等于行为正确。因为存在一种情况页面本身配置是对的但经过CDN、反向代理、WAF等中间链路时响应头被改写了。所以更严谨的做法是创建一个测试页面用iframe加载目标页面然后在浏览器里实际打开这个测试页面看效果。下面是我常用来做验证的最小测试页面!DOCTYPE html html head titleFrame Test/title /head body h1Iframe Embedding Test/h1 iframe srchttps://your-target-site.com/admin/login width800 height600/iframe /body /html把src换成你的目标地址。如果浏览器显示iframe空白区说明拦截生效了如果内容正常显示说明配置没有生效或者被中间链路改掉了。再结合浏览器开发者工具的Network面板里看目标页面的响应头基本就能定位问题出在哪一层。这套验证方式不需要任何自动化工具随手一个HTML文件就能搞定。6.2 常见的验证告警问题我遇到过一种很典型的情况curl看响应头有DENY但浏览器里iframe还是能加载。结果排查下来发现页面前端做了一次重定向用户点击后先访问了A页面A页面返回302跳到B页面而B页面没有设置X-Frame-Options。浏览器最终加载的是B页面的响应头所以最初那个DENY根本没派上用场。遇到这种情况要对全站做一次爬虫级响应头扫描把每一个最终的200响应页面都检查一遍。另外静态资源、图片、CSS、JS文件本身不会执行页面交互逻辑但有些扫描器会把这些也算进去。严格来说X-Frame-Options只要在HTML文档响应上设置就够了。如果你看见某个静态图片响应没有这个头不用太焦虑但保险起见加上也无妨。6.3 与前端反嵌套代码形成双保险响应头是最可靠的防线因为它发生在页面渲染之前由浏览器强制执行不受页面脚本加载速度的影响。但响应头并不能覆盖极端场景比如某些浏览器插件、老旧浏览器、或者用户在浏览器设置里停用了安全策略。所以很多安全团队会额外在前端页面上写一段反嵌套JS代码。最经典的做法是script if (self top) { // 正常打开什么都不做 } else { // 被嵌入iframe强制跳出 top.location self.location; } /script这段代码的思路是页面若不是被作为顶层窗口打开就把顶层窗口重定向到自身页面。它的好处是国内老浏览器兼容性很好但坏处是它是基于脚本的跟响应头相比不够“硬”。而且有些场景下top.location的跳转也会受到沙箱iframe的allow-top-navigation限制如果你的恶意页面来源本身就在沙箱里这段代码可能失效。所以JS反嵌套只能当辅助真正的底线还是响应头。7. 常见问题排查实录与兼容性清单7.1 典型问题速查表我把这些年遇到的点击劫持相关配置问题整理成表格适合直接截图保存或者贴到团队文档里问题现象可能原因排查与解决方案curl有头浏览器iframe仍然可加载中间代理/负荷均衡器改写响应头或页面经302跳转后目标页无响应头用浏览器Network面板查看最终响应检查代理和负载均衡配置配置了DENY但自己站点的嵌套也失效这是正常行为DENY本身不允许任何同源/跨域嵌套如业务需要内部嵌套改用SAMEORIGIN主站生效子路径页面没有响应头server/location覆盖或应用框架单独设置过Nginx里在子location里重新声明所有响应头应用层补充中间件老项目里ALLOW-FROM配置不生效现代浏览器已废弃该指令改用CSP frame-ancestors字典配置Spring Security配置了仍然不生效过滤链顺序问题或frameOptions被disable检查配置中是否调用过disable()如有应移除加了CSP之后页面iframe反而打不开frame-ancestors和X-Frame-Options相互约束按更严格执行按业务需要调整其中一个配置不要直接删掉所有响应头7.2 浏览器兼容性简要说明X-Frame-Options最舒服的地方就是兼容性好得离谱。所有主流浏览器包括Chrome、Firefox、Edge、Safari以及国产双核浏览器的兼容模式对DENY和SAMEORIGIN都有完整支持。现在唯一需要担心的就是前面说过的ALLOW-FROM已死以及CSP frame-ancestors在部分浏览器版本上仍然不支持。如果你面向的是一般的集团公司、政府网站或金融机构站点用户浏览器环境普遍是Chrome内核或Edge直接上CSP frame-ancestors完全没问题。但如果你面向很老的内网系统或者定制化浏览器环境稳妥的做法是X-Frame-Options和CSP同时设置。7.3 配置清单自检给项目上线前做一个快速自检基本能覆盖90%的情况登录页、支付页、管理后台是否都已设置DENY或frame-ancestors none中间链路CDN、WAF、负载均衡是否保留了响应头建议在边缘节点直接统一注入避免回源配置丢失。是否检查过含重定向链路的最终页面302、301跳转后的落地页也必须带响应头。如果用了CSP是否检查过frame-ancestors的源没有带路径是否存在应用层的Filter或中间件在响应后才设置响应头测试页面是否验证过iframe确实被拦截而不只是看了响应头8. 从单一响应头到纵深防线说实话X-Frame-Options只是点击劫持防护的第一道门甚至可以说只是最低要求。如果你把一个系统的安全希望完全寄托在一个响应头上那和把家门钥匙藏在地毯底下没什么区别。我在实际项目里通常是这样构建完整防线的第一层在网关或边缘节点统一注入X-Frame-Options和CSP frame-ancestors保证所有入口统一收口同时避免各应用各自配置互相覆盖。第二层在应用框架和前端权限系统里加入敏感操作的二次确认机制比如支付前的动态验证码、关键操作前的重新登录等。第三层对用户输入的链接做严格的协议和域名单白名单校验不给攻击者利用用户提交的URL做内嵌跳转的机会。第四层定期做安全扫描把“检查X-Frame-Options和CSP是否存在”这个用例写进自动化测试用例里防止某次发版或者中间件升级把响应头配置冲掉。有一个小技巧我很推荐在自动化发布流水线里增加一道安全头校验任务部署完成之后自动拉取几个关键页面的响应头核对X-Frame-Options、CSP、Strict-Transport-Security这些头是否都符合预期。一旦发现缺失直接卡住后续流程让发布不通过。这样就能避免“上线一小时安全告警先到”这种尴尬场景。从我自己的经验来看X-Frame-Options从来不是“配置一下就好”的事情。真正需要你投入精力的是把检查逻辑沉淀下来让它在每一次迭代、每一次发版里都自动生效。安全本来就是对抗今天你堵住了点击劫持明天可能又会有别的攻击手法冒出来。把手里的每一道基础防线都用扎实比追逐任何“万能防护方案”都靠谱。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑