Skip to content

Commit 07f3db8

Browse files
qzhang-panwclaude
authored andcommitted
Fix O(N²) duplicate-attribute check in Attributes iterator
`IterState::check_for_duplicates` did a linear scan of every previously seen attribute name for each new attribute, so a start tag with N distinct names cost O(N²) byte comparisons -- a CPU-exhaustion vector on untrusted XML (#969). Small tags keep the linear scan: for the handful of attributes a real start tag carries it is faster than hashing and needs no allocation (the busiest element in `players.xml` has 22). Once a tag declares more than `SMALL_ATTRIBUTE_COUNT` (32) attributes it switches to a 64-bit hash pre-filter, making the whole tag O(N). The set is seeded from the names already collected, so a duplicate that spans the switch is still caught, and on a hit it falls back to the linear scan to report the exact previous position -- `AttrError::Duplicated` is unchanged. The pre-filter stores SipHash name hashes in a `HashSet` keyed by an identity hasher, since the values are already hashes (no re-hashing). Exercised by a new `benches/issue971.rs` and a unit test covering a duplicate past the hash threshold. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 7ca2526 commit 07f3db8

4 files changed

Lines changed: 224 additions & 2 deletions

File tree

Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,11 @@ harness = false
6868
path = "benches/encoding.rs"
6969
required-features = ["encoding"]
7070

71+
[[bench]]
72+
name = "issue971"
73+
harness = false
74+
path = "benches/issue971.rs"
75+
7176
[features]
7277
default = []
7378

Changelog.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@
2121

2222
### Bug Fixes
2323

24+
- [#969]: `Attributes` (and anything that iterates `BytesStart::attributes()`
25+
with the default `with_checks(true)`) no longer takes O(N²) time on a start
26+
tag with a large number of attributes. Small tags keep the previous linear
27+
scan; larger ones switch to a 64-bit hash pre-filter, so the whole tag is
28+
O(N). The exact `AttrError::Duplicated(new, prev)` positions are unchanged.
2429
- [#970]: `NamespaceResolver::push` (and hence every `NsReader` `Start`/`Empty`
2530
event) now rejects a start tag that declares more than
2631
`DEFAULT_MAX_DECLARATIONS_PER_ELEMENT` (256) `xmlns` / `xmlns:*` namespace
@@ -31,10 +36,11 @@
3136
via `NamespaceResolver::set_max_declarations_per_element` (use `usize::MAX`
3237
to disable).
3338

34-
[#970]: https://github.com/tafia/quick-xml/issues/970
35-
3639
### Misc Changes
3740

41+
[#969]: https://github.com/tafia/quick-xml/issues/969
42+
[#970]: https://github.com/tafia/quick-xml/issues/970
43+
3844

3945
## 0.40.1 -- 2026-05-15
4046

benches/issue971.rs

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
//! Regression benchmark for [#969] / [#971]: iterating the attributes of a
2+
//! single start tag must stay ~O(N) in the number of attributes.
3+
//!
4+
//! `BytesStart::attributes()` defaults to `with_checks(true)`, which rejects
5+
//! duplicate attribute names. That check used to be a linear scan of every
6+
//! previously seen name, making a tag with N distinct attributes cost O(N²) byte
7+
//! comparisons -- a CPU-exhaustion vector on untrusted XML. This benchmark times
8+
//! the check across a range of attribute counts; if the O(N²) behaviour ever
9+
//! returns, the per-element time for the larger inputs will blow up.
10+
//!
11+
//! Run with `cargo bench --bench issue971`. It is also executed once per case as
12+
//! a smoke test by `cargo test --benches` on CI.
13+
//!
14+
//! [#969]: https://github.com/tafia/quick-xml/issues/969
15+
//! [#971]: https://github.com/tafia/quick-xml/pull/971
16+
17+
use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Throughput};
18+
use quick_xml::events::Event;
19+
use quick_xml::reader::Reader;
20+
21+
/// Builds a single empty-element tag carrying `n` distinct attributes, as in the
22+
/// [#969] proof of concept (`<e a00000000="" a00000001="" ... />`).
23+
fn tag_with_attributes(n: usize) -> String {
24+
let mut xml = String::with_capacity(n * 13 + 8);
25+
xml.push_str("<e");
26+
for i in 0..n {
27+
xml.push_str(&format!(" a{:08}=\"\"", i));
28+
}
29+
xml.push_str("/>");
30+
xml
31+
}
32+
33+
/// Iterates every attribute of the single start tag in `xml`, returning the
34+
/// count. `checks` toggles the duplicate-name detection under test.
35+
fn count_attributes(xml: &str, checks: bool) -> usize {
36+
let mut reader = Reader::from_str(xml);
37+
match reader.read_event() {
38+
Ok(Event::Empty(e)) => {
39+
let mut count = 0;
40+
for attr in e.attributes().with_checks(checks) {
41+
attr.expect("valid attribute");
42+
count += 1;
43+
}
44+
count
45+
}
46+
other => panic!("expected an empty element, got {:?}", other),
47+
}
48+
}
49+
50+
fn duplicate_check(c: &mut Criterion) {
51+
let mut group = c.benchmark_group("issue971_attributes");
52+
for n in [16usize, 64, 256, 1024, 4096] {
53+
let xml = tag_with_attributes(n);
54+
assert_eq!(count_attributes(&xml, true), n);
55+
group.throughput(Throughput::Elements(n as u64));
56+
57+
group.bench_with_input(BenchmarkId::new("with_checks(true)", n), &xml, |b, xml| {
58+
b.iter(|| count_attributes(xml, true))
59+
});
60+
group.bench_with_input(BenchmarkId::new("with_checks(false)", n), &xml, |b, xml| {
61+
b.iter(|| count_attributes(xml, false))
62+
});
63+
}
64+
group.finish();
65+
}
66+
67+
criterion_group!(benches, duplicate_check);
68+
criterion_main!(benches);

src/events/attributes.rs

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ use crate::name::{LocalName, Namespace, NamespaceResolver, QName};
99
use crate::utils::{is_whitespace, Bytes};
1010
use crate::XmlVersion;
1111

12+
use std::collections::HashSet;
1213
use std::fmt::{self, Debug, Display, Formatter};
14+
use std::hash::{BuildHasherDefault, DefaultHasher, Hasher};
1315
use std::iter::FusedIterator;
1416
use std::{borrow::Cow, ops::Range};
1517

@@ -964,6 +966,53 @@ enum State {
964966
SkipEqValue(usize),
965967
}
966968

969+
/// Number of attributes a start tag may have before the duplicate-name check
970+
/// switches from a direct linear scan of the previously seen names to a hash
971+
/// pre-filter (see [`IterState::check_for_duplicates`]).
972+
///
973+
/// Real-world start tags carry only a handful of attributes -- the busiest
974+
/// element in our benchmark corpus (`tests/documents/players.xml`) has 22 --
975+
/// where the scan is faster than hashing and needs no allocation. Larger tags
976+
/// are where the scan became the O(N²) CPU-DoS of [#969], so above this count we
977+
/// pay for a hash set to keep the whole tag O(N). The value sits just above the
978+
/// measured linear-vs-hash crossover.
979+
///
980+
/// [#969]: https://github.com/tafia/quick-xml/issues/969
981+
const SMALL_ATTRIBUTE_COUNT: usize = 32;
982+
983+
/// A no-op [`Hasher`] for the `key_hashes` set, whose values are already 64-bit
984+
/// hashes of attribute names; re-hashing them with the default SipHash would be
985+
/// wasted work. Only `write_u64` is ever exercised (via `u64`'s `Hash` impl).
986+
#[derive(Default)]
987+
struct IdentityHasher(u64);
988+
989+
impl Hasher for IdentityHasher {
990+
#[inline]
991+
fn finish(&self) -> u64 {
992+
self.0
993+
}
994+
995+
#[inline]
996+
fn write(&mut self, _: &[u8]) {
997+
// The set only ever stores `u64` keys, which route through `write_u64`.
998+
unreachable!("IdentityHasher only supports u64 keys")
999+
}
1000+
1001+
#[inline]
1002+
fn write_u64(&mut self, n: u64) {
1003+
self.0 = n;
1004+
}
1005+
}
1006+
1007+
/// Hashes a single attribute name. A fresh [`DefaultHasher`] per name keeps each
1008+
/// hash independent (so it is also DoS-resistant on untrusted input).
1009+
#[inline]
1010+
fn hash_name(name: &[u8]) -> u64 {
1011+
let mut hasher = DefaultHasher::new();
1012+
hasher.write(name);
1013+
hasher.finish()
1014+
}
1015+
9671016
/// External iterator over spans of attribute key and value
9681017
#[derive(Clone, Debug)]
9691018
pub(crate) struct IterState {
@@ -979,6 +1028,13 @@ pub(crate) struct IterState {
9791028
/// names. We store a ranges instead of slices to able to report a previous
9801029
/// attribute position
9811030
keys: Vec<Range<usize>>,
1031+
/// 64-bit hashes of the byte content of `keys`, used as an O(1) pre-filter
1032+
/// once a start tag declares more than `SMALL_ATTRIBUTE_COUNT` attributes, so
1033+
/// the duplicate check stays O(N) over the whole tag instead of O(N²). The
1034+
/// values are already hashes, so the set stores them with `IdentityHasher`
1035+
/// instead of re-hashing. Allocated only when the threshold is crossed, so
1036+
/// small tags (and [`IterState::new`]) stay allocation-free and `const`.
1037+
key_hashes: Option<HashSet<u64, BuildHasherDefault<IdentityHasher>>>,
9821038
}
9831039

9841040
impl IterState {
@@ -988,6 +1044,7 @@ impl IterState {
9881044
html,
9891045
check_duplicates: true,
9901046
keys: Vec::new(),
1047+
key_hashes: None,
9911048
}
9921049
}
9931050

@@ -1063,13 +1120,27 @@ impl IterState {
10631120
}
10641121
}
10651122

1123+
/// Checks that the attribute name `key` (a range into `slice`) was not seen
1124+
/// earlier in the same start tag, recording it for subsequent checks.
1125+
///
1126+
/// Small tags use a direct linear scan of [`Self::keys`]: for a handful of
1127+
/// attributes that beats hashing and needs no allocation, which is the
1128+
/// overwhelmingly common case. Once a tag declares more than
1129+
/// `SMALL_ATTRIBUTE_COUNT` attributes -- where the scan would become the
1130+
/// O(N²) CPU-DoS of [#969] -- it switches to a hash pre-filter that keeps the
1131+
/// whole tag O(N).
1132+
///
1133+
/// [#969]: https://github.com/tafia/quick-xml/issues/969
10661134
#[inline]
10671135
fn check_for_duplicates(
10681136
&mut self,
10691137
slice: &[u8],
10701138
key: Range<usize>,
10711139
) -> Result<Range<usize>, AttrError> {
10721140
if self.check_duplicates {
1141+
if self.keys.len() >= SMALL_ATTRIBUTE_COUNT {
1142+
return self.check_for_duplicates_hashed(slice, key);
1143+
}
10731144
if let Some(prev) = self
10741145
.keys
10751146
.iter()
@@ -1082,6 +1153,44 @@ impl IterState {
10821153
Ok(key)
10831154
}
10841155

1156+
/// Cold path of [`Self::check_for_duplicates`] for start tags with many
1157+
/// attributes: a [`HashSet`] of 64-bit name hashes acts as an O(1) pre-filter
1158+
/// so iterating N attributes is O(N) rather than O(N²).
1159+
#[cold]
1160+
fn check_for_duplicates_hashed(
1161+
&mut self,
1162+
slice: &[u8],
1163+
key: Range<usize>,
1164+
) -> Result<Range<usize>, AttrError> {
1165+
let keys = &self.keys;
1166+
let key_hashes = self.key_hashes.get_or_insert_with(|| {
1167+
// First time over the threshold: seed the set with the names already
1168+
// collected during the linear phase so the pre-filter knows them.
1169+
let mut set = HashSet::with_capacity_and_hasher(
1170+
keys.len() * 2,
1171+
BuildHasherDefault::<IdentityHasher>::default(),
1172+
);
1173+
for r in keys {
1174+
set.insert(hash_name(&slice[r.clone()]));
1175+
}
1176+
set
1177+
});
1178+
// A fresh hash proves the name is new. On a hit (a real duplicate, or the
1179+
// astronomically rare 64-bit collision) fall back to the linear scan to
1180+
// recover the exact previous position for `AttrError::Duplicated`.
1181+
if !key_hashes.insert(hash_name(&slice[key.clone()])) {
1182+
if let Some(prev) = self
1183+
.keys
1184+
.iter()
1185+
.find(|r| slice[(*r).clone()] == slice[key.clone()])
1186+
{
1187+
return Err(AttrError::Duplicated(key.start, prev.start));
1188+
}
1189+
}
1190+
self.keys.push(key.clone());
1191+
Ok(key)
1192+
}
1193+
10851194
/// # Parameters
10861195
///
10871196
/// - `slice`: content of the tag, used for checking for duplicates
@@ -1988,6 +2097,40 @@ mod xml {
19882097
assert_eq!(iter.next(), None);
19892098
assert_eq!(iter.next(), None);
19902099
}
2100+
2101+
/// Once a start tag declares more than `SMALL_ATTRIBUTE_COUNT`
2102+
/// attributes the duplicate check switches to its hash-based path. A
2103+
/// duplicate of a name first seen during the earlier linear phase must
2104+
/// still be detected, with the original position reported. Regression
2105+
/// cover for the cold path of [#969].
2106+
///
2107+
/// [#969]: https://github.com/tafia/quick-xml/issues/969
2108+
#[test]
2109+
fn duplicate_past_hash_threshold() {
2110+
let dup = SMALL_ATTRIBUTE_COUNT / 2;
2111+
let n = SMALL_ATTRIBUTE_COUNT + 8;
2112+
2113+
let mut source = String::from("tag");
2114+
let mut positions = Vec::with_capacity(n);
2115+
for i in 0..n {
2116+
source.push(' ');
2117+
positions.push(source.len());
2118+
source.push_str(&format!("k{:04}=''", i));
2119+
}
2120+
// Repeat the name first seen at `positions[dup]` (linear phase).
2121+
source.push(' ');
2122+
let dup_pos = source.len();
2123+
source.push_str(&format!("k{:04}=''", dup));
2124+
2125+
let mut iter = Attributes::new(&source, 3);
2126+
for _ in 0..n {
2127+
assert!(matches!(iter.next(), Some(Ok(_))));
2128+
}
2129+
assert_eq!(
2130+
iter.next(),
2131+
Some(Err(AttrError::Duplicated(dup_pos, positions[dup])))
2132+
);
2133+
}
19912134
}
19922135

19932136
/// Check for duplicated names is disabled

0 commit comments

Comments
 (0)