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:
@@ -8,7 +8,7 @@ Provides async Neo4j operations with:
|
||||
- Automatic retry on transient failures
|
||||
"""
|
||||
|
||||
from neo4j import AsyncGraphDatabase, AsyncDriver, AsyncSession
|
||||
from neo4j import AsyncGraphDatabase, AsyncDriver, AsyncSession, READ_ACCESS
|
||||
from typing import Optional, List, Dict, Any
|
||||
import logging
|
||||
|
||||
@@ -97,6 +97,38 @@ class Neo4jClient:
|
||||
records = await result.data()
|
||||
return records
|
||||
|
||||
async def execute_read(
|
||||
self,
|
||||
cypher: str,
|
||||
parameters: Optional[Dict[str, Any]] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Execute Cypher in a READ-ONLY session.
|
||||
|
||||
The session is opened with default_access_mode=READ_ACCESS, so the
|
||||
database rejects any write attempt (CREATE/MERGE/DELETE/SET/...)
|
||||
even if it slips past caller-side validation. Use this for any
|
||||
query built from untrusted input (e.g. the /query/graph endpoint).
|
||||
|
||||
Args:
|
||||
cypher: Cypher query string
|
||||
parameters: Query parameters
|
||||
|
||||
Returns:
|
||||
List of result records as dictionaries
|
||||
|
||||
Raises:
|
||||
Exception: If driver not initialized, query fails, or the
|
||||
query attempts a write (rejected by the read session)
|
||||
"""
|
||||
if not self._driver:
|
||||
await self.connect()
|
||||
|
||||
async with self._driver.session(default_access_mode=READ_ACCESS) as session:
|
||||
result = await session.run(cypher, parameters or {})
|
||||
records = await result.data()
|
||||
return records
|
||||
|
||||
async def execute_write(
|
||||
self,
|
||||
cypher: str,
|
||||
|
||||
+12
-6
@@ -338,17 +338,23 @@ async def graph_query(
|
||||
api_key: str = Depends(verify_api_key)
|
||||
):
|
||||
"""
|
||||
Execute a Cypher query against the Neo4j knowledge graph.
|
||||
Execute a raw Cypher query against the Neo4j knowledge graph
|
||||
(ADMIN/DEBUG — read-only, NOT tenant-scoped).
|
||||
|
||||
Queries are automatically scoped to the user's data for security.
|
||||
Use this for custom graph traversals beyond what /graph/nodes provides.
|
||||
**Security model:**
|
||||
- Queries containing write clauses (CREATE/MERGE/DELETE/SET/REMOVE/DROP/
|
||||
DETACH/FOREACH/LOAD CSV) or any CALL are rejected with 400.
|
||||
- Execution happens in a read-only Neo4j session, so writes are refused
|
||||
by the database even if validation is bypassed.
|
||||
- Results are NOT automatically restricted to the requesting user's
|
||||
tenant: an arbitrary query can read any tenant's nodes. Scope your
|
||||
own patterns (e.g. `MATCH (d:User_<Tenant>_Document:Document) ...`).
|
||||
For tenant-scoped access use /graph/nodes instead.
|
||||
|
||||
**Example:**
|
||||
```
|
||||
POST /query/graph?query=MATCH%20(d:Document)-[:MENTIONS]->(p:Person)%20RETURN%20d,p&user=jpmschweitzer
|
||||
POST /query/graph?query=MATCH%20(d:Document)-[:MENTIONS]->(p:Person)%20RETURN%20d,p&user=<tenant>
|
||||
```
|
||||
|
||||
**Security:** All queries are user-scoped to prevent cross-user data access.
|
||||
"""
|
||||
from src.services.graph_service import GraphService
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user