Verified Commit 2e8d00e5 authored by Michael Usachenko's avatar Michael Usachenko Committed by GitLab
Browse files

fix(query-engine): bound all unbounded query DSL fields

parent c2390b8b
Loading
Loading
Loading
Loading
+18 −9
Original line number Diff line number Diff line
@@ -35,6 +35,7 @@
      "type": "array",
      "description": "Aggregation functions to apply. Required when query_type is 'aggregation'.",
      "default": [],
      "maxItems": 10,
      "items": {
        "$ref": "#/$defs/Aggregation"
      }
@@ -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": {
@@ -282,7 +284,8 @@
          "items": {
            "$ref": "#/$defs/Identifier"
          },
          "minItems": 1
          "minItems": 1,
          "maxItems": 50
        }
      ]
    },
@@ -325,7 +328,8 @@
        },
        {
          "type": "string",
          "description": "String value"
          "description": "String value",
          "maxLength": 1024
        },
        {
          "type": "boolean",
@@ -336,7 +340,8 @@
          "description": "List of values",
          "items": {
            "$ref": "#/$defs/FilterValue"
          }
          },
          "maxItems": 100
        }
      ]
    },
@@ -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 } } }
        }
      ]
    },
@@ -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
@@ -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": {}
        }
      },
@@ -452,7 +458,8 @@
          "items": {
            "$ref": "#/$defs/RelationshipTypeName"
          },
          "minItems": 1
          "minItems": 1,
          "maxItems": 10
        }
      ]
    },
@@ -535,7 +542,8 @@
          "description": "Optional: relationship types to traverse. If omitted, all types are considered.",
          "items": {
            "$ref": "#/$defs/RelationshipTypeName"
          }
          },
          "maxItems": 10
        }
      }
    },
@@ -564,6 +572,7 @@
          "items": {
            "$ref": "#/$defs/RelationshipTypeName"
          },
          "maxItems": 10,
          "default": []
        }
      }
+54 −9
Original line number Diff line number Diff line
@@ -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!(
@@ -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!(
@@ -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!(
@@ -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