Rust in Everything
基于 pulldown-cmark 0.13.5

push_html 原样输出 <script>

主线位置:输出主线的最后一步。HtmlWriter::run 把 Event::Html 和 Event::InlineHtml 原样写出,链接地址只做转义、不看协议。转义保证什么、不保证什么,在转义里讲过;这一页讲把它直接用在用户输入上的后果和修法。

flowchart TB
  user["用户输入"] --> p["Parser"]
  p --> h["Html 和 InlineHtml"]
  p --> l["Link 和 Image"]
  h --> w["push_html 原样写"]
  l --> e["escape_href 只转义"]
  w --> x["脚本能执行"]
  e --> x
  p --> f["事件过滤"]
  f --> safe["HTML 变文字"]

实验在 exp-pulldown-cmark/pita-lab,bin p03_xss(release),输出存在 out-p03.txt。M2 Max、macOS 27、rustc 1.99.0,pulldown-cmark 0.13.5,选项只开 ENABLE_HEADING_ATTRIBUTES(前九条开不开结果都一样)。

现象

评论区用 Parser::new_ext 加 html::push_html 渲染用户写的 Markdown,十条输入,每条的 push_html 输出:

--- "<script>alert(1)</script>\n"
push_html  <script>alert(1)</script>
--- "hi <img src=x onerror=alert(1)>\n"
push_html  <p>hi <img src=x onerror=alert(1)></p>
--- "[click](javascript:alert(1))\n"
push_html  <p><a href="javascript:alert(1)">click</a></p>
--- "<javascript:alert(1)>\n"
push_html  <p><a href="javascript:alert(1)">javascript:alert(1)</a></p>
--- "![x](javascript:alert(1))\n"
push_html  <p><img src="javascript:alert(1)" alt="x" /></p>
--- "[a](jav&#x61;script:alert(1))\n"
push_html  <p><a href="javascript:alert(1)">a</a></p>
--- "[a](JaVaScRiPt:alert(1))\n"
push_html  <p><a href="JaVaScRiPt:alert(1)">a</a></p>
--- "[a](<jav&#9;ascript:alert(1)>)\n"
push_html  <p><a href="jav%09ascript:alert(1)">a</a></p>
--- "[ok](/docs) <https://example.com> ![p](/a.png)\n"
push_html  <p><a href="/docs">ok</a> <a href="https://example.com">https://example.com</a> <img src="/a.png" alt="p" /></p>
--- "## 标题 {onmouseover=alert(1)}\n"
push_html  <h2 onmouseover="alert(1)">标题</h2>

除了第八、九条,都能在浏览器里执行脚本:HTML 块、行内 HTML 的事件属性、javascript: 出现在链接、自动链接、图片里,大小写混写和实体编码也一样。最后一条没有一个尖括号,却写出了一个事件属性。

原因

四处,都是有意的行为,不是漏洞:

  • 原始 HTML 是 Markdown 语法。 CommonMark 规定 HTML 块和行内 HTML 原样通过。解析器产出 Event::Html / Event::InlineHtml,HtmlWriter::run 里只有一行处理它:

                  Html(html) | InlineHtml(html) => {
                      self.write(&html)?;
                  }
    

    Options 里没有关掉原始 HTML 的位,GFM 的 tagfilter 也没有实现(ENABLE_GFM 只开了提示块)。

  • 地址只转义,不看协议。 链接和图片的地址经 escape_href 写进属性:不在安全表里的字节做百分号编码,&、' 换成实体。它保证地址逃不出引号,不判断 javascript: 能不能出现。

  • 实体在解析时已经解开。 jav&#x61;script: 到事件里就是 javascript::解析链接地址时,实体和反斜杠转义就被解开了(代码段、HTML、实体)。在源文上搜 javascript: 抓不到它。

  • 标题属性是任意的键值对。 开了 ENABLE_HEADING_ATTRIBUTES,标题末尾 {...} 里除了 #id、.class,其余的词都按 key=value 或 key 收进 Tag::Heading 的 attrs(parse_inside_attribute_block),不限制键名。start_tag 把键和值都过一遍 escape_html 再写成属性:转义挡住了引号,挡不住 onmouseover 这个名字本身。

tag 之后 main 上的 html.rs 只加了新扩展的标签(容器块、<mark>),这几行没变;main 上的 parse_inside_attribute_block 只是把字符串里的 NUL 换掉,仍然接受任意键名。README 和 guide 都没有提净化。

修法

信任边界在调用方。在事件流上做,比在源文或 HTML 字符串上做准:解析器已经把「这是 HTML」「这是链接地址」「这是标题属性」分好了类,地址也已经解码。转义给过一个过滤链接的版本,这里把图片和标题属性也算进去:

fn allowed(url: &str) -> bool {
  match url.split_once(':') {
    // a ':' after '/', '?' or '#' is not a scheme separator
    Some((scheme, _)) if !scheme.contains(['/', '?', '#']) => {
      matches!(scheme.to_ascii_lowercase().as_str(), "http" | "https" | "mailto")
    }
    _ => true,
  }
}

// Raw HTML becomes text; links and images with a scheme outside the allow
// list are dropped (link text and image alt text stay as plain text).
fn sanitize<'a>(events: impl Iterator<Item = Event<'a>>) -> impl Iterator<Item = Event<'a>> {
  let mut dropped: Vec<bool> = Vec::new();
  events.filter_map(move |ev| match ev {
    Event::Html(s) | Event::InlineHtml(s) => Some(Event::Text(s)),
    // ENABLE_HEADING_ATTRIBUTES: `{key=value}` becomes any attribute.
    Event::Start(Tag::Heading { level, .. }) => Some(Event::Start(Tag::Heading {
      level,
      id: None,
      classes: Vec::new(),
      attrs: Vec::new(),
    })),
    Event::Start(Tag::Link { ref dest_url, .. }) | Event::Start(Tag::Image { ref dest_url, .. }) => {
      let ok = allowed(dest_url);
      dropped.push(!ok);
      ok.then_some(ev)
    }
    Event::End(TagEnd::Link | TagEnd::Image) => {
      if dropped.pop() == Some(true) { None } else { Some(ev) }
    }
    _ => Some(ev),
  })
}

End(Link) 和 End(Image) 不带地址,丢没丢对应的 Start 要自己用栈记;链接和图片可以互相嵌套,栈是共用的。html::push_html(&mut out, sanitize(Parser::new_ext(src, opts))) 的输出:

sanitized  &lt;script&gt;alert(1)&lt;/script&gt;
sanitized  <p>hi &lt;img src=x onerror=alert(1)&gt;</p>
sanitized  <p>click</p>
sanitized  <p>javascript:alert(1)</p>
sanitized  <p>x</p>
sanitized  <p>a</p>
sanitized  <p>a</p>
sanitized  <p>a</p>
sanitized  <p><a href="/docs">ok</a> <a href="https://example.com">https://example.com</a> <img src="/a.png" alt="p" /></p>
sanitized  <h2>标题</h2>

原始 HTML 变成被转义的文字,坏链接和坏图片只留文字,标题的 id、类名和属性全部清掉,正常的相对地址、https 链接和图片不受影响。标题属性清得这么狠,是因为用户给的 id 也能和页面上已有的 id 撞车;不需要的话,渲染用户输入时干脆不开 ENABLE_HEADING_ATTRIBUTES,花括号就留在标题文字里。这个过滤把所有原始 HTML 都降成了文字;要放行一部分标签(<kbd>、<details>),更稳妥的是对 push_html 的输出再过一遍白名单式的 HTML 净化库。实验环境的 cargo 缓存里没有这类库,本页没有测。本站不走 push_html,它在事件层怎么处理 HTML 和链接见本站的 Markdown 渲染。

📝NOTE
没有蠢问题

问: [a](<jav&#9;ascript:alert(1)>) 那一条,push_html 输出的是 href="jav%09ascript:alert(1)"。浏览器不是会把网址里的制表符删掉吗?为什么不把它算作危险?

答: 浏览器删的是字面的制表符,不是 %09。&#9; 在解析时解成一个真的制表符,escape_href 把它编码成 %09;% 不能出现在协议名里,整个地址就成了相对地址。用 Chrome 156 headless 读两个链接的 protocol:href="jav%09ascript:alert(1)" 是 file:(页面本身的协议,说明被当成相对地址),属性里直接写一个制表符的 href="jav ascript:alert(1)" 是 javascript:。百分号编码在这里恰好救了一次;过滤照样拦下它,因为协议名 jav\tascript 不在白名单里。

怎么检查

给渲染函数写一条测试,用上面十条输入(再加上你自己见过的写法),用和线上相同的选项跑一遍,对输出做粗查:

// Crude output check for tests: a <script> tag, an on* attribute inside
// any tag, or a script URL in href/src.
fn dangerous(html: &str) -> bool {
  let h = html.to_ascii_lowercase();
  let on_attr = h.split('<').skip(1).any(|tag| {
    let tag = tag.split('>').next().unwrap_or("");
    tag.split_whitespace().skip(1).any(|a| a.starts_with("on") && a.contains('='))
  });
  h.contains("<script")
    || on_attr
    || h.contains("href=\"javascript:")
    || h.contains("src=\"javascript:")
}
dangerous outputs: push_html 8/10, sanitized 0/10

这种粗查只能防回归,证明不了安全。另外两条:

  • 在代码里搜 push_html、write_html_fmt、write_html_io,看输入是否可能来自用户;来自用户而前面没有过滤层,就是这个坑。
  • 渲染用户输入的选项里有 ENABLE_HEADING_ATTRIBUTES 的,确认过滤层清掉了 Tag::Heading 的 attrs。
  • 检查协议时看事件里的 dest_url,不要在源文上做字符串匹配。

小结

  • push_html 原样写出 Event::Html 和 Event::InlineHtml;javascript: 在链接、自动链接、图片里都会进属性;实体编码的协议在解析时已经解开;开了 ENABLE_HEADING_ATTRIBUTES,{onmouseover=...} 直接成了标题的属性。0.13.5 和 main 都这样,没有关掉原始 HTML 的选项。
  • 转义只保证内容逃不出标签和属性,不判断内容安不安全。
  • 修法:在事件流上把原始 HTML 变成文字,按白名单检查链接和图片的协议,清掉标题属性;要保留部分 HTML,就对输出再做白名单净化。

💬 关联讨论

发起讨论
加载中...