1use 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#[derive(Debug, Clone)]
46pub struct SchemaEnforcementConfig {
47 pub enabled: bool,
49 pub paths: Vec<String>,
52 pub schema_path: std::path::PathBuf,
54 pub parse_error_severity: Severity,
56 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub enum OpValidity {
75 Valid,
77 ParseError,
79 SchemaInvalid,
84}
85
86pub 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
92pub 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
102pub fn validate_op(schema: &Valid<Schema>, op: &str) -> OpValidity {
110 if Document::parse(op, "op.graphql").is_err() {
112 return OpValidity::ParseError;
113 }
114 match ExecutableDocument::parse_and_validate(schema, op, "op.graphql") {
116 Ok(_) => OpValidity::Valid,
117 Err(_) => OpValidity::SchemaInvalid,
118 }
119}
120
121pub struct GraphqlSchemaModule {
124 schema: Valid<Schema>,
125 enabled: bool,
126 paths: Vec<String>,
130 parse_error_severity: Severity,
131 unknown_field_severity: Severity,
132}
133
134impl GraphqlSchemaModule {
135 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 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 fn operations(&self, ctx: &RequestContext) -> Vec<String> {
166 let mut carriers = Vec::new();
167
168 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 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
201fn 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
225fn 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 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 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 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 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 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 #[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"); 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 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 let on = ctx("POST", "/graphql", &[], body.clone(), &[]);
455 assert_eq!(module().operations(&on), vec!["{ a }".to_string()]);
456 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 ("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 let bare = ctx("GET", "/graphql", &[], ParsedBody::None, &[("query", "{ a }")]);
477 assert_eq!(module().operations(&bare), vec!["{ a }".to_string()]);
478 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 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 #[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 #[test]
539 fn spec_violations_are_flagged() {
540 let schema = parse_schema(RICH_SDL).unwrap();
541 for q in [
542 "{ user(id: $nope) { id } }", "{ user(id: 1) }", "query($x: ID!) { user(id: 1) { id } }", "query { user(id: 1) { id } } fragment F on User { n }", ] {
547 assert_eq!(validate_op(&schema, q), OpValidity::SchemaInvalid, "must flag: {q}");
548 }
549 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 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}