Skip to main content

waf_detection/
grpc.rs

1// SPDX-FileCopyrightText: 2026 0x00spor3
2// SPDX-License-Identifier: Apache-2.0
3
4//! gRPC structural protections (gRPC phase).
5//!
6//! Like `graphql`/`request_smuggling`, this is a STRUCTURAL control, NOT content
7//! inspection: it enforces DoS/abuse caps on the SHAPE of the framed protobuf body a
8//! request carries — total message size, protobuf field count, sub-message nesting depth —
9//! plus a policy for COMPRESSED (un-inspectable) payloads. The metrics come from the
10//! [`grpc_extract`] pass, so the module does NOT join the content-rule prefilter union.
11//!
12//! **Accounting (deliberate).** The protobuf field *content* (a SQLi/XSS smuggled inside a
13//! string field) is caught by the normal content modules via the §6 derived channel — the
14//! normalizer feeds them the extracted leaf strings. This module owns ONLY the structural
15//! signal (size/field/depth/compressed/malformed → `Reject{400}`). Keeping the two apart
16//! is the point: a content catch is credited to §6, not to gRPC.
17//!
18//! Recognised by `Content-Type: application/grpc*` on a `ParsedBody::Raw` body (the framed
19//! message is binary, so the normalizer leaves it raw). Default OFF (`[modules.grpc]`).
20
21use waf_core::{
22    CompressedPolicy, Config, Decision, GrpcConfig, ParsedBody, Phase, RequestContext, WafModule,
23};
24use waf_normalizer::grpc::{grpc_extract, GrpcLimits};
25
26#[derive(Default)]
27pub struct GrpcModule {
28    cfg: GrpcConfig,
29}
30
31impl GrpcModule {
32    pub fn new() -> Self {
33        Self::default()
34    }
35}
36
37fn content_type(ctx: &RequestContext) -> &str {
38    ctx.normalized
39        .headers
40        .iter()
41        .find(|(k, _)| k == "content-type")
42        .map(|(_, v)| v.as_str())
43        .unwrap_or("")
44}
45
46/// A `grpc-encoding` other than `identity` (or, by the per-message flag) means the payload
47/// is compressed → opaque to inspection. NB: an ABSENT `grpc-encoding` means `identity`
48/// (gRPC spec) → inspectable; we must not treat absent as compressed.
49fn header_compressed(ctx: &RequestContext) -> bool {
50    ctx.normalized
51        .headers
52        .iter()
53        .find(|(k, _)| k == "grpc-encoding")
54        .map(|(_, v)| !v.trim().eq_ignore_ascii_case("identity"))
55        .unwrap_or(false)
56}
57
58fn reject(reason: &'static str) -> Decision {
59    Decision::Reject {
60        rule_id: "grpc".to_string(),
61        reason: reason.to_string(),
62        status: 400,
63        retry_after: None,
64    }
65}
66
67impl WafModule for GrpcModule {
68    fn id(&self) -> &str {
69        "grpc"
70    }
71
72    fn phase(&self) -> Phase {
73        Phase::Body
74    }
75
76    /// Structural: its caps are not content-rule matches, so the content fast-path must
77    /// not skip it (else a gRPC DoS with no content signature would bypass). Same rule as
78    /// the GraphQL module.
79    fn structural(&self) -> bool {
80        true
81    }
82
83    fn init(&mut self, cfg: &Config) {
84        self.cfg = cfg.modules.grpc.clone();
85    }
86
87    fn inspect(&self, ctx: &RequestContext) -> Decision {
88        if !self.cfg.enabled {
89            return Decision::Allow;
90        }
91        // gRPC is identified by Content-Type (`application/grpc`, `+proto`, `-web`, …).
92        if !content_type(ctx).trim_start().starts_with("application/grpc") {
93            return Decision::Allow;
94        }
95        // The framed protobuf body is binary → the normalizer leaves it Raw.
96        let ParsedBody::Raw(body) = &ctx.normalized.body else {
97            return Decision::Allow;
98        };
99
100        // The module needs only the STRUCTURAL metrics (leaves go to the §6 channel via the
101        // normalizer), so it asks the parser for no leaves (`max_leaves = 0`).
102        let limits = GrpcLimits {
103            max_depth: self.cfg.max_depth,
104            max_fields: self.cfg.max_fields,
105            max_leaves: 0,
106        };
107        let ex = grpc_extract(body, limits);
108
109        // Illegal framing / wire format → reject (a malformed gRPC body is not benign).
110        if ex.malformed {
111            return reject("malformed gRPC framing");
112        }
113        // Size cap applies even to compressed frames (the length is in the frame header).
114        if ex.total_payload_bytes > self.cfg.max_message_bytes {
115            return reject("gRPC message size exceeds limit");
116        }
117        // Compressed payload = opaque → apply the configured policy (default fail-closed).
118        if ex.compressed || header_compressed(ctx) {
119            return match self.cfg.on_compressed {
120                CompressedPolicy::Reject => reject("gRPC compressed payload is not inspectable"),
121                // Passthrough: forward uninspected (declared). Size was already capped above;
122                // field/depth caps cannot be checked on an unparsed payload.
123                CompressedPolicy::Passthrough => Decision::Allow,
124            };
125        }
126        if ex.depth_exceeded {
127            return reject("gRPC message nesting depth exceeds limit");
128        }
129        if ex.fields_exceeded {
130            return reject("gRPC field count exceeds limit");
131        }
132        Decision::Allow
133    }
134}