框架策略明确谁可以嵌入当前页面
点击劫持利用用户对界面的误判,让一个动作落到并非其真实意图的控件上。恶意父页面可以把被嵌入的应用放在误导性视觉层后面,使用户点击实际进入该应用中的真实按钮。父页面不必能够读取子页面的文档结构,用户仍然可以与框架里的内容交互。因此,同源读取限制不能替代应用是否允许被嵌入的独立决定,也不能仅靠看不到对方文档就判断点击过程安全。 被嵌入的响应可以通过内容安全策略禁止任何祖先嵌入,或者明确列出允许的祖先来源。这里写的应当是获准包含当前文档的页面来源。假设商户页面嵌入支付页面,应由支付页面的策略允许商户来源;仅列出支付组件自己的来源,并不能表达商户有权嵌入它。配置之前先明确哪一份响应属于子页面、哪个来源是父页面,可以避免把允许列表方向写反。 把限制放到正确的响应上 嵌入祖先指令回答谁能嵌入当前文档,框架来源指令则回答当前文档自己可以加载哪些框架。给子页面设置严格的 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 页面同样渲染,因为它限制的是子页面接下来自己加载的框架,而不是正在验证的父子嵌入关系。 ...