Query validation decisions (QV)#

Decisions behind the Cypher validator. For the flow itself, the allowlist, and the test workflow, see query validation.

Primary code: reporting/services/query_validator.py, tests/unit/reporting/services/query_validator_test.py, tests/data/query-fuzzing.csv.

QV-001 — Errors block, warnings never do#

Applies to: validate_query → ValidationResult(errors, warnings)

Layers 1 and 2 (the EXPLAIN read-only check and the SSRF/admin/procedure guard) produce errors. Layers 3 and 4 (CyVer SchemaValidator and PropertiesValidator, both run via asyncio.to_thread) produce warnings only.

Why: CyVer reasons about a schema snapshot. Blocking on it would reject valid queries whenever the graph is mid-sync, and the failure would look like a validator bug rather than a stale schema.

QV-002 — Procedures are allowlisted, not denylisted#

Applies to: _DEFAULT_ALLOWED_PROCEDURES, QUERY_VALIDATOR_ALLOWED_PROCEDURES

Every CALL <procedure> is matched against an allowlist of side-effect-free schema procedures. Extend it with exact names or namespace prefixes (apoc.).

Why: the set of dangerous procedures is open-ended and grows with every plugin. The set of procedures a reporting tool needs is small and known.

Note: a namespace prefix in QUERY_VALIDATOR_ALLOWED_PROCEDURES also drops that namespace’s dangerous-function guard. Granting apoc. is therefore a larger decision than it looks.

QV-003 — The guard scans three forms of the query, and comments are checked two-sided#

Applies to: _scan_for_dangerous_constructs

The original text, the comment-stripped form, and the unicode-decoded form are all scanned for LOAD CSV, the USE clause, admin/catalog commands, and the apoc.cypher.*/gds.*/ai.*/genai.* function namespaces.

Why two-sided on comments: scanning the comment-stripped form catches CALL /* x */ apoc.; scanning the original catches // inside a string literal (a URL, say) where the stripper would hide a following CALL apoc.. Either scan alone has a bypass.

QV-004 — Every attack vector tried is recorded, including the ones that were already blocked#

Applies to: tests/data/query-fuzzing.csv

One row per technique (Technique, Cypher, Result, Blocked_By, Notes), with matching assertions in query_validator_test.py.

Why: the corpus is the record of what has been tried, not just what failed. A vector that is already blocked still belongs in it, because the next person to consider that technique should find it answered rather than re-derive it.

Do, when adding a case: confirm whether Neo4j actually executes the vector (logs: ExternalResourceFailed, or write side effects) → add the CSV row → add the unit test → if it is not blocked, fix query_validator.py first and then prove it with the test.

QV-005 — The generic MCP query reuses validation’s plan and rejects risky scans#

Applies to: ValidationResult.plan, mcp_builtins.graph::_handle_query, MCP_GRAPH_QUERY_REJECT_UNINDEXED, MCP_GRAPH_QUERY_UNINDEXED_MAX_ESTIMATED_ROWS

The validation EXPLAIN is the one planning pass: its result is retained on ValidationResult, returned directly by graph__explain, and inspected before graph__query executes. The generic MCP query is refused when Neo4j emits a performance notification, or when its plan combines a non-index scan with an operator cardinality above the configured threshold. Rejections carry the full plan and the scan/cardinality summary. REST queries and authored toolset tools keep their existing behavior.

Why not reject every scan: a label scan bounded to ten rows and a targeted five-hop path estimated at 2,967 rows both completed locally. The failed internet-reachability shape used the same broad five-hop expansion after a label scan, but its plan estimated 265,885 rows and it repeatedly reached the 30-second transaction timeout. A threshold distinguishes those observed shapes without pretending that the word Scan alone predicts cost.

Why notifications are insufficient: the current Neo4j planner emitted no performance notification for the timed-out shape, an all-node scan, a function-wrapped indexed predicate, bounded or unbounded variable paths, or a Cartesian product in the development graph. They remain a useful signal when present, but making them the only signal would admit the failure this guard is for.

Why the plan is retained: graph__query already paid for EXPLAIN to enforce read-only execution. Calling graph__explain first previously planned twice inside that tool and a third time during graph__query, while adding another model/tool round trip. Keeping the first result makes the guard and its diagnostic response no additional database call.