# GraphQL Batching Attack via Unrestricted Query Depth Leading to Denial of Service

Language: Python
Severity: High
CWE: CWE-400

## Source
9

## Flow
9-11

## Sink
11

## Vulnerable Code
```python
from graphql import build_schema, graphql_sync
from flask import Flask, request, jsonify
app = Flask(__name__)
schema_str = """
    type Query { org: Org }
    type Org { name: String, parent: Org }
"""
schema = build_schema(schema_str)
@app.route('/iot/graphql', methods=['POST'])
def iot_device_query():
    payloads = request.get_json()
    if not isinstance(payloads, list):
        payloads = [payloads]
    results = []
    for entry in payloads:
        qry = entry.get('query', '')
        res = graphql_sync(schema, qry)
        results.append({'data': res.data, 'errors': [str(e) for e in res.errors] if res.errors else []})
    return jsonify(results)
```

## Explanation

The endpoint accepts a raw JSON body from the request (line 11) without any validation on the number of batched queries or the depth/complexity of each GraphQL query. The recursive schema type 'Org.parent: Org' allows arbitrarily deep nested queries, and graphql_sync executes each one synchronously. An attacker can send a single POST with hundreds of deeply nested queries in a batch array, exhausting CPU and memory and causing a Denial of Service.

## Remediation

The fix introduces two key protections: (1) a maximum batch size limit (MAX_BATCH_SIZE = 10) that rejects requests containing more queries than allowed, preventing batching-based amplification attacks; and (2) a query depth validation function that parses each GraphQL query into an AST and recursively measures its selection depth, rejecting any query that exceeds the configured maximum depth (MAX_QUERY_DEPTH = 7), preventing deeply nested recursive queries from exhausting server resources. Additionally, input validation is added for malformed JSON bodies and non-dict query entries.

## Secure Code
```python
from graphql import build_schema, graphql_sync, parse
from flask import Flask, request, jsonify

app = Flask(__name__)

MAX_BATCH_SIZE = 10
MAX_QUERY_DEPTH = 7

schema_str = """
    type Query { org: Org }
    type Org { name: String, parent: Org }
"""
schema = build_schema(schema_str)


def measure_depth(node, current_depth=0):
    """Recursively measure the maximum depth of selections in a GraphQL AST node."""
    if not hasattr(node, 'selection_set') or node.selection_set is None:
        return current_depth
    max_depth = current_depth
    for selection in node.selection_set.selections:
        child_depth = measure_depth(selection, current_depth + 1)
        if child_depth > max_depth:
            max_depth = child_depth
    return max_depth


def validate_query_depth(query_string, max_depth):
    """Parse the query and check that its depth does not exceed the allowed maximum."""
    try:
        document = parse(query_string)
    except Exception as e:
        return False, f"Query parse error: {str(e)}"
    for definition in document.definitions:
        depth = measure_depth(definition)
        if depth > max_depth:
            return False, f"Query depth {depth} exceeds maximum allowed depth of {max_depth}"
    return True, None


@app.route('/iot/graphql', methods=['POST'])
def iot_device_query():
    payloads = request.get_json()
    if payloads is None:
        return jsonify({"error": "Invalid JSON body"}), 400
    if not isinstance(payloads, list):
        payloads = [payloads]

    # Enforce batch size limit
    if len(payloads) > MAX_BATCH_SIZE:
        return jsonify({"error": f"Batch size {len(payloads)} exceeds maximum allowed batch size of {MAX_BATCH_SIZE}"}), 400

    results = []
    for entry in payloads:
        if not isinstance(entry, dict):
            results.append({'data': None, 'errors': ['Invalid query entry']})
            continue
        qry = entry.get('query', '')

        # Enforce query depth limit
        is_valid, error_msg = validate_query_depth(qry, MAX_QUERY_DEPTH)
        if not is_valid:
            results.append({'data': None, 'errors': [error_msg]})
            continue

        res = graphql_sync(schema, qry)
        results.append({'data': res.data, 'errors': [str(e) for e in res.errors] if res.errors else []})
    return jsonify(results)
```
