Skip to main content

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}