Rust in Everything
基于 dioxus 0.7.10

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> 里放数据要做两件事:

  1. 编码成 JS 字面量。 serde_json::to_string 处理引号、反斜杠、换行;它不转义 /,所以还要把 </ 换成 <\/,</script> 才不会结束脚本。JSON 里的 <\/ 在 JS 字符串里就是 </,值不变(上面 d 读回来的正是原字符串)。
  2. 用 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 写入;或者把数据放进属性。

💬 关联讨论

发起讨论
加载中...