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}