点击劫持靠的是把你的页面用透明 iframe 盖在别人的页面上。Django 用 X-Frame-Options 防它;Django 6 之后也可以用 CSP 的 frame-ancestors。这篇用真实的 Chrome 嵌入同源和跨源的 iframe,看每种设置的实际效果。
/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
页面 同源 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 还能指定允许哪些具体的站点来嵌入。两个同时存在时浏览器如何取舍,本文没有验证(我只测了单独出现的情形)。