点击劫持利用用户对界面的误判,让一个动作落到并非其真实意图的控件上。恶意父页面可以把被嵌入的应用放在误导性视觉层后面,使用户点击实际进入该应用中的真实按钮。父页面不必能够读取子页面的文档结构,用户仍然可以与框架里的内容交互。因此,同源读取限制不能替代应用是否允许被嵌入的独立决定,也不能仅靠看不到对方文档就判断点击过程安全。
被嵌入的响应可以通过内容安全策略禁止任何祖先嵌入,或者明确列出允许的祖先来源。这里写的应当是获准包含当前文档的页面来源。假设商户页面嵌入支付页面,应由支付页面的策略允许商户来源;仅列出支付组件自己的来源,并不能表达商户有权嵌入它。配置之前先明确哪一份响应属于子页面、哪个来源是父页面,可以避免把允许列表方向写反。
把限制放到正确的响应上
嵌入祖先指令回答谁能嵌入当前文档,框架来源指令则回答当前文档自己可以加载哪些框架。给子页面设置严格的 frame-src,并不会阻止另一个父页面嵌入它。默认来源全部拒绝,也不会作为 frame-ancestors 的回退值。这些指令控制的是关系中的不同方向,不能仅因为名字都涉及框架,就把其中一个当成另一个的替代品。
嵌入祖先指令不能通过 CSP 的 meta 元素生效,必须通过响应头交付。遇到多层嵌套时,每一级祖先都需要符合策略,而不只是直接父级。某个组件即使可以合法放在指定应用中,当该应用本身又被放进未经许可的外层框架时,仍可能被阻止。因此制定允许列表时,要审阅整个预期嵌入链,不能只凭最内层组件和它紧邻的容器作判断。
坏掉的样子
一次部署可能只给容器页面增加了策略,却让真正敏感的子页面响应保持不受限制;另一次部署可能把策略写入 HTML 元数据,看到配置文字存在就误以为浏览器会执行。应检查实际被框架加载的响应,并验证浏览器是否渲染。仅收到二百状态,既不能证明嵌入成功,也不能证明没有框架防护,因为浏览器可以收到文档后,拒绝在当前祖先环境中显示它。
互相冲突的响应头也可能误导审阅者。在支持这套规则的现代浏览器里,强制执行的嵌入祖先策略优先于该响应的 X-Frame-Options。给允许某个父来源的策略再附加一个 DENY,并不必然得到期望中的更严格结果。如果确实需要保留多个响应头,应让它们与实际受众和浏览器支持约定一致,不能假定它们都像多份 CSP 那样自然取限制交集。
快速验证
将代码保存为 frame-policy.py,执行 python3 frame-policy.py –serve,并打开打印出的父页面地址。本次实际使用 Python 3.14.4 与 Chrome 152。父服务和子服务位于不同的本机回环端口,六份子响应只有合成说明文字,以及表示成功渲染的消息。父页面只接受预期子来源发来的消息并展示结果,结束后按控制键加 C 关闭两个临时服务,不会操作真实账号或业务控件。
import json
import sys
from http.server import BaseHTTPRequestHandler, HTTPServer
from threading import Event, Thread
from urllib.request import urlopen
paths = ["open", "csp-block", "xfo-block", "csp-allow", "meta", "frame-src"]
requests = []
class Child(BaseHTTPRequestHandler):
def log_message(self, *args):
pass
def do_GET(self):
name = self.path.lstrip("/")
assert name in paths
requests.append(name)
meta = ('<meta http-equiv="Content-Security-Policy" content="frame-ancestors \'none\'">'
if name == "meta" else "")
body = ("<!doctype html><meta charset=utf-8>" + meta + "<p>Rendered: " + name + "</p>"
"<script>parent.postMessage({frame:" + json.dumps(name) + "}," + json.dumps(app_origin) + ");</script>").encode()
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
if name == "csp-block":
self.send_header("Content-Security-Policy", "frame-ancestors 'none'")
elif name == "xfo-block":
self.send_header("X-Frame-Options", "DENY")
elif name == "csp-allow":
self.send_header("Content-Security-Policy", "frame-ancestors " + app_origin)
self.send_header("X-Frame-Options", "DENY")
elif name == "frame-src":
self.send_header("Content-Security-Policy", "frame-src 'none'")
self.end_headers()
self.wfile.write(body)
class Parent(BaseHTTPRequestHandler):
def log_message(self, *args):
pass
def do_GET(self):
if self.path == "/events":
body = json.dumps(requests).encode()
kind = "application/json"
else:
body = ("""<!doctype html><meta charset="utf-8"><title>Frame policy lab</title>
<h1>Which child responses can this parent embed?</h1><pre id="result">Waiting for frame messages...</pre>
<button id="check">Show server requests</button><div id="frames"></div><script>
const child=""" + json.dumps(child_origin) + ";const names=" + json.dumps(paths) + """;
window.received=[];
window.snapshot=async()=>{
const result={rendered:[...window.received].sort(),serverRequests:await (await fetch('/events')).json()};
document.getElementById('result').textContent=JSON.stringify(result,null,2);return result;
};
addEventListener('message',event=>{
if(event.origin===child && names.includes(event.data?.frame)) {
window.received.push(event.data.frame);
document.getElementById('result').textContent=JSON.stringify(window.received,null,2);
}
});
document.getElementById('check').onclick=()=>window.snapshot();
for(const name of names) {
const frame=document.createElement('iframe');frame.src=child+'/'+name;frame.title=name;
frame.width=240;frame.height=100;document.getElementById('frames').append(frame);
}
</script>""").encode()
kind = "text/html; charset=utf-8"
self.send_response(200)
self.send_header("Content-Type", kind)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
child = HTTPServer(("127.0.0.1", 0), Child)
app = HTTPServer(("127.0.0.1", 0), Parent)
child_origin = f"http://127.0.0.1:{child.server_port}"
app_origin = f"http://127.0.0.1:{app.server_port}"
threads = [Thread(target=server.serve_forever, daemon=True) for server in (child, app)]
for thread in threads:
thread.start()
try:
for name in paths:
with urlopen(child_origin + "/" + name, timeout=5) as response:
assert response.status == 200
if name == "csp-allow":
assert response.headers["Content-Security-Policy"] == "frame-ancestors " + app_origin
assert response.headers["X-Frame-Options"] == "DENY"
response.read()
requests.clear()
print(json.dumps({"app_url": app_origin, "child_origin": child_origin,
"wire_responses": 6}), flush=True)
if "--serve" in sys.argv:
Event().wait()
except KeyboardInterrupt:
pass
finally:
for server in (child, app):
server.shutdown()
server.server_close()
for thread in threads:
thread.join(timeout=5)
开放子页面应正常渲染;csp-block 被禁止祖先嵌入策略拒绝,xfo-block 被 DENY 拒绝。csp-allow 虽然同时发送 DENY,但强制祖先策略明确允许当前父来源,因此正常渲染。meta 页面也会渲染,因为该交付方式不能执行嵌入祖先指令;frame-src 页面同样渲染,因为它限制的是子页面接下来自己加载的框架,而不是正在验证的父子嵌入关系。
预期结果是四条成功渲染消息与六条服务端请求记录。点击页面中的查看服务端请求按钮,可以同时核对两者;浏览器检查还记录了两个被拒框架对应的响应阻止错误。单独运行 Python 探针时,六份响应都会返回二百状态,说明只读响应头不能替代渲染验证。本例只验证一个浏览器中的单层嵌入,没有验证真实账号操作或全部嵌套集成。发布前还应让合法路径与被拒父来源经过实际代理和内容分发层,确认目标子响应仍然保有预期策略。
延伸阅读
来源
- MDN CSP frame-ancestors,允许的嵌入父级、祖先检查及不支持的 meta 交付方式,已核验 2026-09-13。
- MDN CSP frame-src,控制当前文档自己加载的框架,已核验 2026-09-13。
- MDN X-Frame-Options,禁止嵌入与同源嵌入限制,已核验 2026-09-13。
- W3C CSP Level 3,嵌入祖先策略的执行及其与 X-Frame-Options 的关系,已核验 2026-09-13。