Skip to main content

waf_schema_enforcement/
lib.rs

1// SPDX-FileCopyrightText: 2026 0x00spor3
2// SPDX-License-Identifier: Apache-2.0
3
4//! Positive-security detection modules for [Light WAF](https://github.com/0x00spor3/Light-WAF): they
5//! validate requests against **the application's real contract** and score what falls outside it.
6//!
7//! - [`GraphqlSchemaModule`] — GraphQL operations against the app's SDL (`apollo-compiler`).
8//! - [`GrpcSchemaModule`] — unary gRPC messages against the app's `FileDescriptorSet`
9//!   (`prost-reflect`).
10//! - [`ApiSchemaModule`] — REST/JSON requests against the app's OpenAPI 3.0/3.1 spec (`oas3` +
11//!   `jsonschema`, with no HTTP retriever: a WAF never fetches remote schemas).
12//! - [`BotDetectionModule`] — client integrity: a modern-browser user agent with no `sec-fetch-*`
13//!   headers.
14//!
15//! Every module returns additive scores into the same cumulative anomaly score as the core modules,
16//! under reserved rule-id namespaces ([`rules`]), with configurable severities. The core's structural
17//! caps run first, so validators only see bounded input; value-level injection stays in the core.
18//! A missing or invalid schema/descriptor/spec is a construction error (fail-closed on configuration,
19//! never on traffic). Build the modules inside a `module_factory` to pick up a changed schema on
20//! reload.
21
22use std::path::Path;
23
24use apollo_compiler::ast::Document;
25use apollo_compiler::validation::Valid;
26use apollo_compiler::{ExecutableDocument, Schema};
27use waf_core::{Config, Decision, ParsedBody, Phase, RequestContext, ScoreItem, Severity, WafModule};
28use waf_normalizer::graphql::unwrap_query_envelope;
29
30pub mod api_schema;
31pub mod bot;
32pub mod grpc;
33pub mod rules;
34
35pub use api_schema::{
36    load_spec, normalize_schema_3_0, parse_spec, ApiSchemaEnforcementConfig, ApiSchemaModule,
37    CompiledSpec, LoadedSpec, RequestParts, Resolution, Router, Violation,
38};
39pub use bot::{BotDetectionConfig, BotDetectionModule};
40pub use grpc::{GrpcSchemaEnforcementConfig, GrpcSchemaModule};
41
42/// Configuration for the GraphQL schema-enforcement module. The core `Config` carries no
43/// enterprise fields, so the enterprise wiring builds this from `[enterprise.schema_enforcement]`
44/// (Step 5) and hands it to [`GraphqlSchemaModule::from_config`].
45#[derive(Debug, Clone)]
46pub struct SchemaEnforcementConfig {
47    /// Off by default → the module is inert (contributes nothing).
48    pub enabled: bool,
49    /// GraphQL endpoint path(s) this schema guards. Used by the multi-transport operation
50    /// extraction (Step 3); the probe transport (`application/graphql` body) ignores it.
51    pub paths: Vec<String>,
52    /// PEM-less SDL schema file (`.graphql`) describing the app's real API.
53    pub schema_path: std::path::PathBuf,
54    /// Severity for a body that is not parseable as GraphQL (lower — broken client / generic probing).
55    pub parse_error_severity: Severity,
56    /// Severity for a syntactically valid query that violates the schema (Critical — targeted probing).
57    pub unknown_field_severity: Severity,
58}
59
60impl Default for SchemaEnforcementConfig {
61    fn default() -> Self {
62        Self {
63            enabled: false,
64            paths: vec!["/graphql".to_string()],
65            schema_path: std::path::PathBuf::new(),
66            parse_error_severity: Severity::Warning,
67            unknown_field_severity: Severity::Critical,
68        }
69    }
70}
71
72/// How a GraphQL operation validates against the loaded schema.
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub enum OpValidity {
75    /// Parses and validates cleanly against the schema.
76    Valid,
77    /// Not even parseable as GraphQL (broken client / generic probing).
78    ParseError,
79    /// Parses as GraphQL, but fails validation against the app's real schema — an unknown
80    /// field/type/argument (the canonical targeted-probing case) or any other schema-validation
81    /// failure (undefined/unused variable, missing subselection…). A spec-compliant client never
82    /// emits these and the real backend rejects them too, so it is not a false positive.
83    SchemaInvalid,
84}
85
86/// Pure, re-entrant SDL parse+validate (G5 reload-readiness): SDL **text** → validated schema, no
87/// global state, no I/O. The testable core of schema loading.
88pub fn parse_schema(sdl: &str) -> Result<Valid<Schema>, String> {
89    Schema::parse_and_validate(sdl, "schema.graphql").map_err(|e| e.errors.to_string())
90}
91
92/// Load and validate a schema **from a file** (G5 reload-ready entry point): read → [`parse_schema`].
93/// Called at construction so the boot can fail-closed on a missing/invalid schema; the future core
94/// `module_factory` will call the SAME function with keep-old-on-invalid (re-entrant, no side effects
95/// beyond the read).
96pub fn load_schema(path: &Path) -> Result<Valid<Schema>, String> {
97    let sdl = std::fs::read_to_string(path)
98        .map_err(|e| format!("cannot read schema file {}: {e}", path.display()))?;
99    parse_schema(&sdl)
100}
101
102/// Validate one operation string against the schema, splitting parse-error from schema-invalid so
103/// the two carry distinct severities (G3).
104///
105/// **Step-0 discovery**: `ExecutableDocument::parse` is schema-aware — it already rejects unknown
106/// fields at parse time — so it cannot separate "not GraphQL syntax" from "valid GraphQL, wrong for
107/// the schema". The clean split uses the pure-syntax AST parser (no schema) as the parse-error gate,
108/// then `parse_and_validate` for everything schema-related.
109pub fn validate_op(schema: &Valid<Schema>, op: &str) -> OpValidity {
110    // Pure syntax gate (schema-free): a broken client / non-GraphQL body fails here.
111    if Document::parse(op, "op.graphql").is_err() {
112        return OpValidity::ParseError;
113    }
114    // Syntactically valid → does it validate against the app's real schema?
115    match ExecutableDocument::parse_and_validate(schema, op, "op.graphql") {
116        Ok(_) => OpValidity::Valid,
117        Err(_) => OpValidity::SchemaInvalid,
118    }
119}
120
121/// GraphQL schema-enforcement module. Holds the app's validated schema (immutable in-memory — no
122/// hot-reload in v1, see G5) and validates each operation the request carries.
123pub struct GraphqlSchemaModule {
124    schema: Valid<Schema>,
125    enabled: bool,
126    /// GraphQL endpoint path(s): JSON/GET transports are inspected ONLY here, so a non-GraphQL JSON
127    /// API with a `query` field is left alone (mirrors the OPEN Phase-11 gating). The
128    /// `application/graphql` body is recognised by Content-Type on any path.
129    paths: Vec<String>,
130    parse_error_severity: Severity,
131    unknown_field_severity: Severity,
132}
133
134impl GraphqlSchemaModule {
135    /// Production constructor: load the schema from `cfg.schema_path`. **Boot-fatal** on a
136    /// missing/invalid schema — `init(&Config)` gets the core config (no schema path) and returns
137    /// `()`, so the fail-closed load MUST happen here at construction (like `ReputationModule`).
138    pub fn from_config(cfg: &SchemaEnforcementConfig) -> Result<Self, String> {
139        Ok(Self {
140            schema: load_schema(&cfg.schema_path)?,
141            enabled: cfg.enabled,
142            paths: cfg.paths.clone(),
143            parse_error_severity: cfg.parse_error_severity,
144            unknown_field_severity: cfg.unknown_field_severity,
145        })
146    }
147
148    /// Test/embedding constructor from inline SDL **text** (no file), using default config (paths
149    /// `["/graphql"]`, severities Warning/Critical).
150    pub fn from_sdl(sdl: &str, enabled: bool) -> Result<Self, String> {
151        let cfg = SchemaEnforcementConfig { enabled, ..Default::default() };
152        Ok(Self {
153            schema: parse_schema(sdl)?,
154            enabled: cfg.enabled,
155            paths: cfg.paths,
156            parse_error_severity: cfg.parse_error_severity,
157            unknown_field_severity: cfg.unknown_field_severity,
158        })
159    }
160
161    /// The GraphQL operation text(s) the request carries — a faithful mirror of the OPEN
162    /// `GraphqlModule::operations` (Phase 11): `application/graphql` raw body by Content-Type on any
163    /// path; a JSON `query` leaf (and batch `<i>.query`) and a GET `?query=` value ONLY on a
164    /// configured GraphQL path. Empty ⇒ not a recognised GraphQL request → the module stays inert.
165    fn operations(&self, ctx: &RequestContext) -> Vec<String> {
166        let mut carriers = Vec::new();
167
168        // `application/graphql` raw body — identified by Content-Type, any path. A single operation.
169        if content_type(ctx).trim_start().starts_with("application/graphql") {
170            if let ParsedBody::Raw(b) = &ctx.normalized.body {
171                if let Ok(s) = std::str::from_utf8(b) {
172                    carriers.push(s.to_string());
173                }
174            }
175            return expand(carriers);
176        }
177
178        // JSON / GET transports: only on a configured GraphQL endpoint path.
179        let path_match = self.paths.iter().any(|p| ctx.normalized.path.eq_ignore_ascii_case(p));
180        if !path_match {
181            return Vec::new();
182        }
183        if let ParsedBody::JsonFlattened(pairs) = &ctx.normalized.body {
184            for (k, v) in pairs {
185                if is_graphql_query_key(k) {
186                    carriers.push(v.clone());
187                }
188            }
189        }
190        if ctx.method.eq_ignore_ascii_case("GET") {
191            for (k, v) in &ctx.normalized.query_params {
192                if k == "query" {
193                    carriers.push(v.clone());
194                }
195            }
196        }
197        expand(carriers)
198    }
199}
200
201/// Expand each carrier into the operation(s) the validator must see: a carrier that is a JSON
202/// envelope `{"query":"<doc>"}` is unwrapped to the inner document(s); any other carrier (a bare
203/// GraphQL document, `application/graphql` body, or already-extracted JSON leaf) is used as-is.
204/// Reuses the OPEN `unwrap_query_envelope` so extraction never diverges from the Phase-11 module.
205fn expand(carriers: Vec<String>) -> Vec<String> {
206    let mut out = Vec::with_capacity(carriers.len());
207    for c in carriers {
208        match unwrap_query_envelope(&c) {
209            Some(docs) => out.extend(docs),
210            None => out.push(c),
211        }
212    }
213    out
214}
215
216fn content_type(ctx: &RequestContext) -> &str {
217    ctx.normalized
218        .headers
219        .iter()
220        .find(|(k, _)| k == "content-type")
221        .map(|(_, v)| v.as_str())
222        .unwrap_or("")
223}
224
225/// A flattened-JSON key that holds a GraphQL operation: the top-level `query`, or a batch element
226/// `<index>.query`. NOT an arbitrary `*.query` (e.g. a variable named `query` nested under
227/// `variables`). Re-derived from the OPEN module (where it is private).
228fn is_graphql_query_key(k: &str) -> bool {
229    if k == "query" {
230        return true;
231    }
232    match k.strip_suffix(".query") {
233        Some(prefix) => !prefix.is_empty() && prefix.bytes().all(|b| b.is_ascii_digit()),
234        None => false,
235    }
236}
237
238impl WafModule for GraphqlSchemaModule {
239    fn id(&self) -> &str {
240        "schema-graphql"
241    }
242
243    fn phase(&self) -> Phase {
244        Phase::Body
245    }
246
247    /// Structural: its verdict is not a content-rule match, so the content fast-path must not skip
248    /// it (lesson of Phase 11).
249    fn structural(&self) -> bool {
250        true
251    }
252
253    fn init(&mut self, _cfg: &Config) {}
254
255    fn inspect(&self, ctx: &RequestContext) -> Decision {
256        if !self.enabled {
257            return Decision::Allow;
258        }
259        let ops = self.operations(ctx);
260        if ops.is_empty() {
261            return Decision::Allow;
262        }
263        // Dedup the signal by kind: a batch of N schema-invalid operations is one probing signal,
264        // not N — so we emit at most one `parse-error` and one `unknown-field` contribution
265        // regardless of batch size (bounded contribution; the OPEN Phase-11 cap bounds batch size
266        // upstream anyway). We still scan every operation so any single violation is caught.
267        let mut parse_error = false;
268        let mut schema_invalid = false;
269        for op in &ops {
270            match validate_op(&self.schema, op) {
271                OpValidity::Valid => {}
272                OpValidity::ParseError => parse_error = true,
273                OpValidity::SchemaInvalid => schema_invalid = true,
274            }
275        }
276        let mut items = Vec::new();
277        if parse_error {
278            items.push(ScoreItem {
279                rule_id: rules::GRAPHQL_PARSE_ERROR.id(),
280                severity: self.parse_error_severity,
281            });
282        }
283        if schema_invalid {
284            items.push(ScoreItem {
285                rule_id: rules::GRAPHQL_UNKNOWN_FIELD.id(),
286                severity: self.unknown_field_severity,
287            });
288        }
289        if items.is_empty() {
290            Decision::Allow
291        } else {
292            Decision::Scores(items)
293        }
294    }
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300
301    const SDL: &str = r#"
302        type Query { user(id: ID!): User }
303        type User { id: ID!, name: String }
304    "#;
305
306    /// A unique temp path so parallel tests don't collide (no tempfile dep).
307    fn temp_path(tag: &str) -> std::path::PathBuf {
308        let uniq = format!(
309            "waf-schema-{tag}-{}-{:?}.graphql",
310            std::process::id(),
311            std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
312        );
313        std::env::temp_dir().join(uniq)
314    }
315
316    #[test]
317    fn valid_schema_parses() {
318        assert!(parse_schema(SDL).is_ok());
319    }
320
321    #[test]
322    fn invalid_schema_is_rejected() {
323        // `Query` references an undefined type.
324        assert!(parse_schema("type Query { user: Undefined }").is_err());
325    }
326
327    #[test]
328    fn valid_query_passes() {
329        let schema = parse_schema(SDL).unwrap();
330        assert_eq!(validate_op(&schema, "{ user(id: 1) { id name } }"), OpValidity::Valid);
331    }
332
333    #[test]
334    fn unknown_field_is_schema_invalid() {
335        let schema = parse_schema(SDL).unwrap();
336        // `password` is not a field of `User` — a query the real client never sends.
337        assert_eq!(
338            validate_op(&schema, "{ user(id: 1) { id password } }"),
339            OpValidity::SchemaInvalid
340        );
341    }
342
343    #[test]
344    fn broken_syntax_is_parse_error() {
345        let schema = parse_schema(SDL).unwrap();
346        assert_eq!(validate_op(&schema, "{ user(id: 1) {"), OpValidity::ParseError);
347    }
348
349    // ── Step 2: schema load from file + config-driven construction ──────────────
350
351    #[test]
352    fn load_schema_reads_and_validates_a_file() {
353        let path = temp_path("ok");
354        std::fs::write(&path, SDL).unwrap();
355        let loaded = load_schema(&path);
356        let _ = std::fs::remove_file(&path);
357        assert!(loaded.is_ok());
358    }
359
360    #[test]
361    fn load_schema_missing_file_is_err() {
362        let path = temp_path("missing"); // never created
363        assert!(load_schema(&path).is_err());
364    }
365
366    #[test]
367    fn from_config_is_boot_fatal_on_invalid_schema() {
368        let path = temp_path("bad");
369        std::fs::write(&path, "type Query { user: Undefined }").unwrap();
370        let cfg = SchemaEnforcementConfig { schema_path: path.clone(), ..Default::default() };
371        let built = GraphqlSchemaModule::from_config(&cfg);
372        let _ = std::fs::remove_file(&path);
373        assert!(built.is_err(), "an invalid schema file must fail construction (fail-closed boot)");
374    }
375
376    #[test]
377    fn from_config_builds_from_a_valid_file() {
378        let path = temp_path("good");
379        std::fs::write(&path, SDL).unwrap();
380        let cfg = SchemaEnforcementConfig {
381            enabled: true,
382            schema_path: path.clone(),
383            ..Default::default()
384        };
385        let built = GraphqlSchemaModule::from_config(&cfg);
386        let _ = std::fs::remove_file(&path);
387        assert!(built.is_ok());
388    }
389
390    #[test]
391    fn default_config_is_off() {
392        assert!(!SchemaEnforcementConfig::default().enabled);
393    }
394
395    // ── Step 3: multi-transport operation extraction ────────────────────────────
396
397    use waf_core::{Bytes, Normalized, ParsedBody, RequestContext};
398
399    fn ctx(
400        method: &str,
401        path: &str,
402        headers: &[(&str, &str)],
403        body: ParsedBody,
404        query_params: &[(&str, &str)],
405    ) -> RequestContext {
406        let owned = |pairs: &[(&str, &str)]| -> Vec<(String, String)> {
407            pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
408        };
409        let normalized = Normalized {
410            path: path.to_string(),
411            headers: owned(headers),
412            query_params: owned(query_params),
413            body,
414            ..Default::default()
415        };
416        RequestContext {
417            client_ip: "127.0.0.1".parse().unwrap(),
418            request_id: "t".to_string(),
419            timestamp: std::time::SystemTime::now(),
420            method: method.to_string(),
421            path: path.to_string(),
422            raw_path: path.to_string(),
423            query: None,
424            http_version: "HTTP/1.1".to_string(),
425            headers: vec![],
426            cookies: vec![],
427            body: Bytes::new(),
428            normalized,
429            score: 0,
430            score_contributions: vec![],
431        }
432    }
433
434    fn module() -> GraphqlSchemaModule {
435        GraphqlSchemaModule::from_sdl(SDL, true).unwrap()
436    }
437
438    #[test]
439    fn extracts_application_graphql_body_on_any_path() {
440        let c = ctx(
441            "POST",
442            "/anything",
443            &[("content-type", "application/graphql")],
444            ParsedBody::Raw(Bytes::from("{ user(id: 1) { id } }")),
445            &[],
446        );
447        assert_eq!(module().operations(&c), vec!["{ user(id: 1) { id } }".to_string()]);
448    }
449
450    #[test]
451    fn extracts_json_query_leaf_only_on_graphql_path() {
452        let body = ParsedBody::JsonFlattened(vec![("query".to_string(), "{ a }".to_string())]);
453        // On the configured GraphQL path → extracted.
454        let on = ctx("POST", "/graphql", &[], body.clone(), &[]);
455        assert_eq!(module().operations(&on), vec!["{ a }".to_string()]);
456        // On a non-GraphQL path → left alone (a JSON API with a `query` field is not GraphQL).
457        let off = ctx("POST", "/api/search", &[], body, &[]);
458        assert!(module().operations(&off).is_empty());
459    }
460
461    #[test]
462    fn extracts_batched_query_leaves() {
463        let body = ParsedBody::JsonFlattened(vec![
464            ("0.query".to_string(), "{ a }".to_string()),
465            ("1.query".to_string(), "{ b }".to_string()),
466            // A nested variable named `query` must NOT be treated as an operation.
467            ("variables.query".to_string(), "not an op".to_string()),
468        ]);
469        let c = ctx("POST", "/graphql", &[], body, &[]);
470        assert_eq!(module().operations(&c), vec!["{ a }".to_string(), "{ b }".to_string()]);
471    }
472
473    #[test]
474    fn extracts_get_query_param_and_unwraps_json_envelope() {
475        // Bare document in ?query=.
476        let bare = ctx("GET", "/graphql", &[], ParsedBody::None, &[("query", "{ a }")]);
477        assert_eq!(module().operations(&bare), vec!["{ a }".to_string()]);
478        // A JSON envelope carried in ?query= is unwrapped to the inner document (reuses OPEN helper).
479        let env = ctx(
480            "GET",
481            "/graphql",
482            &[],
483            ParsedBody::None,
484            &[("query", r#"{"query":"{ b }"}"#)],
485        );
486        assert_eq!(module().operations(&env), vec!["{ b }".to_string()]);
487    }
488
489    #[test]
490    fn query_key_matching() {
491        assert!(is_graphql_query_key("query"));
492        assert!(is_graphql_query_key("0.query"));
493        assert!(is_graphql_query_key("11.query"));
494        assert!(!is_graphql_query_key("variables.query"));
495        assert!(!is_graphql_query_key("a.query"));
496        assert!(!is_graphql_query_key("query.sub"));
497        assert!(!is_graphql_query_key(".query"));
498    }
499
500    // ── Step 6: FP-guard — legitimate GraphQL constructs must validate CLEAN ────
501    // A richer schema so we can exercise aliases, fragments, variables, directives, lists.
502    const RICH_SDL: &str = r#"
503        type Query { user(id: ID!): User, me: User }
504        type User { id: ID!, name: String, friends: [User!] }
505    "#;
506
507    /// Probe: discover apollo's verdict on a set of constructs, so a false positive on a real
508    /// client's query can never slip into a schema-invalid score.
509    #[test]
510    fn legit_constructs_validate_clean() {
511        let schema = parse_schema(RICH_SDL).unwrap();
512        let legit = [
513            ("plain", "{ user(id: 1) { id name } }"),
514            ("field_alias", "{ current: user(id: 1) { id } me { id } }"),
515            ("typename_meta", "{ user(id: 1) { __typename id } }"),
516            ("named_fragment", "query { user(id: 1) { ...F } } fragment F on User { id name }"),
517            ("inline_fragment", "{ user(id: 1) { ... on User { id } } }"),
518            ("variables_used", "query($id: ID!) { user(id: $id) { id } }"),
519            ("builtin_directive", "{ user(id: 1) { name @include(if: true) } }"),
520            ("nested_list", "{ user(id: 1) { friends { id } } }"),
521            ("introspection_schema", "{ __schema { queryType { name } } }"),
522            ("introspection_type", r#"{ __type(name: "User") { name } }"#),
523        ];
524        for (name, q) in legit {
525            assert_eq!(
526                validate_op(&schema, q),
527                OpValidity::Valid,
528                "legit construct `{name}` must be Valid (no FP): {q}"
529            );
530        }
531    }
532
533    /// Documents the frozen verdict on spec-edge cases (probed empirically against apollo-compiler).
534    /// All of these are genuine schema-validation failures that a spec-compliant client never emits
535    /// and the real GraphQL backend rejects too — so flagging them is NOT a false positive. The one
536    /// real FP source (schema drift: the app deployed a newer schema than the WAF's copy) is handled
537    /// by G3 (Scores + tunable severity), not by loosening validation.
538    #[test]
539    fn spec_violations_are_flagged() {
540        let schema = parse_schema(RICH_SDL).unwrap();
541        for q in [
542            "{ user(id: $nope) { id } }",                             // undefined variable
543            "{ user(id: 1) }",                                        // missing subselection
544            "query($x: ID!) { user(id: 1) { id } }",                 // unused variable
545            "query { user(id: 1) { id } } fragment F on User { n }", // unused + invalid fragment
546        ] {
547            assert_eq!(validate_op(&schema, q), OpValidity::SchemaInvalid, "must flag: {q}");
548        }
549        // Identical duplicate fields are valid (they merge) — must NOT be flagged.
550        assert_eq!(validate_op(&schema, "{ user(id: 1) { id id } }"), OpValidity::Valid);
551    }
552
553    #[test]
554    fn batch_of_invalid_ops_dedups_to_one_contribution() {
555        // Two schema-invalid operations in one batch → a single `unknown-field` signal (bounded).
556        let body = ParsedBody::JsonFlattened(vec![
557            ("0.query".to_string(), "{ user(id: 1) { nope } }".to_string()),
558            ("1.query".to_string(), "{ user(id: 1) { alsoNope } }".to_string()),
559        ]);
560        let c = ctx("POST", "/graphql", &[], body, &[]);
561        match module().inspect(&c) {
562            Decision::Scores(items) => {
563                assert_eq!(items.len(), 1, "batch must yield one deduped contribution, not N");
564                assert_eq!(items[0].rule_id, rules::GRAPHQL_UNKNOWN_FIELD.id());
565            }
566            other => panic!("expected Scores, got {other:?}"),
567        }
568    }
569}