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
+46 -22
View File
@@ -73,6 +73,15 @@ class GraphService:
self.neo4j = neo4j_client
self.wiki = wikijs_client
# Conservative denylist of Cypher write clauses / procedure calls.
# Matched as whole words against the uppercased query. CALL is rejected
# entirely (covers db.*/apoc.* write procedures and CALL {} subqueries)
# because reliably distinguishing read from write procedures would
# require a real Cypher parser.
_WRITE_CLAUSE_PATTERN = re.compile(
r"\b(CREATE|MERGE|DELETE|DETACH|SET|REMOVE|DROP|FOREACH|LOAD|CALL)\b"
)
async def execute_query(
self,
query: str,
@@ -80,29 +89,39 @@ class GraphService:
user: str
) -> CypherQueryResponse:
"""
Execute user-scoped Cypher query.
Execute a raw Cypher query — READ-ONLY, NOT tenant-scoped.
Automatically injects user label into query for security.
Security model (admin/debug endpoint):
- Queries containing write clauses (CREATE/MERGE/DELETE/SET/REMOVE/
DROP/DETACH/FOREACH/LOAD CSV) or any CALL are rejected up front.
- The query is executed through a session opened with
default_access_mode=READ_ACCESS, so the database itself refuses
writes even if the denylist is bypassed.
- Results are NOT automatically restricted to the user's tenant
labels: an arbitrary Cypher query can read any tenant's nodes.
Callers must scope patterns themselves (e.g. match on
`User_<Tenant>`/`User_<Tenant>_Document` labels).
Args:
query: Cypher query
query: Cypher query (read-only)
parameters: Query parameters
user: User identifier
user: Requesting user (audit logging only — does NOT scope
the query)
Returns:
Query results with metadata
Raises:
ValueError: If the query contains write clauses or fails
"""
start_time = time.time()
# Get user-specific label
user_label = get_neo4j_user_label(user)
self._reject_write_clauses(query)
# Inject user label into query for scoping
# This ensures users can only query their own data
scoped_query = self._scope_query_to_user(query, user_label)
logger.info(f"Read-only Cypher query for user '{user}' (unscoped): {query[:200]}")
try:
results = await self.neo4j.execute_query(scoped_query, parameters)
results = await self.neo4j.execute_read(query, parameters)
query_time_ms = (time.time() - start_time) * 1000
return CypherQueryResponse(
@@ -111,28 +130,33 @@ class GraphService:
query_time_ms=query_time_ms
)
except ValueError:
raise
except Exception as e:
logger.error(f"Cypher query failed: {e}", exc_info=True)
raise ValueError(f"Query execution failed: {str(e)}")
def _scope_query_to_user(self, query: str, user_label: str) -> str:
def _reject_write_clauses(self, query: str) -> None:
"""
Inject user label into Cypher query for multi-tenancy.
Reject Cypher queries containing write clauses or procedure calls.
Simple implementation: adds user label to node patterns.
Production version would use proper query parsing.
Conservative denylist on the uppercased query: false positives are
acceptable (e.g. the word SET in a string literal), false negatives
are not. The read-only session is the hard backstop.
Args:
query: Original Cypher query
user_label: User-specific label
query: Raw Cypher query
Returns:
Scoped query
Raises:
ValueError: If a denylisted clause is found
"""
# For now, return query as-is
# TODO: Implement proper query scoping with label injection
logger.warning("Query scoping not yet implemented - returning unscoped query")
return query
match = self._WRITE_CLAUSE_PATTERN.search(query.upper())
if match:
raise ValueError(
f"Query rejected: '{match.group(1)}' is not allowed — "
"/query/graph and /graph/query are read-only (no write "
"clauses or CALL procedures)"
)
async def list_nodes(
self,