use std::path::Path;
use apollo_compiler::ast::Document;
use apollo_compiler::validation::Valid;
use apollo_compiler::{ExecutableDocument, Schema};
use waf_core::{Config, Decision, ParsedBody, Phase, RequestContext, ScoreItem, Severity, WafModule};
use waf_normalizer::graphql::unwrap_query_envelope;
pub mod api_schema;
pub mod bot;
pub mod grpc;
pub mod rules;
pub use api_schema::{
load_spec, normalize_schema_3_0, parse_spec, ApiSchemaEnforcementConfig, ApiSchemaModule,
CompiledSpec, LoadedSpec, RequestParts, Resolution, Router, Violation,
};
pub use bot::{BotDetectionConfig, BotDetectionModule};
pub use grpc::{GrpcSchemaEnforcementConfig, GrpcSchemaModule};
#[derive(Debug, Clone)]
pub struct SchemaEnforcementConfig {
pub enabled: bool,
pub paths: Vec<String>,
pub schema_path: std::path::PathBuf,
pub parse_error_severity: Severity,
pub unknown_field_severity: Severity,
}
impl Default for SchemaEnforcementConfig {
fn default() -> Self {
Self {
enabled: false,
paths: vec!["/graphql".to_string()],
schema_path: std::path::PathBuf::new(),
parse_error_severity: Severity::Warning,
unknown_field_severity: Severity::Critical,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OpValidity {
Valid,
ParseError,
SchemaInvalid,
}
pub fn parse_schema(sdl: &str) -> Result<Valid<Schema>, String> {
Schema::parse_and_validate(sdl, "schema.graphql").map_err(|e| e.errors.to_string())
}
pub fn load_schema(path: &Path) -> Result<Valid<Schema>, String> {
let sdl = std::fs::read_to_string(path)
.map_err(|e| format!("cannot read schema file {}: {e}", path.display()))?;
parse_schema(&sdl)
}
pub fn validate_op(schema: &Valid<Schema>, op: &str) -> OpValidity {
if Document::parse(op, "op.graphql").is_err() {
return OpValidity::ParseError;
}
match ExecutableDocument::parse_and_validate(schema, op, "op.graphql") {
Ok(_) => OpValidity::Valid,
Err(_) => OpValidity::SchemaInvalid,
}
}
pub struct GraphqlSchemaModule {
schema: Valid<Schema>,
enabled: bool,
paths: Vec<String>,
parse_error_severity: Severity,
unknown_field_severity: Severity,
}
impl GraphqlSchemaModule {
pub fn from_config(cfg: &SchemaEnforcementConfig) -> Result<Self, String> {
Ok(Self {
schema: load_schema(&cfg.schema_path)?,
enabled: cfg.enabled,
paths: cfg.paths.clone(),
parse_error_severity: cfg.parse_error_severity,
unknown_field_severity: cfg.unknown_field_severity,
})
}
pub fn from_sdl(sdl: &str, enabled: bool) -> Result<Self, String> {
let cfg = SchemaEnforcementConfig { enabled, ..Default::default() };
Ok(Self {
schema: parse_schema(sdl)?,
enabled: cfg.enabled,
paths: cfg.paths,
parse_error_severity: cfg.parse_error_severity,
unknown_field_severity: cfg.unknown_field_severity,
})
}
fn operations(&self, ctx: &RequestContext) -> Vec<String> {
let mut carriers = Vec::new();
if content_type(ctx).trim_start().starts_with("application/graphql") {
if let ParsedBody::Raw(b) = &ctx.normalized.body {
if let Ok(s) = std::str::from_utf8(b) {
carriers.push(s.to_string());
}
}
return expand(carriers);
}
let path_match = self.paths.iter().any(|p| ctx.normalized.path.eq_ignore_ascii_case(p));
if !path_match {
return Vec::new();
}
if let ParsedBody::JsonFlattened(pairs) = &ctx.normalized.body {
for (k, v) in pairs {
if is_graphql_query_key(k) {
carriers.push(v.clone());
}
}
}
if ctx.method.eq_ignore_ascii_case("GET") {
for (k, v) in &ctx.normalized.query_params {
if k == "query" {
carriers.push(v.clone());
}
}
}
expand(carriers)
}
}
fn expand(carriers: Vec<String>) -> Vec<String> {
let mut out = Vec::with_capacity(carriers.len());
for c in carriers {
match unwrap_query_envelope(&c) {
Some(docs) => out.extend(docs),
None => out.push(c),
}
}
out
}
fn content_type(ctx: &RequestContext) -> &str {
ctx.normalized
.headers
.iter()
.find(|(k, _)| k == "content-type")
.map(|(_, v)| v.as_str())
.unwrap_or("")
}
fn is_graphql_query_key(k: &str) -> bool {
if k == "query" {
return true;
}
match k.strip_suffix(".query") {
Some(prefix) => !prefix.is_empty() && prefix.bytes().all(|b| b.is_ascii_digit()),
None => false,
}
}
impl WafModule for GraphqlSchemaModule {
fn id(&self) -> &str {
"schema-graphql"
}
fn phase(&self) -> Phase {
Phase::Body
}
fn structural(&self) -> bool {
true
}
fn init(&mut self, _cfg: &Config) {}
fn inspect(&self, ctx: &RequestContext) -> Decision {
if !self.enabled {
return Decision::Allow;
}
let ops = self.operations(ctx);
if ops.is_empty() {
return Decision::Allow;
}
let mut parse_error = false;
let mut schema_invalid = false;
for op in &ops {
match validate_op(&self.schema, op) {
OpValidity::Valid => {}
OpValidity::ParseError => parse_error = true,
OpValidity::SchemaInvalid => schema_invalid = true,
}
}
let mut items = Vec::new();
if parse_error {
items.push(ScoreItem {
rule_id: rules::GRAPHQL_PARSE_ERROR.id(),
severity: self.parse_error_severity,
});
}
if schema_invalid {
items.push(ScoreItem {
rule_id: rules::GRAPHQL_UNKNOWN_FIELD.id(),
severity: self.unknown_field_severity,
});
}
if items.is_empty() {
Decision::Allow
} else {
Decision::Scores(items)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const SDL: &str = r#"
type Query { user(id: ID!): User }
type User { id: ID!, name: String }
"#;
fn temp_path(tag: &str) -> std::path::PathBuf {
let uniq = format!(
"waf-schema-{tag}-{}-{:?}.graphql",
std::process::id(),
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
);
std::env::temp_dir().join(uniq)
}
#[test]
fn valid_schema_parses() {
assert!(parse_schema(SDL).is_ok());
}
#[test]
fn invalid_schema_is_rejected() {
assert!(parse_schema("type Query { user: Undefined }").is_err());
}
#[test]
fn valid_query_passes() {
let schema = parse_schema(SDL).unwrap();
assert_eq!(validate_op(&schema, "{ user(id: 1) { id name } }"), OpValidity::Valid);
}
#[test]
fn unknown_field_is_schema_invalid() {
let schema = parse_schema(SDL).unwrap();
assert_eq!(
validate_op(&schema, "{ user(id: 1) { id password } }"),
OpValidity::SchemaInvalid
);
}
#[test]
fn broken_syntax_is_parse_error() {
let schema = parse_schema(SDL).unwrap();
assert_eq!(validate_op(&schema, "{ user(id: 1) {"), OpValidity::ParseError);
}
#[test]
fn load_schema_reads_and_validates_a_file() {
let path = temp_path("ok");
std::fs::write(&path, SDL).unwrap();
let loaded = load_schema(&path);
let _ = std::fs::remove_file(&path);
assert!(loaded.is_ok());
}
#[test]
fn load_schema_missing_file_is_err() {
let path = temp_path("missing"); assert!(load_schema(&path).is_err());
}
#[test]
fn from_config_is_boot_fatal_on_invalid_schema() {
let path = temp_path("bad");
std::fs::write(&path, "type Query { user: Undefined }").unwrap();
let cfg = SchemaEnforcementConfig { schema_path: path.clone(), ..Default::default() };
let built = GraphqlSchemaModule::from_config(&cfg);
let _ = std::fs::remove_file(&path);
assert!(built.is_err(), "an invalid schema file must fail construction (fail-closed boot)");
}
#[test]
fn from_config_builds_from_a_valid_file() {
let path = temp_path("good");
std::fs::write(&path, SDL).unwrap();
let cfg = SchemaEnforcementConfig {
enabled: true,
schema_path: path.clone(),
..Default::default()
};
let built = GraphqlSchemaModule::from_config(&cfg);
let _ = std::fs::remove_file(&path);
assert!(built.is_ok());
}
#[test]
fn default_config_is_off() {
assert!(!SchemaEnforcementConfig::default().enabled);
}
use waf_core::{Bytes, Normalized, ParsedBody, RequestContext};
fn ctx(
method: &str,
path: &str,
headers: &[(&str, &str)],
body: ParsedBody,
query_params: &[(&str, &str)],
) -> RequestContext {
let owned = |pairs: &[(&str, &str)]| -> Vec<(String, String)> {
pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
};
let normalized = Normalized {
path: path.to_string(),
headers: owned(headers),
query_params: owned(query_params),
body,
..Default::default()
};
RequestContext {
client_ip: "127.0.0.1".parse().unwrap(),
request_id: "t".to_string(),
timestamp: std::time::SystemTime::now(),
method: method.to_string(),
path: path.to_string(),
raw_path: path.to_string(),
query: None,
http_version: "HTTP/1.1".to_string(),
headers: vec![],
cookies: vec![],
body: Bytes::new(),
normalized,
score: 0,
score_contributions: vec![],
}
}
fn module() -> GraphqlSchemaModule {
GraphqlSchemaModule::from_sdl(SDL, true).unwrap()
}
#[test]
fn extracts_application_graphql_body_on_any_path() {
let c = ctx(
"POST",
"/anything",
&[("content-type", "application/graphql")],
ParsedBody::Raw(Bytes::from("{ user(id: 1) { id } }")),
&[],
);
assert_eq!(module().operations(&c), vec!["{ user(id: 1) { id } }".to_string()]);
}
#[test]
fn extracts_json_query_leaf_only_on_graphql_path() {
let body = ParsedBody::JsonFlattened(vec![("query".to_string(), "{ a }".to_string())]);
let on = ctx("POST", "/graphql", &[], body.clone(), &[]);
assert_eq!(module().operations(&on), vec!["{ a }".to_string()]);
let off = ctx("POST", "/api/search", &[], body, &[]);
assert!(module().operations(&off).is_empty());
}
#[test]
fn extracts_batched_query_leaves() {
let body = ParsedBody::JsonFlattened(vec![
("0.query".to_string(), "{ a }".to_string()),
("1.query".to_string(), "{ b }".to_string()),
("variables.query".to_string(), "not an op".to_string()),
]);
let c = ctx("POST", "/graphql", &[], body, &[]);
assert_eq!(module().operations(&c), vec!["{ a }".to_string(), "{ b }".to_string()]);
}
#[test]
fn extracts_get_query_param_and_unwraps_json_envelope() {
let bare = ctx("GET", "/graphql", &[], ParsedBody::None, &[("query", "{ a }")]);
assert_eq!(module().operations(&bare), vec!["{ a }".to_string()]);
let env = ctx(
"GET",
"/graphql",
&[],
ParsedBody::None,
&[("query", r#"{"query":"{ b }"}"#)],
);
assert_eq!(module().operations(&env), vec!["{ b }".to_string()]);
}
#[test]
fn query_key_matching() {
assert!(is_graphql_query_key("query"));
assert!(is_graphql_query_key("0.query"));
assert!(is_graphql_query_key("11.query"));
assert!(!is_graphql_query_key("variables.query"));
assert!(!is_graphql_query_key("a.query"));
assert!(!is_graphql_query_key("query.sub"));
assert!(!is_graphql_query_key(".query"));
}
const RICH_SDL: &str = r#"
type Query { user(id: ID!): User, me: User }
type User { id: ID!, name: String, friends: [User!] }
"#;
#[test]
fn legit_constructs_validate_clean() {
let schema = parse_schema(RICH_SDL).unwrap();
let legit = [
("plain", "{ user(id: 1) { id name } }"),
("field_alias", "{ current: user(id: 1) { id } me { id } }"),
("typename_meta", "{ user(id: 1) { __typename id } }"),
("named_fragment", "query { user(id: 1) { ...F } } fragment F on User { id name }"),
("inline_fragment", "{ user(id: 1) { ... on User { id } } }"),
("variables_used", "query($id: ID!) { user(id: $id) { id } }"),
("builtin_directive", "{ user(id: 1) { name @include(if: true) } }"),
("nested_list", "{ user(id: 1) { friends { id } } }"),
("introspection_schema", "{ __schema { queryType { name } } }"),
("introspection_type", r#"{ __type(name: "User") { name } }"#),
];
for (name, q) in legit {
assert_eq!(
validate_op(&schema, q),
OpValidity::Valid,
"legit construct `{name}` must be Valid (no FP): {q}"
);
}
}
#[test]
fn spec_violations_are_flagged() {
let schema = parse_schema(RICH_SDL).unwrap();
for q in [
"{ user(id: $nope) { id } }", "{ user(id: 1) }", "query($x: ID!) { user(id: 1) { id } }", "query { user(id: 1) { id } } fragment F on User { n }", ] {
assert_eq!(validate_op(&schema, q), OpValidity::SchemaInvalid, "must flag: {q}");
}
assert_eq!(validate_op(&schema, "{ user(id: 1) { id id } }"), OpValidity::Valid);
}
#[test]
fn batch_of_invalid_ops_dedups_to_one_contribution() {
let body = ParsedBody::JsonFlattened(vec![
("0.query".to_string(), "{ user(id: 1) { nope } }".to_string()),
("1.query".to_string(), "{ user(id: 1) { alsoNope } }".to_string()),
]);
let c = ctx("POST", "/graphql", &[], body, &[]);
match module().inspect(&c) {
Decision::Scores(items) => {
assert_eq!(items.len(), 1, "batch must yield one deduped contribution, not N");
assert_eq!(items[0].rule_id, rules::GRAPHQL_UNKNOWN_FIELD.id());
}
other => panic!("expected Scores, got {other:?}"),
}
}
}