waf_normalizer/grpc.rs
1// SPDX-FileCopyrightText: 2026 0x00spor3
2// SPDX-License-Identifier: Apache-2.0
3
4//! Minimal gRPC framing + protobuf wire-format extractor (the 9th custom parser in this
5//! crate, fuzzed — see ARCHITECTURE §13). Two layers, one linear pass, never panics:
6//!
7//! 1. **gRPC framing**: a body is a sequence of length-prefixed messages
8//! `[compressed-flag:1][length:4 big-endian][message:length]`. We de-frame, count
9//! messages/bytes, and flag any **compressed** frame (the per-message flag bit) — a
10//! compressed payload is opaque here, the policy (`on_compressed`) lives in the module.
11//!
12//! 2. **protobuf wire-format (NO schema)**: each field is `tag = (field<<3)|wire_type`
13//! (varint) followed by its value. We walk the fields and, for **length-delimited**
14//! (wire-type 2) fields, apply the heuristic: *valid UTF-8 → a leaf string*
15//! (a content-inspection candidate, fed to the §6 derived channel); *otherwise →
16//! recurse as a nested sub-message* (depth-capped). Varint/fixed fields are skipped.
17//!
18//! **Honesty (best-effort).** The wire format is schema-less and therefore *ambiguous* by
19//! construction: a length-delimited field may be a string, opaque bytes, or an embedded
20//! message, and they are indistinguishable without the `.proto`. So content extraction is
21//! **best-effort** (the same status as GraphQL `max_complexity`). The *guaranteed*
22//! deliverable is the **structural** signal — message size, field count, nesting depth,
23//! compressed/malformed flags — which the `grpc` module turns into a `Reject`. NB: a
24//! sub-message whose raw bytes are valid UTF-8 is kept as ONE leaf string; the nested
25//! field *content* is still a substring of it, so a content rule still matches — only the
26//! per-field structure is lost, not the payload text.
27
28/// Caps that bound the parser's own work (anti-DoS during parsing) and surface the
29/// structural signal. `max_depth`/`max_fields` doubling as the parser bound and the
30/// module's cap is deliberate: parsing past a cap that already forces a `Reject` is wasted
31/// work. `max_leaves` bounds the extracted content surface.
32#[derive(Debug, Clone, Copy)]
33pub struct GrpcLimits {
34 pub max_depth: u32,
35 pub max_fields: u32,
36 pub max_leaves: usize,
37}
38
39impl Default for GrpcLimits {
40 fn default() -> Self {
41 Self { max_depth: 16, max_fields: 4096, max_leaves: 1024 }
42 }
43}
44
45/// Structural metrics + extracted content of a gRPC body.
46#[derive(Debug, Default, Clone, PartialEq, Eq)]
47pub struct GrpcExtract {
48 /// Number of length-prefixed frames.
49 pub messages: u32,
50 /// Sum of message lengths (the inspectable payload size).
51 pub total_payload_bytes: u64,
52 /// Largest single frame length.
53 pub max_message_len: u32,
54 /// Total protobuf fields walked (across frames).
55 pub fields: u32,
56 /// Deepest sub-message nesting actually entered (≤ `max_depth`).
57 pub max_depth: u32,
58 /// A sub-message at the depth cap tried to nest deeper (depth-bomb signal).
59 pub depth_exceeded: bool,
60 /// The field cap was hit (field-bomb signal); parsing of that message stopped.
61 pub fields_exceeded: bool,
62 /// At least one frame carried the per-message COMPRESSED flag (payload not parsed).
63 pub compressed: bool,
64 /// Framing or wire-format parse hit something illegal/truncated.
65 pub malformed: bool,
66 /// UTF-8 length-delimited fields — content-inspection candidates (best-effort).
67 pub leaves: Vec<String>,
68}
69
70/// Read a base-128 varint at `*i`, advancing `*i`. `None` on truncation or >10 bytes.
71fn read_varint(b: &[u8], i: &mut usize) -> Option<u64> {
72 let mut result: u64 = 0;
73 for k in 0..10 {
74 let byte = *b.get(*i)?;
75 *i += 1;
76 result |= ((byte & 0x7f) as u64) << (7 * k);
77 if byte & 0x80 == 0 {
78 return Some(result);
79 }
80 }
81 None // more than 10 continuation bytes → malformed
82}
83
84/// De-frame `body` and extract its [`GrpcExtract`]. Linear, bounded by `limits`, no panic.
85pub fn grpc_extract(body: &[u8], limits: GrpcLimits) -> GrpcExtract {
86 let mut out = GrpcExtract::default();
87 let mut pos = 0;
88 while pos + 5 <= body.len() {
89 let flag = body[pos];
90 let len = u32::from_be_bytes([body[pos + 1], body[pos + 2], body[pos + 3], body[pos + 4]]) as usize;
91 let msg_start = pos + 5;
92 let Some(msg_end) = msg_start.checked_add(len) else {
93 out.malformed = true;
94 return out;
95 };
96 if msg_end > body.len() {
97 out.malformed = true; // truncated frame
98 return out;
99 }
100 out.messages = out.messages.saturating_add(1);
101 out.total_payload_bytes = out.total_payload_bytes.saturating_add(len as u64);
102 out.max_message_len = out.max_message_len.max(len as u32);
103
104 let msg = &body[msg_start..msg_end];
105 match flag {
106 0 => parse_message(msg, 1, &limits, &mut out),
107 1 => out.compressed = true, // compressed payload — policy upstream (on_compressed)
108 _ => out.malformed = true, // only 0/1 are defined flag values
109 }
110 pos = msg_end;
111 }
112 // Trailing bytes that do not form a full 5-byte header = a partial/illegal frame.
113 if pos != body.len() {
114 out.malformed = true;
115 }
116 out
117}
118
119/// Walk one protobuf message at `depth`, mutating `out`. Stops (without panic) on the
120/// first malformed field or when a cap is hit.
121fn parse_message(buf: &[u8], depth: u32, limits: &GrpcLimits, out: &mut GrpcExtract) {
122 out.max_depth = out.max_depth.max(depth);
123 let mut i = 0;
124 while i < buf.len() {
125 let Some(tag) = read_varint(buf, &mut i) else {
126 out.malformed = true;
127 return;
128 };
129 out.fields = out.fields.saturating_add(1);
130 if out.fields > limits.max_fields {
131 out.fields_exceeded = true;
132 return;
133 }
134 match (tag & 0x7) as u8 {
135 0 => {
136 // VARINT
137 if read_varint(buf, &mut i).is_none() {
138 out.malformed = true;
139 return;
140 }
141 }
142 1 => {
143 // I64 (8 bytes)
144 if i + 8 > buf.len() {
145 out.malformed = true;
146 return;
147 }
148 i += 8;
149 }
150 5 => {
151 // I32 (4 bytes)
152 if i + 4 > buf.len() {
153 out.malformed = true;
154 return;
155 }
156 i += 4;
157 }
158 2 => {
159 // LEN (length-delimited): string | bytes | sub-message.
160 let Some(len) = read_varint(buf, &mut i) else {
161 out.malformed = true;
162 return;
163 };
164 let end = match i.checked_add(len as usize) {
165 Some(e) if e <= buf.len() => e,
166 _ => {
167 out.malformed = true;
168 return;
169 }
170 };
171 let field = &buf[i..end];
172 i = end;
173 match std::str::from_utf8(field) {
174 // valid UTF-8 → a leaf string (content-inspection candidate)
175 Ok(s) if !s.is_empty() => {
176 if out.leaves.len() < limits.max_leaves {
177 out.leaves.push(s.to_string());
178 }
179 }
180 Ok(_) => {} // empty field: nothing to inspect
181 // not UTF-8 → try as a nested sub-message, depth-capped
182 Err(_) => {
183 if depth >= limits.max_depth {
184 out.depth_exceeded = true;
185 } else {
186 parse_message(field, depth + 1, limits, out);
187 }
188 }
189 }
190 }
191 // groups (3/4, deprecated) or an illegal wire type → stop this message.
192 _ => {
193 out.malformed = true;
194 return;
195 }
196 }
197 }
198}
199
200#[cfg(test)]
201mod tests {
202 use super::*;
203
204 // ── protobuf / gRPC encoders (test helpers) ──────────────────────────────────
205
206 fn varint(mut v: u64, out: &mut Vec<u8>) {
207 loop {
208 let mut byte = (v & 0x7f) as u8;
209 v >>= 7;
210 if v != 0 {
211 byte |= 0x80;
212 }
213 out.push(byte);
214 if v == 0 {
215 break;
216 }
217 }
218 }
219
220 /// A length-delimited (wire-type 2) field: `field` number carrying `data`.
221 fn len_field(field: u64, data: &[u8]) -> Vec<u8> {
222 let mut out = Vec::new();
223 varint((field << 3) | 2, &mut out);
224 varint(data.len() as u64, &mut out);
225 out.extend_from_slice(data);
226 out
227 }
228
229 /// A varint (wire-type 0) field — useful to force NON-UTF-8 bytes (value ≥ 0x80) so a
230 /// sub-message wrapping it is recursed, not mistaken for a string.
231 fn varint_field(field: u64, value: u64) -> Vec<u8> {
232 let mut out = Vec::new();
233 varint(field << 3, &mut out); // wire-type 0 (VARINT) = the low 3 bits being zero
234 varint(value, &mut out);
235 out
236 }
237
238 /// Wrap a message body in a single uncompressed gRPC frame.
239 fn frame(msg: &[u8]) -> Vec<u8> {
240 let mut out = vec![0u8];
241 out.extend_from_slice(&(msg.len() as u32).to_be_bytes());
242 out.extend_from_slice(msg);
243 out
244 }
245
246 // ── extraction ────────────────────────────────────────────────────────────────
247
248 #[test]
249 fn extracts_string_leaf_from_unary_frame() {
250 // {1: "1 UNION SELECT a,b FROM users--"} in one uncompressed frame.
251 let sqli = "1 UNION SELECT a,b FROM users--";
252 let body = frame(&len_field(1, sqli.as_bytes()));
253 let ex = grpc_extract(&body, GrpcLimits::default());
254 assert_eq!(ex.messages, 1);
255 assert!(!ex.compressed && !ex.malformed);
256 assert!(ex.leaves.iter().any(|l| l == sqli), "SQLi leaf must be extracted: {:?}", ex.leaves);
257 }
258
259 #[test]
260 fn recurses_into_non_utf8_submessage() {
261 // Outer {3: <sub>} where sub = {2: <varint 300>}{1: "inner-secret"}. The varint
262 // (300 = 0xac 0x02) makes the sub-message bytes non-UTF-8 → recursion fires.
263 let mut sub = varint_field(2, 300);
264 sub.extend_from_slice(&len_field(1, b"inner-secret"));
265 let body = frame(&len_field(3, &sub));
266 let ex = grpc_extract(&body, GrpcLimits::default());
267 assert!(ex.max_depth >= 2, "must recurse into the sub-message (depth {})", ex.max_depth);
268 assert!(ex.leaves.iter().any(|l| l == "inner-secret"), "nested leaf: {:?}", ex.leaves);
269 assert!(!ex.depth_exceeded);
270 }
271
272 // ── paletto A: the trap (benign deep nesting) vs a depth-bomb ────────────────
273
274 /// Build `depth` nested NON-UTF-8 sub-messages (each wraps a varint to force recursion)
275 /// with a benign string at the bottom.
276 fn nested_messages(depth: u32) -> Vec<u8> {
277 let mut inner = varint_field(2, 300); // non-UTF-8 marker
278 inner.extend_from_slice(&len_field(15, b"benign-leaf"));
279 for _ in 0..depth {
280 inner = {
281 let mut wrap = varint_field(2, 300);
282 wrap.extend_from_slice(&len_field(1, &inner));
283 wrap
284 };
285 }
286 frame(&inner)
287 }
288
289 #[test]
290 fn benign_deep_but_within_cap_is_not_a_false_reject() {
291 // The negative reference (paletto A): legitimately nested, but under the cap →
292 // fully extracted, depth_exceeded = FALSE. Proves the cap distinguishes nesting
293 // from a depth-bomb.
294 let limits = GrpcLimits { max_depth: 16, ..GrpcLimits::default() };
295 let ex = grpc_extract(&nested_messages(8), limits);
296 assert!(!ex.depth_exceeded, "legitimate nesting must not trip the depth cap");
297 assert!(!ex.malformed);
298 assert!(ex.leaves.iter().any(|l| l == "benign-leaf"));
299 }
300
301 #[test]
302 fn depth_bomb_beyond_cap_is_flagged() {
303 let limits = GrpcLimits { max_depth: 8, ..GrpcLimits::default() };
304 let ex = grpc_extract(&nested_messages(40), limits);
305 assert!(ex.depth_exceeded, "a depth-bomb past the cap must be flagged");
306 assert!(ex.max_depth <= 8, "recursion must stop at the cap (got {})", ex.max_depth);
307 }
308
309 // ── structural signals ───────────────────────────────────────────────────────
310
311 #[test]
312 fn compressed_frame_is_flagged_not_parsed() {
313 // flag = 1 (compressed); the payload is opaque → no leaves, compressed = true.
314 let mut body = vec![1u8];
315 let payload = len_field(1, b"would-be-secret");
316 body.extend_from_slice(&(payload.len() as u32).to_be_bytes());
317 body.extend_from_slice(&payload);
318 let ex = grpc_extract(&body, GrpcLimits::default());
319 assert!(ex.compressed);
320 assert!(ex.leaves.is_empty(), "a compressed frame must not be parsed here");
321 }
322
323 #[test]
324 fn field_cap_is_flagged() {
325 let mut msg = Vec::new();
326 for n in 1..=10u64 {
327 msg.extend_from_slice(&varint_field(n, 1));
328 }
329 let ex = grpc_extract(&frame(&msg), GrpcLimits { max_fields: 5, ..GrpcLimits::default() });
330 assert!(ex.fields_exceeded);
331 }
332
333 #[test]
334 fn multi_frame_body_counts_all() {
335 let mut body = frame(&len_field(1, b"first"));
336 body.extend_from_slice(&frame(&len_field(1, b"second")));
337 let ex = grpc_extract(&body, GrpcLimits::default());
338 assert_eq!(ex.messages, 2);
339 assert!(ex.leaves.iter().any(|l| l == "first"));
340 assert!(ex.leaves.iter().any(|l| l == "second"));
341 }
342
343 // ── robustness (never panic) ─────────────────────────────────────────────────
344
345 #[test]
346 fn truncated_and_malformed_inputs_do_not_panic() {
347 let limits = GrpcLimits::default();
348 let _ = grpc_extract(&[], limits);
349 let _ = grpc_extract(&[0], limits); // partial header
350 let _ = grpc_extract(&[0, 0, 0, 0, 100], limits); // length 100, no payload
351 let _ = grpc_extract(&[0, 0, 0, 0, 3, 0xff, 0xff, 0xff], limits); // garbage payload
352 let _ = grpc_extract(&[0, 0, 0, 0, 2, 0x08], limits); // tag varint then truncated value
353 // a LEN field claiming more bytes than present
354 let _ = grpc_extract(&frame(&[0x0a, 0x7f]), limits);
355 }
356
357 #[test]
358 fn empty_string_field_yields_no_leaf() {
359 let ex = grpc_extract(&frame(&len_field(1, b"")), GrpcLimits::default());
360 assert!(ex.leaves.is_empty());
361 assert_eq!(ex.messages, 1);
362 assert!(!ex.malformed);
363 }
364}