点击劫持利用用户对界面的误判,让一个动作落到并非其真实意图的控件上。恶意父页面可以把被嵌入的应用放在误导性视觉层后面,使用户点击实际进入该应用中的真实按钮。父页面不必能够读取子页面的文档结构,用户仍然可以与框架里的内容交互。因此,同源读取限制不能替代应用是否允许被嵌入的独立决定,也不能仅靠看不到对方文档就判断点击过程安全。

被嵌入的响应可以通过内容安全策略禁止任何祖先嵌入,或者明确列出允许的祖先来源。这里写的应当是获准包含当前文档的页面来源。假设商户页面嵌入支付页面,应由支付页面的策略允许商户来源;仅列出支付组件自己的来源,并不能表达商户有权嵌入它。配置之前先明确哪一份响应属于子页面、哪个来源是父页面,可以避免把允许列表方向写反。

把限制放到正确的响应上

嵌入祖先指令回答谁能嵌入当前文档,框架来源指令则回答当前文档自己可以加载哪些框架。给子页面设置严格的 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。