script 里的动态文本不转义
主线位置:首屏主线的「
dioxus_ssr::Renderer带水合标记」那一步。渲染器决定每段文本转不转义、要不要包水合注释;script和style下面的文本不转义。转义规则的全表见 dioxus-ssr的「转义」,本页只讲往<script>里塞数据时会出的两个问题。
flowchart TB q["请求里的字符串"] --> txt["script 的动态文本"] txt --> raw["不转义,原样写出"] raw --> brk["遇到 script 结束标签"] brk --> inj["后面的标签被浏览器执行"] txt --> mark["fullstack 前面加 node-id 注释"] mark --> cmt["这一行成了 JS 注释"] q --> fix["JSON 编码并替换斜杠"] fix --> inner["dangerous_inner_html"] inner --> ok["原样写出,能执行"]
本页实验在 M2 Max、macOS 27、rustc 1.99.0、Chrome 156 上运行,依赖 dioxus = "=0.7.10"(router、fullstack),dx 0.7.9 --release 构建的 fullstack 应用,外加一个原生程序。浏览器一侧用 headless Chrome,经 DevTools Protocol 打开页面、读 window 上的变量。
现象
一个路由把查询参数 q 写进四个 <script>,写法各不相同:
#[component]
fn Esc(q: String) -> Element {
// JSON-encode, then break up every "</"
let json = serde_json::to_string(&q).unwrap_or_default().replace("</", "<\\/");
rsx! {
// a: the raw value as script text
script { "window.__a = \"{q}\";" }
// b: escaped, but still a dynamic text child of script
script { "window.__b = {json};" }
// c: same as b, with a line break first
script { "\nwindow.__c = {json};" }
// d: escaped, written with dangerous_inner_html
script { dangerous_inner_html: "window.__d = {json};" }
p { id: "out", "q = {q}" }
button { id: "inc", onclick: move |_| {}, "+" }
}
}
请求 /esc?q=</script><script>window.__pwned=1</script>(URL 编码后发出)。服务端发来的这四段:
<script data-node-hydration="0"><!--node-id1-->window.__a = "</script>
<script>window.__pwned=1</script>";<!--#--></script>
<script data-node-hydration="2"><!--node-id3-->window.__b = "<\/script><script>window.__pwned=1<\/script>";<!--#--></script>
<script data-node-hydration="4"><!--node-id5-->
window.__c = "<\/script><script>window.__pwned=1<\/script>";<!--#--></script>
<script data-node-hydration="6">window.__d = "<\/script><script>window.__pwned=1<\/script>";</script>
(为了好读,在每个 </script><script 之间断了行,原文是一行。)浏览器里水合完成后读这几个变量:
({ pwned: window.__pwned, a: window.__a, b: window.__b, c: window.__c, d: window.__d }) = {"pwned":1,"c":"</script><script>window.__pwned=1</script>","d":"</script><script>window.__pwned=1</script>"}
- a:
q里的</script>原样写出,浏览器在那里结束了第一个脚本,后面的<script>window.__pwned=1</script>成了一个真正的脚本并且执行了:pwned是 1。查询参数是攻击者能控制的,这就是一个反射型 XSS。 - b:值已经安全了(
<\/script>在 JS 字符串里还是</script>,HTML 解析器却不会在这里结束脚本),但__b没有被设置。 - c:和 b 只差开头一个换行,
__c设上了。 - d:用
dangerous_inner_html写同样的内容,没有任何注释,__d设上了。
b 和 c 的差别来自 fullstack 加的水合标记。用原生程序对同一种写法分别调 dioxus_ssr::render 和 pre_render(后者打开水合标记,fullstack 服务端用的就是这种模式):
fn app() -> Element {
let q = "</script><script>alert(1)</script>".to_string();
rsx! {
script { "window.__a = \"{q}\";" }
script { "window.__static = 1;" }
}
}
fn main() {
let mut dom = VirtualDom::new(app);
dom.rebuild_in_place();
println!("render: {}", dioxus_ssr::render(&dom));
println!("pre_render: {}", dioxus_ssr::pre_render(&dom));
}
render: <script>window.__a = "</script><script>alert(1)</script>";</script><script>window.__static = 1;</script>
pre_render: <script data-node-hydration="0"><!--node-id1-->window.__a = "</script><script>alert(1)</script>";<!--#--></script><script data-node-hydration="2">window.__static = 1;</script>
两种模式都不转义;pre_render 还在动态文本前后加了 <!--node-id1--> 和 <!--#-->,静态文本没有。
出处
不转义是有意的。模板切缓存时(cache.rs):
// Escape the text in children if this is not a style or script tag. If it is a style
// or script tag, we want to allow the user to write code inside the tag
let escape_text = match *tag {
"style" | "script" => EscapeText::NoEscape,
_ => EscapeText::Escape,
};
script 里的东西被当成代码,写代码的人负责它的内容;ssr/tests/escape.rs 里有一个测试就叫 don_t_escape_dynamic_scripts。这个规则的来历(以前连脚本里的 < 都会被换成实体,代码就坏了)见 dioxus-ssr的冷知识。
水合注释是另一处:渲染动态文本时(renderer.rs),pre_render 模式下不管父元素是什么,都在前后写 <!--node-id{N}--> 和 <!--#-->,好让客户端水合时找到这个文本节点(dioxus-ssr的「三种水合标记」):
DynamicNode::Text(text) => {
// in SSR, we are concerned that we can't hunt down the right text node since they might get merged
if self.pre_render {
write!(buf, "<!--node-id{}-->", self.dynamic_node_id)?;
self.dynamic_node_id += 1;
}
在 <script> 里,HTML 解析器不认注释,这些字符原样成为脚本源码的一部分。而浏览器的 JS(经典脚本,不是 module)把 <!-- 当作单行注释的开头,于是从 <!--node-id3--> 到行尾都是注释,window.__b = ... 没有执行。c 的换行让赋值语句到了第二行,躲过了注释;行尾的 <!--#--> 也是注释,正好无害。dangerous_inner_html 是属性,不走动态文本那条路,没有注释。
修法
往 <script> 里放数据要做两件事:
- 编码成 JS 字面量。
serde_json::to_string处理引号、反斜杠、换行;它不转义/,所以还要把</换成<\/,</script>才不会结束脚本。JSON 里的<\/在 JS 字符串里就是</,值不变(上面 d 读回来的正是原字符串)。 - 用
dangerous_inner_html写进去,避开水合注释。名字里的 dangerous 在这里是字面意思:它原样写出,第 1 步不能省。
let json = serde_json::to_string(&q).unwrap_or_default().replace("</", "<\\/");
rsx! {
script { dangerous_inner_html: "window.__d = {json};" }
}
不必放进脚本的数据,更简单的做法是放进属性:属性值总会被转义(data-q: "{q}"),JS 用 getAttribute 读。只在客户端用到的数据,干脆在 use_effect 里用 document::eval 加 send 传过去,不经过 HTML。
style 也一样:动态文本不转义,插进去的 </style> 同样能结束样式表。用户控制的值不要拼进 style { },改用元素的 style 属性(属性会被转义)。
怎么确认没中招
- 搜
script {和style {后面紧跟带{..}插值的字符串字面量。没有插值的静态文本不加注释、内容是自己写的,可以不管。 - 对接收查询参数或用户输入的页面,用一个含
</script><script>window.__pwned=1</script>的值请求,再在浏览器里看window.__pwned:上面的 a 写法会得到 1。 - 对 fullstack 页面 curl 一次,看
<script后面有没有紧跟<!--node-id:有,就说明那一行脚本在浏览器里是注释。
小结
script、style里的文本,静态和动态都不转义;插进去的</script>会提前结束脚本,后面的内容被当成 HTML,可以注入新的脚本。- fullstack 渲染时,
script里的动态文本前面多了<!--node-id..-->,经典脚本把这一行当成注释,赋值不执行。 - 修法:
serde_json编码,再把</换成<\/,用dangerous_inner_html写入;或者把数据放进属性。