Loading config/schemas/graph_query.schema.json +18 −9 Original line number Diff line number Diff line Loading @@ -35,6 +35,7 @@ "type": "array", "description": "Aggregation functions to apply. Required when query_type is 'aggregation'.", "default": [], "maxItems": 10, "items": { "$ref": "#/$defs/Aggregation" } Loading Loading @@ -239,13 +240,14 @@ }, "filters": { "type": "object", "description": "Property filters. Keys are property names, values are Filter objects.", "description": "Property filters. Keys are property names, values are Filter objects. Limited to prevent query complexity explosion.", "propertyNames": { "$ref": "#/$defs/Identifier" }, "additionalProperties": { "$ref": "#/$defs/Filter" }, "maxProperties": 10, "default": {} }, "node_ids": { Loading Loading @@ -282,7 +284,8 @@ "items": { "$ref": "#/$defs/Identifier" }, "minItems": 1 "minItems": 1, "maxItems": 50 } ] }, Loading Loading @@ -325,7 +328,8 @@ }, { "type": "string", "description": "String value" "description": "String value", "maxLength": 1024 }, { "type": "boolean", Loading @@ -336,7 +340,8 @@ "description": "List of values", "items": { "$ref": "#/$defs/FilterValue" } }, "maxItems": 100 } ] }, Loading Loading @@ -375,7 +380,7 @@ }, { "if": { "properties": { "op": { "enum": ["contains", "starts_with", "ends_with"] } } }, "then": { "required": ["value"], "properties": { "value": { "type": "string" } } } "then": { "required": ["value"], "properties": { "value": { "type": "string", "maxLength": 1024 } } } } ] }, Loading Loading @@ -405,7 +410,7 @@ }, "max_hops": { "type": "integer", "description": "Maximum number of relationship hops. SECURITY: Limited to 3 to prevent resource exhaustion.", "description": "Maximum number of relationship hops. Limited to prevent resource exhaustion.", "minimum": 1, "maximum": 3, "default": 1 Loading @@ -417,13 +422,14 @@ }, "filters": { "type": "object", "description": "Filters on relationship properties", "description": "Filters on relationship properties. Limited to prevent query complexity explosion.", "propertyNames": { "$ref": "#/$defs/Identifier" }, "additionalProperties": { "$ref": "#/$defs/Filter" }, "maxProperties": 5, "default": {} } }, Loading Loading @@ -452,7 +458,8 @@ "items": { "$ref": "#/$defs/RelationshipTypeName" }, "minItems": 1 "minItems": 1, "maxItems": 10 } ] }, Loading Loading @@ -535,7 +542,8 @@ "description": "Optional: relationship types to traverse. If omitted, all types are considered.", "items": { "$ref": "#/$defs/RelationshipTypeName" } }, "maxItems": 10 } } }, Loading Loading @@ -564,6 +572,7 @@ "items": { "$ref": "#/$defs/RelationshipTypeName" }, "maxItems": 10, "default": [] } } Loading crates/query-engine/src/validate.rs +54 −9 Original line number Diff line number Diff line Loading @@ -149,15 +149,20 @@ impl<'a> Validator<'a> { } /// Defense-in-depth: reject queries that exceed hard caps on complexity. /// The JSON schema already enforces these limits via maxItems / maximum, /// so this only fires if schema validation was somehow bypassed. /// The JSON schema already enforces these limits via maxItems / maximum / /// maxProperties, so this only fires if schema validation was somehow bypassed. pub fn check_depth(&self, input: &Input) -> Result<()> { const MAX_HOPS_CAP: u32 = 3; const MAX_DEPTH_CAP: u32 = 3; const MAX_NODES_CAP: usize = 5; const MAX_RELS_CAP: usize = 5; const MAX_AGGS_CAP: usize = 10; const MAX_NODE_IDS: usize = 500; const MAX_IN_VALUES: usize = 100; const MAX_FILTERS_PER_NODE: usize = 20; const MAX_FILTERS_PER_REL: usize = 10; const MAX_COLUMNS: usize = 50; const MAX_REL_TYPES: usize = 10; if input.nodes.len() > MAX_NODES_CAP { return Err(QueryError::DepthExceeded(format!( Loading @@ -171,6 +176,12 @@ impl<'a> Validator<'a> { input.relationships.len() ))); } if input.aggregations.len() > MAX_AGGS_CAP { return Err(QueryError::LimitExceeded(format!( "aggregations count ({}) must not exceed {MAX_AGGS_CAP}", input.aggregations.len() ))); } for rel in &input.relationships { if rel.max_hops > MAX_HOPS_CAP { return Err(QueryError::DepthExceeded(format!( Loading @@ -178,15 +189,33 @@ impl<'a> Validator<'a> { rel.max_hops ))); } if rel.types.len() > MAX_REL_TYPES { return Err(QueryError::LimitExceeded(format!( "relationship type count ({}) must not exceed {MAX_REL_TYPES}", rel.types.len() ))); } if let Some(ref path) = input.path && path.max_depth > MAX_DEPTH_CAP { if rel.filters.len() > MAX_FILTERS_PER_REL { return Err(QueryError::LimitExceeded(format!( "relationship filter count ({}) must not exceed {MAX_FILTERS_PER_REL}", rel.filters.len() ))); } } if let Some(ref path) = input.path { if path.max_depth > MAX_DEPTH_CAP { return Err(QueryError::DepthExceeded(format!( "max_depth ({}) must not exceed {MAX_DEPTH_CAP}", path.max_depth ))); } if path.rel_types.len() > MAX_REL_TYPES { return Err(QueryError::LimitExceeded(format!( "path rel_types count ({}) must not exceed {MAX_REL_TYPES}", path.rel_types.len() ))); } } for node in &input.nodes { if node.node_ids.len() > MAX_NODE_IDS { return Err(QueryError::LimitExceeded(format!( Loading @@ -195,6 +224,22 @@ impl<'a> Validator<'a> { node.id ))); } if node.filters.len() > MAX_FILTERS_PER_NODE { return Err(QueryError::LimitExceeded(format!( "filter count ({}) for node \"{}\" must not exceed {MAX_FILTERS_PER_NODE}", node.filters.len(), node.id ))); } if let Some(crate::input::ColumnSelection::List(cols)) = &node.columns && cols.len() > MAX_COLUMNS { return Err(QueryError::LimitExceeded(format!( "columns count ({}) for node \"{}\" must not exceed {MAX_COLUMNS}", cols.len(), node.id ))); } for (prop, filter) in &node.filters { if let Some(FilterOp::In) = filter.op { let len = filter Loading Loading
config/schemas/graph_query.schema.json +18 −9 Original line number Diff line number Diff line Loading @@ -35,6 +35,7 @@ "type": "array", "description": "Aggregation functions to apply. Required when query_type is 'aggregation'.", "default": [], "maxItems": 10, "items": { "$ref": "#/$defs/Aggregation" } Loading Loading @@ -239,13 +240,14 @@ }, "filters": { "type": "object", "description": "Property filters. Keys are property names, values are Filter objects.", "description": "Property filters. Keys are property names, values are Filter objects. Limited to prevent query complexity explosion.", "propertyNames": { "$ref": "#/$defs/Identifier" }, "additionalProperties": { "$ref": "#/$defs/Filter" }, "maxProperties": 10, "default": {} }, "node_ids": { Loading Loading @@ -282,7 +284,8 @@ "items": { "$ref": "#/$defs/Identifier" }, "minItems": 1 "minItems": 1, "maxItems": 50 } ] }, Loading Loading @@ -325,7 +328,8 @@ }, { "type": "string", "description": "String value" "description": "String value", "maxLength": 1024 }, { "type": "boolean", Loading @@ -336,7 +340,8 @@ "description": "List of values", "items": { "$ref": "#/$defs/FilterValue" } }, "maxItems": 100 } ] }, Loading Loading @@ -375,7 +380,7 @@ }, { "if": { "properties": { "op": { "enum": ["contains", "starts_with", "ends_with"] } } }, "then": { "required": ["value"], "properties": { "value": { "type": "string" } } } "then": { "required": ["value"], "properties": { "value": { "type": "string", "maxLength": 1024 } } } } ] }, Loading Loading @@ -405,7 +410,7 @@ }, "max_hops": { "type": "integer", "description": "Maximum number of relationship hops. SECURITY: Limited to 3 to prevent resource exhaustion.", "description": "Maximum number of relationship hops. Limited to prevent resource exhaustion.", "minimum": 1, "maximum": 3, "default": 1 Loading @@ -417,13 +422,14 @@ }, "filters": { "type": "object", "description": "Filters on relationship properties", "description": "Filters on relationship properties. Limited to prevent query complexity explosion.", "propertyNames": { "$ref": "#/$defs/Identifier" }, "additionalProperties": { "$ref": "#/$defs/Filter" }, "maxProperties": 5, "default": {} } }, Loading Loading @@ -452,7 +458,8 @@ "items": { "$ref": "#/$defs/RelationshipTypeName" }, "minItems": 1 "minItems": 1, "maxItems": 10 } ] }, Loading Loading @@ -535,7 +542,8 @@ "description": "Optional: relationship types to traverse. If omitted, all types are considered.", "items": { "$ref": "#/$defs/RelationshipTypeName" } }, "maxItems": 10 } } }, Loading Loading @@ -564,6 +572,7 @@ "items": { "$ref": "#/$defs/RelationshipTypeName" }, "maxItems": 10, "default": [] } } Loading
crates/query-engine/src/validate.rs +54 −9 Original line number Diff line number Diff line Loading @@ -149,15 +149,20 @@ impl<'a> Validator<'a> { } /// Defense-in-depth: reject queries that exceed hard caps on complexity. /// The JSON schema already enforces these limits via maxItems / maximum, /// so this only fires if schema validation was somehow bypassed. /// The JSON schema already enforces these limits via maxItems / maximum / /// maxProperties, so this only fires if schema validation was somehow bypassed. pub fn check_depth(&self, input: &Input) -> Result<()> { const MAX_HOPS_CAP: u32 = 3; const MAX_DEPTH_CAP: u32 = 3; const MAX_NODES_CAP: usize = 5; const MAX_RELS_CAP: usize = 5; const MAX_AGGS_CAP: usize = 10; const MAX_NODE_IDS: usize = 500; const MAX_IN_VALUES: usize = 100; const MAX_FILTERS_PER_NODE: usize = 20; const MAX_FILTERS_PER_REL: usize = 10; const MAX_COLUMNS: usize = 50; const MAX_REL_TYPES: usize = 10; if input.nodes.len() > MAX_NODES_CAP { return Err(QueryError::DepthExceeded(format!( Loading @@ -171,6 +176,12 @@ impl<'a> Validator<'a> { input.relationships.len() ))); } if input.aggregations.len() > MAX_AGGS_CAP { return Err(QueryError::LimitExceeded(format!( "aggregations count ({}) must not exceed {MAX_AGGS_CAP}", input.aggregations.len() ))); } for rel in &input.relationships { if rel.max_hops > MAX_HOPS_CAP { return Err(QueryError::DepthExceeded(format!( Loading @@ -178,15 +189,33 @@ impl<'a> Validator<'a> { rel.max_hops ))); } if rel.types.len() > MAX_REL_TYPES { return Err(QueryError::LimitExceeded(format!( "relationship type count ({}) must not exceed {MAX_REL_TYPES}", rel.types.len() ))); } if let Some(ref path) = input.path && path.max_depth > MAX_DEPTH_CAP { if rel.filters.len() > MAX_FILTERS_PER_REL { return Err(QueryError::LimitExceeded(format!( "relationship filter count ({}) must not exceed {MAX_FILTERS_PER_REL}", rel.filters.len() ))); } } if let Some(ref path) = input.path { if path.max_depth > MAX_DEPTH_CAP { return Err(QueryError::DepthExceeded(format!( "max_depth ({}) must not exceed {MAX_DEPTH_CAP}", path.max_depth ))); } if path.rel_types.len() > MAX_REL_TYPES { return Err(QueryError::LimitExceeded(format!( "path rel_types count ({}) must not exceed {MAX_REL_TYPES}", path.rel_types.len() ))); } } for node in &input.nodes { if node.node_ids.len() > MAX_NODE_IDS { return Err(QueryError::LimitExceeded(format!( Loading @@ -195,6 +224,22 @@ impl<'a> Validator<'a> { node.id ))); } if node.filters.len() > MAX_FILTERS_PER_NODE { return Err(QueryError::LimitExceeded(format!( "filter count ({}) for node \"{}\" must not exceed {MAX_FILTERS_PER_NODE}", node.filters.len(), node.id ))); } if let Some(crate::input::ColumnSelection::List(cols)) = &node.columns && cols.len() > MAX_COLUMNS { return Err(QueryError::LimitExceeded(format!( "columns count ({}) for node \"{}\" must not exceed {MAX_COLUMNS}", cols.len(), node.id ))); } for (prop, filter) in &node.filters { if let Some(FilterOp::In) = filter.op { let len = filter Loading