mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-24 10:27:44 -06:00
Bump quick-xml to 0.41: the namespace-declaration DoS was reachable (RUSTSEC-2026-0195) (#363)
`crates/docx/src/xml.rs` parses untrusted document XML through `quick_xml::reader::NsReader`. In 0.38.4 that is RUSTSEC-2026-0195 (CVSS 7.5): the namespace resolver pushes a binding for every `xmlns` / `xmlns:*` declaration on a start tag into an unbounded `Vec`, with no limit on the number of declarations. None of the reader's existing caps bound that path - MAX_PART (256 MiB), MAX_TOTAL (512 MiB), MAX_ENTRIES, MAX_DEPTH and MAX_ELEMENTS bound part size, entry count, nesting and element count, not declarations per tag. Measured before this change, `wordcraft-cli convert` on a crafted .docx whose `word/document.xml` is one start tag with 21,845,333 `xmlns:a="u"` declarations: 508 KB on disk -> 262 MB decompressed (under MAX_PART) -> 1.19 GB peak RSS, exit 0: the document opened and converted without any warning. At the MAX_PART/MAX_TOTAL ceiling that is roughly 2.4 GB of resident memory from about a megabyte of download, i.e. an OOM kill with unsaved work open. Nothing panics, so the `Session::run` guard does not catch it. 0.41.0 caps namespace declarations per start tag at 256 (`DEFAULT_MAX_DECLARATIONS_PER_ELEMENT`, raisable through `NamespaceResolver::set_max_declarations_per_element`) and returns `NamespaceError::TooManyDeclarations` beyond it. The same fixture now fails in about a second with a clear error and no namespace allocation: wordcraft-cli: malformed XML: start tag declares more than 256 namespace bindings; raise the limit with NamespaceResolver::set_max_declarations_per_element Two API adaptations for 0.41 (0.x bumps are allowed to break): - `resolve_attribute` moved off `NsReader` on to the resolver: `r.resolve_attribute(key)` -> `r.resolver_mut().resolve_attribute(key)`. - `Attribute::unescape_value` is deprecated in favour of `normalized_value`, which also applies XML attribute-value normalization as the spec requires; attribute values in a Word part are UTF-8 here (UTF-16 parts are transcoded first). Regression test: `crates/docx/src/xml.rs::too_many_namespace_declarations_is_error` asserts a tag with 10,000 declarations is an error. It fails on 0.38.4 and passes on 0.41.0. This also clears the second quick-xml advisory, RUSTSEC-2026-0194 (quadratic duplicate-attribute scan). That one was not reachable here - the reader already passes `with_checks(false)` at xml.rs:257, and a 60,000-attribute tag parses in the same 65 ms as a 20,000-attribute one - but `cargo audit` is clean afterwards. Verified: `cargo xtask ci`; `cargo audit` (no vulnerabilities left, the two unmaintained-crate warnings are `krilla`'s `rustybuzz`/`ttf-parser` and are unrelated); 32 docx tests plus the malformed-input suite; and 21 crafted hostile documents through `wordcraft-cli convert` with zero panics before and after. One residual, unchanged by this commit: the fixture above still costs about 509 MB, because MAX_PART lets one part decompress to 256 MiB and every part is held in memory while the package is read. That is the existing, intended bound; what this commit removes is the namespace allocation on top of it.
This commit is contained in:
Generated
+3
-12
@@ -3096,15 +3096,6 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.38.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.41.0"
|
||||
@@ -4404,7 +4395,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quick-xml 0.41.0",
|
||||
"quick-xml",
|
||||
"quote",
|
||||
]
|
||||
|
||||
@@ -5079,7 +5070,7 @@ dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
"proptest",
|
||||
"quick-xml 0.38.4",
|
||||
"quick-xml",
|
||||
"sha1",
|
||||
"sha2",
|
||||
"thiserror 2.0.21",
|
||||
@@ -5133,7 +5124,7 @@ dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
"proptest",
|
||||
"quick-xml 0.38.4",
|
||||
"quick-xml",
|
||||
"wordcraft-doc",
|
||||
"wordcraft-geom",
|
||||
"zip",
|
||||
|
||||
+1
-1
@@ -49,7 +49,7 @@ harfrust = "0.13"
|
||||
vello_cpu = "0.2"
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "tiff"] }
|
||||
krilla = { version = "0.8", features = ["pdf"] }
|
||||
quick-xml = "0.38"
|
||||
quick-xml = "0.41"
|
||||
zip = { version = "3", default-features = false, features = ["deflate"] }
|
||||
cfb = "0.15"
|
||||
# Password-protected (agile-encrypted) Word documents: pure-Rust RustCrypto primitives.
|
||||
|
||||
+15
-2
@@ -4,6 +4,7 @@
|
||||
//! `r:id`, `a:blip`…), so the reader doesn't care which prefixes a producer chose. Names in
|
||||
//! unknown namespaces keep their local name behind a `?:` prefix.
|
||||
|
||||
use quick_xml::XmlVersion;
|
||||
use quick_xml::events::Event;
|
||||
use quick_xml::name::ResolveResult;
|
||||
use quick_xml::reader::NsReader;
|
||||
@@ -261,13 +262,13 @@ pub fn parse(bytes: &[u8]) -> Result<El, DocxError> {
|
||||
if raw_key == b"xmlns" || raw_key.starts_with(b"xmlns:") {
|
||||
continue;
|
||||
}
|
||||
let (ares, alocal) = r.resolve_attribute(key);
|
||||
let (ares, alocal) = r.resolver_mut().resolve_attribute(key);
|
||||
let alocal = String::from_utf8_lossy(alocal.as_ref()).into_owned();
|
||||
let aname = match ares {
|
||||
ResolveResult::Bound(ns) => format!("{}:{alocal}", prefix_for(ns.as_ref())),
|
||||
_ => alocal,
|
||||
};
|
||||
let val = match a.unescape_value() {
|
||||
let val = match a.normalized_value(XmlVersion::Explicit1_0) {
|
||||
Ok(v) => v.into_owned(),
|
||||
Err(_) => String::from_utf8_lossy(&a.value).into_owned(),
|
||||
};
|
||||
@@ -481,6 +482,18 @@ mod tests {
|
||||
assert!(parse(s.as_bytes()).is_err());
|
||||
}
|
||||
|
||||
/// A start tag with thousands of `xmlns:` declarations is refused instead of allocated
|
||||
/// (RUSTSEC-2026-0195: unbounded namespace bindings in the resolver).
|
||||
#[test]
|
||||
fn too_many_namespace_declarations_is_error() {
|
||||
let mut s = String::from("<a xmlns:a0=\"urn:a0\"");
|
||||
for i in 1..10_000 {
|
||||
s.push_str(&format!(" xmlns:a{i}=\"urn:a{i}\""));
|
||||
}
|
||||
s.push_str("><b/></a>");
|
||||
assert!(parse(s.as_bytes()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn garbage_is_error_not_panic() {
|
||||
assert!(parse(b"").is_err());
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
use std::collections::HashMap;
|
||||
use std::io::{Cursor, Read, Write};
|
||||
|
||||
use quick_xml::XmlVersion;
|
||||
use quick_xml::events::{BytesStart, Event};
|
||||
use wordcraft_doc::{Align, Document, Rgb};
|
||||
|
||||
@@ -659,7 +660,8 @@ fn attrs_of(e: &BytesStart<'_>) -> Attrs {
|
||||
let mut v = Vec::new();
|
||||
for a in e.attributes().with_checks(false).flatten().take(128) {
|
||||
let k = String::from_utf8_lossy(a.key.as_ref()).into_owned();
|
||||
let val = a.unescape_value().map(|c| c.into_owned()).unwrap_or_else(|_| String::from_utf8_lossy(&a.value).into_owned());
|
||||
let val =
|
||||
a.normalized_value(XmlVersion::Explicit1_0).map(|c| c.into_owned()).unwrap_or_else(|_| String::from_utf8_lossy(&a.value).into_owned());
|
||||
v.push((k, val));
|
||||
}
|
||||
v
|
||||
|
||||
Reference in New Issue
Block a user