fix(security)!: make raw Cypher endpoints read-only with write-clause denylist

/query/graph scoping was a documented no-op (graph_service returned the
query unscoped) and neo4j_client permitted writes; a live probe showed a
nonexistent user could read the whole graph.

- Add Neo4jClient.execute_read() that opens the session with
  default_access_mode=READ_ACCESS so the database refuses writes even if
  validation is bypassed.
- GraphService.execute_query() now rejects queries containing
  CREATE/MERGE/DELETE/DETACH/SET/REMOVE/DROP/FOREACH/LOAD or any CALL
  (conservative word-boundary denylist on the uppercased query) and
  executes through the read-only session; the no-op _scope_query_to_user
  is removed.
- Remove the false user-scoping claims from /query/graph (main.py) and
  /graph/query docs and the CypherQueryRequest model: the endpoints are
  documented as admin/debug, unscoped read-only (per-tenant label
  injection for arbitrary Cypher would need a real parser; /graph/nodes
  remains the tenant-scoped path).
- Offline unit tests: denylist coverage (incl. lowercase/multiline/CALL),
  word-boundary false-positive check, and READ_ACCESS session assertion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 11:08:40 +02:00
co-authored by Claude Fable 5
parent 84e9185371
commit a4c299bf9b
6 changed files with 216 additions and 35 deletions
+9 -6
View File
@@ -40,21 +40,24 @@ async def execute_cypher_query(
api_key: str = Depends(verify_api_key)
):
"""
Execute a user-scoped Cypher query.
Execute a raw Cypher query (ADMIN/DEBUG — read-only, NOT tenant-scoped).
The query is automatically scoped to the user's data for security.
This prevents users from accessing other users' graph data.
**Security model:**
- Write clauses (CREATE/MERGE/DELETE/SET/REMOVE/DROP/DETACH/FOREACH/
LOAD CSV) and CALL procedures are rejected with 400.
- Execution happens in a read-only Neo4j session as a hard backstop.
- Results are NOT restricted to the requesting user's tenant labels —
scope your own patterns (e.g. match `User_<Tenant>_Document`).
For tenant-scoped access use /graph/nodes instead.
**Example Request:**
```json
{
"query": "MATCH (d:Document) RETURN d LIMIT 10",
"query": "MATCH (d:User_Llm_Tester_Document:Document) RETURN d LIMIT 10",
"parameters": {},
"user": "<tenant>"
}
```
**Security:** Query is automatically scoped with user label.
"""
try:
return await graph_service.execute_query(