点击劫持防御实战:X-Frame-Options 配置

点击劫持(Clickjacking)防御的核心手段是在响应头中声明页面不可被第三方站点嵌套,X-Frame-OptionsCSP frame-ancestors 是两种经过浏览器厂商广泛支持的实现方式。本文从攻击原理出发,给出 Nginx、Apache、Tomcat 与 Spring Security 四层可直接复制的配置,并提供 curl 与 PoC 页面两种验证方法,帮助站点在同一套配置下同时覆盖老版本浏览器与现代浏览器。

适用场景

  • 站点存在转账、下单、删除数据、授权登录、修改密码等一次性点击即可完成的高风险操作。
  • 已通过 SameSite=Lax 缓解 CSRF,但仍存在同站内嵌或旧浏览器 Cookie 投递的场景。
  • 安全测评或等保核查中,报告指出「缺少 Clickjacking 防护响应头」。
  • 页面被第三方通过 iframe 嵌套用于广告劫持、流量劫持或诱导点击。

前置条件

  • 具备 Web 服务器(Nginx / Apache / Tomcat)配置文件修改权限,或应用层拦截器改造权限。
  • 站点已启用 HTTPS(建议,避免响应头被中间设备剥离)。
  • 可执行 curl 命令用于验证响应头。
  • 修改配置前备份原始文件,Nginx 使用 nginx -t 校验通过后再 reload。

原理说明

点击劫持的成立依赖三个条件同时满足:目标页面可以被 iframe 嵌套、攻击者能控制嵌套层的视觉呈现、受害者已完成身份认证。攻击者通常在恶意页面中创建透明 iframe 覆盖在诱导按钮之上:

<style>
  iframe {
    position: absolute;
    top: 0; left: 0;
    width: 100%; height: 100%;
    opacity: 0.0001;      /* 完全透明,肉眼不可见 */
    z-index: 9999;        /* 覆盖在诱导元素之上 */
    border: 0;
  }
</style>
<button style="position:absolute;top:300px;left:200px;z-index:1">点击领取优惠券</button>
<iframe src="https://bank.example.com/transfer?amount=10000&to=attacker"></iframe>

用户看到的是「领取优惠券」,实际点击落点在被透明 iframe 覆盖的目标页面上。防御思路即是切断第一个条件——通过响应头告诉浏览器禁止或将嵌套来源限制为同源。

两种响应头的差异如下:

  • X-Frame-Options:兼容性好,取值 DENY(完全禁止嵌套)、SAMEORIGIN(仅允许同源嵌套)。ALLOW-FROM 已被主流浏览器废弃,不应再使用。
  • Content-Security-Policy: frame-ancestors:CSP Level 2 引入,支持白名单域名与通配符,优先级高于 X-Frame-Options。frame-ancestors 'none' 等价于 DENY,'self' 等价于 SAMEORIGIN。

同时下发两者可获得最大兼容性:新浏览器按 CSP 执行,旧浏览器回退到 X-Frame-Options。

操作步骤

步骤一:Nginx 配置响应头

httpserverlocation 段中添加:

server {
    listen 443 ssl http2;
    server_name www.example.com;

    # 老浏览器兜底
    add_header X-Frame-Options "SAMEORIGIN" always;

    # 现代浏览器主策略,按需替换 'self' 为白名单域名
    add_header Content-Security-Policy "frame-ancestors 'self'" always;

    location / {
        proxy_pass http://127.0.0.1:8080;
    }
}

关键参数说明always 必须添加,否则响应码非 200/204/301/302/304 时该头不输出;若站点已存在 Content-Security-Policy,需将 frame-ancestors 合并进同一条指令,重复下发同名头时浏览器只取第一条,后续会被忽略。

校验并生效:

nginx -t
nginx -s reload

步骤二:Apache 配置响应头

# 确认 headers 模块已加载
a2enmod headers

<VirtualHost *:443>
    Header always set X-Frame-Options "SAMEORIGIN"
    Header always set Content-Security-Policy "frame-ancestors 'self'"
</VirtualHost>

检测配置语法并重启:

apachectl configtest
systemctl reload apache2   # CentOS/RHEL 使用 httpd

步骤三:Tomcat 配置过滤器

Tomcat 7 及以上内置 HttpHeaderSecurityFilter,在 conf/web.xml<web-app> 内添加:

<filter>
  <filter-name>httpHeaderSecurity</filter-name>
  <filter-class>org.apache.catalina.filters.HttpHeaderSecurityFilter</filter-class>
  <init-param>
    <param-name>antiClickJackingEnabled</param-name>
    <param-value>true</param-value>
  </init-param>
  <init-param>
    <param-name>antiClickJackingOption</param-name>
    <param-value>SAMEORIGIN</param-value>
  </init-param>
</filter>
<filter-mapping>
  <filter-name>httpHeaderSecurity</filter-name>
  <url-pattern>/*</url-pattern>
</filter-mapping>

步骤四:应用层声明式配置(Spring Security)

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.headers(headers -> headers
                .frameOptions(frame -> frame.sameOrigin())
                // CSP 与 X-Frame-Options 分开下发,避免指令冲突
                .contentSecurityPolicy(csp -> csp.policyDirectives("frame-ancestors 'self'"))
        );
        return http.build();
    }
}

若前端确实需要被指定合作方嵌套(如嵌入支付页),把策略放宽为白名单,而非直接使用 DENY:

add_header Content-Security-Policy "frame-ancestors 'self' https://partner.example.com; ..." always;

步骤五:辅助加固——Cookie 作用域收敛

即使嵌套被拦截,也应降低会话 Cookie 在跨站场景下的投递概率:

Set-Cookie: SESSION=xxxx; Path=/; Domain=www.example.com; Secure; HttpOnly; SameSite=Lax

配置验证

方法一,检查响应头是否下发:

curl -sI https://www.example.com/ | grep -iE 'x-frame-options|content-security-policy'

预期输出:

x-frame-options: SAMEORIGIN
content-security-policy: frame-ancestors 'self'

同时确认错误页也带上响应头(检验 always 是否生效):

curl -sI https://www.example.com/not-exist-page-404 | grep -i x-frame-options

方法二,构造本地 PoC 页面,放入任意站点目录后访问:

<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>clickjacking test</title></head>
<body>
<h3>若下方区域内看不到目标站点内容,说明防护生效</h3>
<iframe src="https://www.example.com/" width="900" height="400"></iframe>
</body></html>

浏览器控制台会出现 Refused to display 'https://www.example.com/' in a frame because it set 'X-Frame-Options' to 'sameorigin'.,即为拦截成功。

方法三,批量核查全站响应头,用于回归测试:

while read -r p; do
  printf '%s -> ' "$p"
  curl -sI "https://www.example.com$p" | grep -ic 'x-frame-options'
done < url_list.txt

常见问题

FAQ1:X-Frame-Options 与 CSP frame-ancestors 同时下发,以哪个为准?

支持 CSP Level 2 的浏览器只执行 frame-ancestors,并忽略同响应中的 X-Frame-Options;不支持 CSP 的旧浏览器才回退读取 X-Frame-Options。因此两者应保持语义一致,避免出现「新浏览器允许、旧浏览器禁止」的割裂状态。若必须二选一,现代站点优先使用 frame-ancestors,因为它支持域名白名单而不是只能全站开关。

FAQ2:配置完成后部分页面仍能被嵌套,如何排查?

按以下顺序定位:

  • 目标页由后端应用直接返回、绕过了 Nginx 的 location 匹配,检查是否存在独立的反代规则或独立端口。
  • 响应中已存在其他来源的 Content-Security-Policy,导致 frame-ancestors 被覆盖或合并失败,用 curl -sI 确认同名头只出现一次。
  • Nginx 子级 location 里又写了 add_header,会顶掉父级的 header 继承,需在子级重复声明。
  • CDN 缓存了旧版本响应,清理缓存后重新验证;静态资源被 CDN 直出时同样要在 CDN 边缘节点配置响应头。

FAQ3:使用了 DENY,页面内自己的 iframe 也被拦截了怎么办?

同源嵌套属于业务需求时应使用 SAMEORIGINframe-ancestors 'self';跨域但可信的嵌套使用白名单写法。切勿为了放行一个页面而全站降级为无头配置,可按 location 粒度对支付、后台等高危路径使用更严格的策略,对普通内容页使用白名单策略。

总结

点击劫持的防御成本极低、收益明确:一条响应头即可关闭整类攻击面。落地顺序建议为先在全站下发 X-Frame-Options: SAMEORIGINContent-Security-Policy: frame-ancestors 'self' 作为基线,再针对需要跨域嵌套的路径单独放宽为白名单,最后把「响应头是否存在」纳入上线回归检查清单。配置完成后务必用 curl 与 PoC 页面双重验证,并检查 CDN 边缘节点是否同样下发了响应头,否则源站加固会在缓存层被绕过。