点击劫持(Clickjacking)防御的核心手段是在响应头中声明页面不可被第三方站点嵌套,X-Frame-Options 与 CSP 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 配置响应头
在 http、server 或 location 段中添加:
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 也被拦截了怎么办?
同源嵌套属于业务需求时应使用 SAMEORIGIN 或 frame-ancestors 'self';跨域但可信的嵌套使用白名单写法。切勿为了放行一个页面而全站降级为无头配置,可按 location 粒度对支付、后台等高危路径使用更严格的策略,对普通内容页使用白名单策略。
总结
点击劫持的防御成本极低、收益明确:一条响应头即可关闭整类攻击面。落地顺序建议为先在全站下发 X-Frame-Options: SAMEORIGIN 与 Content-Security-Policy: frame-ancestors 'self' 作为基线,再针对需要跨域嵌套的路径单独放宽为白名单,最后把「响应头是否存在」纳入上线回归检查清单。配置完成后务必用 curl 与 PoC 页面双重验证,并检查 CDN 边缘节点是否同样下发了响应头,否则源站加固会在缓存层被绕过。