bigywater.tech
Django · 126

Clickjacking 防护与 X-Frame-Options

点击劫持靠的是把你的页面用透明 iframe 盖在别人的页面上。Django 用 X-Frame-Options 防它;Django 6 之后也可以用 CSP 的 frame-ancestors。这篇用真实的 Chrome 嵌入同源和跨源的 iframe,看每种设置的实际效果。

默认 DENY
XFrameOptionsMiddleware 给所有响应加 X-Frame-Options: DENY
同源 vs 跨源
SAMEORIGIN 与 frame-ancestors 'self':同源能嵌、跨源被拦
真浏览器
5 种页面 × 同源 / 跨源 10 个 iframe 在 Chrome 里逐个检查

服务端发出的头

实测 · 本机执行输出
  /default/    X-Frame-Options: DENY         Content-Security-Policy: (没有)
  /same/       X-Frame-Options: SAMEORIGIN   Content-Security-Policy: (没有)
  /exempt/     X-Frame-Options: (没有)         Content-Security-Policy: (没有)
  /csp_self/   X-Frame-Options: (没有)         Content-Security-Policy: frame-ancestors 'self'
  /csp_none/   X-Frame-Options: (没有)         Content-Security-Policy: frame-ancestors 'none'

单个视图可以用装饰器覆盖全局设置:@xframe_options_sameorigin、@xframe_options_exempt、@xframe_options_deny。

全局设置

实测 · 本机执行输出
  X_FRAME_OPTIONS = DENY       → 响应头 DENY
  X_FRAME_OPTIONS = SAMEORIGIN → 响应头 SAMEORIGIN

浏览器里的实际结果

实测 · Chrome + chrome-devtools
  页面                                            同源 iframe(脚本读 contentDocument)   跨源 iframe(看截图)
  default(X-Frame-Options: DENY)                  被拦截(读不到文档)                     被拦截(灰色破损图标)
  same(X-Frame-Options: SAMEORIGIN)               加载成功(读到「sameorigin 页面内容」)   被拦截(灰色破损图标)
  exempt(不带 X-Frame-Options)                     加载成功                               加载成功(看到页面内容)
  csp_self(exempt + frame-ancestors 'self')       加载成功                               被拦截(空白)
  csp_none(exempt + frame-ancestors 'none')       被拦截(读不到文档)                     被拦截(空白)
  控制台消息(节选):Refused to display 'http://localhost:9540/' in a frame because it set 'X-Frame-Options' to 'deny'. /
    Framing 'http://127.0.0.1:9540/' violates the following Content Security Policy directive: "frame-ancestors 'self'". The request has been blocked. /
    Framing 'http://localhost:9540/' violates the following Content Security Policy directive: "frame-ancestors 'none'". The request has been blocked.

同源 iframe 的结果是用脚本读 contentDocument(能读到页面文字即加载成功,读不到说明被拦截);跨源 iframe 脚本读不到内容,所以是我看截图判断的:被拦截的显示为灰色破损图标或空白,加载成功的能看到页面内容。这一节是在浏览器里手工检查后记录的,不是脚本自动生成的。

该选哪个

X-Frame-Options 只能表达「禁止 / 同源」,CSP 的 frame-ancestors 还能指定允许哪些具体的站点来嵌入。两个同时存在时浏览器如何取舍,本文没有验证(我只测了单独出现的情形)。

分步演示

防点击劫持
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 / Python 3.14.7 / Django 6.1.1(默认用 SQLite,其它数据库会在文中注明),用 django.test.Client 或真实的本机服务器进程实测;计时用 time.perf_counter,数字每次运行会有小幅抖动。runserver(--noreload)+ Chrome(chrome-devtools);父页面用 localhost、跨源 iframe 用 127.0.0.1(端口相同、主机名不同即为不同源)
    • 没有验证:移动端浏览器、旧浏览器对 frame-ancestors 的支持、X-Frame-Options 与 CSP 同时出现时的优先级、ALLOW-FROM(已废弃)、点击劫持的其它形式(拖拽、光标欺骗)
    京ICP备2026067502号-1京公网安备11010502063487号