{"title":"GraphQL Batching Attack via Unrestricted Query Depth Leading to Denial of Service","language":"Python","severity":"High","cwe":"CWE-400","source_lines":[9],"flow_lines":[9,11],"sink_lines":[11],"vulnerable_code":"from graphql import build_schema, graphql_sync\nfrom flask import Flask, request, jsonify\napp = Flask(__name__)\nschema_str = \"\"\"\n    type Query { org: Org }\n    type Org { name: String, parent: Org }\n\"\"\"\nschema = build_schema(schema_str)\n@app.route('/iot/graphql', methods=['POST'])\ndef iot_device_query():\n    payloads = request.get_json()\n    if not isinstance(payloads, list):\n        payloads = [payloads]\n    results = []\n    for entry in payloads:\n        qry = entry.get('query', '')\n        res = graphql_sync(schema, qry)\n        results.append({'data': res.data, 'errors': [str(e) for e in res.errors] if res.errors else []})\n    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":"from graphql import build_schema, graphql_sync, parse\nfrom flask import Flask, request, jsonify\n\napp = Flask(__name__)\n\nMAX_BATCH_SIZE = 10\nMAX_QUERY_DEPTH = 7\n\nschema_str = \"\"\"\n    type Query { org: Org }\n    type Org { name: String, parent: Org }\n\"\"\"\nschema = build_schema(schema_str)\n\n\ndef measure_depth(node, current_depth=0):\n    \"\"\"Recursively measure the maximum depth of selections in a GraphQL AST node.\"\"\"\n    if not hasattr(node, 'selection_set') or node.selection_set is None:\n        return current_depth\n    max_depth = current_depth\n    for selection in node.selection_set.selections:\n        child_depth = measure_depth(selection, current_depth + 1)\n        if child_depth > max_depth:\n            max_depth = child_depth\n    return max_depth\n\n\ndef validate_query_depth(query_string, max_depth):\n    \"\"\"Parse the query and check that its depth does not exceed the allowed maximum.\"\"\"\n    try:\n        document = parse(query_string)\n    except Exception as e:\n        return False, f\"Query parse error: {str(e)}\"\n    for definition in document.definitions:\n        depth = measure_depth(definition)\n        if depth > max_depth:\n            return False, f\"Query depth {depth} exceeds maximum allowed depth of {max_depth}\"\n    return True, None\n\n\n@app.route('/iot/graphql', methods=['POST'])\ndef iot_device_query():\n    payloads = request.get_json()\n    if payloads is None:\n        return jsonify({\"error\": \"Invalid JSON body\"}), 400\n    if not isinstance(payloads, list):\n        payloads = [payloads]\n\n    # Enforce batch size limit\n    if len(payloads) > MAX_BATCH_SIZE:\n        return jsonify({\"error\": f\"Batch size {len(payloads)} exceeds maximum allowed batch size of {MAX_BATCH_SIZE}\"}), 400\n\n    results = []\n    for entry in payloads:\n        if not isinstance(entry, dict):\n            results.append({'data': None, 'errors': ['Invalid query entry']})\n            continue\n        qry = entry.get('query', '')\n\n        # Enforce query depth limit\n        is_valid, error_msg = validate_query_depth(qry, MAX_QUERY_DEPTH)\n        if not is_valid:\n            results.append({'data': None, 'errors': [error_msg]})\n            continue\n\n        res = graphql_sync(schema, qry)\n        results.append({'data': res.data, 'errors': [str(e) for e in res.errors] if res.errors else []})\n    return jsonify(results)"}