netget/utils/bson_depth.rs
1//! A bound on BSON nesting, applied before `bson` ever sees the bytes.
2//!
3//! `bson` 3.0 has no depth limit. `Document::from_reader` builds a `RawDocumentBuf` and converts
4//! it with `TryFrom<&RawDocument> for Document` (`src/raw/document.rs:580`), which turns each
5//! element into a `Bson` through `TryFrom<RawBsonRef> for Bson` (`src/raw/bson_ref.rs:412`) and
6//! `TryFrom<RawBson> for Bson` (`src/raw/bson.rs:447`) — and an embedded document, an array or a
7//! code-with-scope's scope goes straight back into the first of those. One Rust call chain per
8//! level, nothing counting. Each level also copies the rest of the bytes (`RawBson::from` owns
9//! the sub-document), so the work is quadratic in the depth as well.
10//!
11//! An embedded document costs the peer **seven bytes per level** (type, a one-byte key and its
12//! NUL, a length, a terminator). Measured against 3.0.0, `Document::from_reader` on a thread of
13//! a given stack size:
14//!
15//! | build | stack | survives | overflows at | bytes |
16//! |---|---|---|---|---|
17//! | debug | 2 MiB (a tokio worker) | 283 levels | **284** | 2 277 |
18//! | release | 2 MiB | 1 219 levels | **1 220** | 9 765 |
19//! | release | 8 MiB (a main thread) | 4 860 levels | **4 861** | 38 893 |
20//!
21//! Ten kilobytes in one unauthenticated `OP_MSG` — the MongoDB server's body cap is 48 MB — and
22//! the process is gone. **This is not a panic**: a stack overflow is a `SIGSEGV` against the
23//! guard page, and `tokio::spawn` cannot contain it.
24//!
25//! So the bytes are walked *iteratively* first. [`scan_bson_document`] keeps the end offset of
26//! each open document in a fixed array of [`MAX_BSON_DEPTH`] slots, allocates nothing, never
27//! recurses, and bounds-checks every length against the document that contains it. Only a
28//! document it reports [`BsonScan::Complete`] may be handed to `bson`.
29//!
30//! **It is at least as strict as `bson` about structure, never stricter about anything `bson`
31//! would accept.** Every element is sized exactly as `RawIter::get_next_kvp` sizes it, so the
32//! scan finds each nested document at the same offset the decoder will; an element that runs
33//! into its parent's terminator, which `bson` yields and only then rejects, is `Malformed` here
34//! at once. What it does not check — UTF-8 in keys and strings, boolean bytes, the old binary
35//! subtype's inner length — cannot open a level, and `bson` still rejects it.
36//!
37//! **The declared top-level length.** `bson`'s `reader_to_vec` (`src/raw.rs:322`) reserves
38//! `Vec::with_capacity(length)` from the document's own first four bytes — up to 2 GiB from a
39//! 21-byte message — before discovering the bytes are not there. A document whose declared
40//! length exceeds the bytes present is `Malformed` here, so nothing downstream reserves on the
41//! peer's number.
42
43/// Deepest nesting a BSON document may use before it is refused. The top-level document is
44/// level 1; each embedded document, array or code-with-scope scope inside it adds one.
45///
46/// MongoDB itself allows 100 levels in a stored document, and a command wraps a document in
47/// two more (the command document and its `documents` array). That is not the number here,
48/// because the table above says what 100 costs: at ~7.4 KB of stack per level in a debug
49/// build, 102 levels spends ~750 KB of a 2 MiB worker on decoding alone, and the MongoDB server
50/// then walks the same document recursively three more times (relaxed extended JSON for the
51/// model's event, `Debug` in a `trace!`, and `Drop`). 64 keeps the whole of that well inside
52/// one worker in every build, and no driver-generated command comes near it; the cost is that
53/// a document nested 63 to 100 levels deep, which real MongoDB would store, is refused.
54pub const MAX_BSON_DEPTH: usize = 64;
55
56/// The verdict on a BSON document at the front of a buffer.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum BsonScan {
59 /// One whole, structurally sound document occupies the first `consumed` bytes.
60 Complete { consumed: usize },
61 /// Nested deeper than the limit. Refused as soon as the first level past it opens.
62 TooDeep { limit: usize },
63 /// Not a BSON document: a length that is negative, too small or larger than what contains
64 /// it, a missing terminator, an unknown element type, or an element that runs past its
65 /// document. `bson` would reject it too, but possibly only after recursing into it.
66 Malformed,
67}
68
69/// Walk one BSON document iteratively, refusing anything nested deeper than [`MAX_BSON_DEPTH`].
70///
71/// Call it on every document that arrives from the network before `bson` decodes it. It is
72/// O(n), allocates nothing, and never recurses. Bytes after the document are ignored, as
73/// `Document::from_reader` ignores them.
74pub fn scan_bson_document(data: &[u8]) -> BsonScan {
75 scan_bson_document_with_limit(data, MAX_BSON_DEPTH)
76}
77
78/// [`scan_bson_document`] with an explicit limit, clamped to [`MAX_BSON_DEPTH`] (which sizes
79/// the fixed array of open documents).
80pub fn scan_bson_document_with_limit(data: &[u8], max_depth: usize) -> BsonScan {
81 let max_depth = max_depth.min(MAX_BSON_DEPTH);
82 // `ends[d]` is one past the last byte (the terminator) of the document open at depth d+1.
83 let mut ends = [0usize; MAX_BSON_DEPTH];
84 let mut depth = 0usize;
85
86 let Some(end) = document_end(data, 0, data.len()) else {
87 return BsonScan::Malformed;
88 };
89 if max_depth == 0 {
90 return BsonScan::TooDeep { limit: max_depth };
91 }
92 ends[0] = end;
93 depth += 1;
94 let mut pos = 4usize;
95
96 loop {
97 let end = ends[depth - 1];
98 // The terminator: `document_end` already checked it is a zero byte.
99 if pos == end - 1 {
100 pos = end;
101 depth -= 1;
102 if depth == 0 {
103 return BsonScan::Complete { consumed: end };
104 }
105 continue;
106 }
107 // Elements occupy [4, end - 1); anything reaching the terminator is malformed.
108 let limit = end - 1;
109 if pos > limit {
110 return BsonScan::Malformed;
111 }
112
113 let kind = data[pos];
114 pos += 1;
115 // The key: a NUL-terminated string that must end before the terminator.
116 let Some(nul) = data[pos..limit].iter().position(|&b| b == 0) else {
117 return BsonScan::Malformed;
118 };
119 pos += nul + 1;
120
121 let size = match kind {
122 // Fixed-size values.
123 0x08 => 1, // boolean
124 0x10 => 4, // int32
125 0x01 | 0x09 | 0x11 | 0x12 => 8, // double, datetime, timestamp, int64
126 0x07 => 12, // ObjectId
127 0x13 => 16, // decimal128
128 0x06 | 0x0A | 0x7F | 0xFF => 0, // undefined, null, max key, min key
129 // Length-prefixed strings: int32 length (including the NUL) >= 1, then the bytes.
130 0x02 | 0x0D | 0x0E => match string_size(data, pos, limit) {
131 Some(n) => n,
132 None => return BsonScan::Malformed,
133 },
134 // DBPointer: a string, then an ObjectId.
135 0x0C => match string_size(data, pos, limit) {
136 Some(n) => n + 12,
137 None => return BsonScan::Malformed,
138 },
139 // Binary: int32 length >= 0, a subtype byte, then the bytes.
140 0x05 => match read_i32(data, pos, limit).and_then(|n| usize::try_from(n).ok()) {
141 Some(n) => match n.checked_add(5) {
142 Some(n) => n,
143 None => return BsonScan::Malformed,
144 },
145 None => return BsonScan::Malformed,
146 },
147 // Regular expression: two C strings.
148 0x0B => {
149 let Some(p) = data[pos..limit].iter().position(|&b| b == 0) else {
150 return BsonScan::Malformed;
151 };
152 let Some(o) = data[pos + p + 1..limit].iter().position(|&b| b == 0) else {
153 return BsonScan::Malformed;
154 };
155 p + 1 + o + 1
156 }
157 // Embedded document or array: a nested level.
158 0x03 | 0x04 => {
159 let Some(child_end) = document_end(data, pos, limit) else {
160 return BsonScan::Malformed;
161 };
162 if depth >= max_depth {
163 return BsonScan::TooDeep { limit: max_depth };
164 }
165 ends[depth] = child_end;
166 depth += 1;
167 pos += 4;
168 continue;
169 }
170 // Code with scope: int32 total, a string, then a document that must end exactly
171 // where the total says — `bson` parses the scope with `RawDocument::from_bytes` on
172 // the rest of the element, which demands that. The scope is a nested level.
173 0x0F => {
174 let Some(total) = read_i32(data, pos, limit).and_then(|n| usize::try_from(n).ok())
175 else {
176 return BsonScan::Malformed;
177 };
178 // 4 (total) + 5 (the shortest string) + 5 (the shortest document).
179 if total < 14 {
180 return BsonScan::Malformed;
181 }
182 let element_end = match pos.checked_add(total) {
183 Some(e) if e <= limit => e,
184 _ => return BsonScan::Malformed,
185 };
186 let Some(code) = string_size(data, pos + 4, element_end) else {
187 return BsonScan::Malformed;
188 };
189 let scope = pos + 4 + code;
190 match document_end(data, scope, element_end) {
191 Some(scope_end) if scope_end == element_end => {}
192 _ => return BsonScan::Malformed,
193 }
194 if depth >= max_depth {
195 return BsonScan::TooDeep { limit: max_depth };
196 }
197 ends[depth] = element_end;
198 depth += 1;
199 pos = scope + 4;
200 continue;
201 }
202 _ => return BsonScan::Malformed,
203 };
204
205 match pos.checked_add(size) {
206 Some(next) if next <= limit => pos = next,
207 _ => return BsonScan::Malformed,
208 }
209 }
210}
211
212/// The end of the document starting at `start`, which must lie wholly before `limit`: its
213/// declared length is at least 5, fits, and its last byte is the zero terminator.
214fn document_end(data: &[u8], start: usize, limit: usize) -> Option<usize> {
215 let len = usize::try_from(read_i32(data, start, limit)?).ok()?;
216 if len < 5 {
217 return None;
218 }
219 let end = start.checked_add(len)?;
220 if end > limit || data[end - 1] != 0 {
221 return None;
222 }
223 Some(end)
224}
225
226/// The size of a length-prefixed BSON string at `start` — the four length bytes plus the
227/// declared length — if the declared length is at least 1 and it fits before `limit`.
228fn string_size(data: &[u8], start: usize, limit: usize) -> Option<usize> {
229 let len = usize::try_from(read_i32(data, start, limit)?).ok()?;
230 if len < 1 {
231 return None;
232 }
233 let size = len.checked_add(4)?;
234 if start.checked_add(size)? > limit {
235 return None;
236 }
237 Some(size)
238}
239
240/// A little-endian i32 at `start`, if all four bytes lie before `limit`.
241fn read_i32(data: &[u8], start: usize, limit: usize) -> Option<i32> {
242 let bytes = data.get(start..start.checked_add(4)?)?;
243 if start + 4 > limit {
244 return None;
245 }
246 Some(i32::from_le_bytes(bytes.try_into().ok()?))
247}